Add MaskTextField

This commit is contained in:
mkpaz 2023-03-11 16:30:40 +04:00
parent c40ec4e3b3
commit d521f8ac4a
8 changed files with 805 additions and 2 deletions

@ -5,6 +5,7 @@
### Features ### Features
- (Base) New `DeckPane` component with swipe and slide transition support. - (Base) New `DeckPane` component with swipe and slide transition support.
- (Base) New `MaskTextField` (and `MaskTextFormatter`) component to support masked text input.
- (CSS) 🚀 New MacOS-like Cupertino theme in light and dark variants. - (CSS) 🚀 New MacOS-like Cupertino theme in light and dark variants.
- (CSS) 🚀 New [Dracula](https://ui.draculatheme.com/) theme. - (CSS) 🚀 New [Dracula](https://ui.draculatheme.com/) theme.
- (CSS) New `TabPane` style. There are three styles supported: default, floating and classic (new one). - (CSS) New `TabPane` style. There are three styles supported: default, floating and classic (new one).

@ -0,0 +1,57 @@
/*
* SPDX-License-Identifier: MIT
*
* Copyright (c) 2013, 2016, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package atlantafx.base.controls;
import atlantafx.base.util.MaskChar;
import atlantafx.base.util.MaskTextFormatter;
import java.util.List;
import javafx.beans.NamedArg;
/**
* This is a convenience wrapper for instantiating a {@link CustomTextField}
* with {@code MaskTextFormatter}. For additional info refer to the {@link MaskTextFormatter}
* docs.
*/
public class MaskTextField extends CustomTextField {
protected final MaskTextFormatter formatter;
public MaskTextField(@NamedArg("text") String mask) {
this("", mask);
}
public MaskTextField(@NamedArg("text") String text, @NamedArg("mask") String mask) {
super(text);
this.formatter = MaskTextFormatter.create(this, mask);
}
public MaskTextField(String text, List<MaskChar> mask) {
super(text);
this.formatter = MaskTextFormatter.create(this, mask);
}
}

@ -0,0 +1,47 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.base.util;
/**
* Provides basic API for defining an input mask.
*/
public interface MaskChar {
char UNDERSCORE = '_';
char INPUT_MASK_LETTER = 'A';
char INPUT_MASK_DIGIT_OR_LETTER = 'N';
char INPUT_MASK_ANY_NON_SPACE = 'X';
char INPUT_MASK_HEX = 'H';
char INPUT_MASK_DIGIT_NON_ZERO = 'D';
char INPUT_MASK_DIGIT = '9';
char INPUT_MASK_DIGIT_0_TO_8 = '8';
char INPUT_MASK_DIGIT_0_TO_7 = '7';
char INPUT_MASK_DIGIT_0_TO_6 = '6';
char INPUT_MASK_DIGIT_0_TO_5 = '5';
char INPUT_MASK_DIGIT_0_TO_4 = '4';
char INPUT_MASK_DIGIT_0_TO_3 = '3';
char INPUT_MASK_DIGIT_0_TO_2 = '2';
char INPUT_MASK_DIGIT_0_TO_1 = '1';
char INPUT_MASK_DIGIT_ZERO = '0';
/**
* Returns true if the character is allowed, false otherwise.
*/
boolean isAllowed(char ch);
/**
* Transforms user input character before setting.
*/
char transform(char ch);
/**
* Returns the placeholder for the mask character.
*/
char getPlaceholder();
/**
* Returns whether character is fixed (prefix, suffix or separator).
*/
boolean isFixed();
}

@ -0,0 +1,385 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.base.util;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import javafx.application.Platform;
import javafx.scene.control.TextField;
import javafx.scene.control.TextFormatter;
import org.jetbrains.annotations.Nullable;
/**
* A {@code TextFormatter} that can restrict the user input by applying a position-based mask.
* It works for the editing cases when the input string has a fixed length and each character
* can be restricted based on its position.<br/><br/>
*
* <p><h3>Input Mask</h3>
* You can specify an input mask either as a string of the predefined characters or as a list of
* {@link MaskChar}, including your own implementation if you're not satisfied with the default
* {@link SimpleMaskChar}, e.g. if you want to override the placeholder character.
* The pre-defined mask characters are:
* <ul>
* <li><b>A</b> - ASCII alphabetic character: <code>[a-zA-Z]</code>.</li>
* <li><b>N</b> - ASCII alphanumeric character: <code>[a-zA-Z0-9]</code>.</li>
* <li><b>X</b> - any character except spaces.</li>
* <li><b>H</b> - hexadecimal character: <code>[a-fA-F0-9]</code>.</li>
* <li><b>D</b> - any digit except zero: <code>[1-9]</code>.</li>
* <li><b>9</b> - any digit required: <code>[0-9]</code>.</li>
* <li><b>8..1</b> - any digit from 0 to that number, respectively.</li>
* <li><b>0</b> - zero only.</li>
* </ul>
*
* <p><h3>Behavior</h3>
* Any {@code TextField} with {@code MaskTextFormatter} applied shows a placeholder mask by default.
* This is basically the input mask with all mask characters replaced with {@link MaskChar#getPlaceholder()}.
* The behavior changes if you set {@link TextField#promptTextProperty()}. In that case placeholder
* mask is only displayed when {@code TextField} gets focus and will be hidden after focus lost.
* So, the placeholder mask is always displayed when focus is set to the {@code TextField}.
* You can replace the placeholder mask with any sensible default simply by changing initial
* {@code TextField} text to any string that is valid against the input mask.
* <br/><br/>The caret will be positioned before the first not fixed character (see {@link MaskChar#isFixed()})
* starting from the beginning of the input mask.<br/><br/>
*
* <p><h3>Validation</h3>
* Validation is out of the {@code MaskTextFormatter} scope. E.g. if one can use {@code "29:59"}
* to restrict time picker input then {@code "27:30"} would be a valid input, but obviously an
* invalid time. Moreover, remember that partial input like this {@code "22:_9"} is also possible.
* Input mask is supposed to assist and guide user input, but can barely cancel the validation
* completely.
*/
public class MaskTextFormatter extends TextFormatter<String> {
protected final MaskTextFilter filter;
protected MaskTextFormatter(MaskTextFilter filter) {
super(filter);
this.filter = filter;
}
public MaskTextFormatter(MaskTextFilter filter, TextField field) {
super(filter);
this.filter = filter;
}
///////////////////////////////////////////////////////////////////////////
// Factory Methods //
///////////////////////////////////////////////////////////////////////////
/**
* Creates a new text field with the provided string input mask.
* Use this if you create your controls from Java code and don't need to
* modify the default {@link MaskChar} implementation.
*/
public static TextField createTextField(String mask) {
return createTextField(fromString(mask));
}
/**
* Creates a new text field with the provided input mask.
* Use this if you create your controls from Java code and want to
* modify the default {@link MaskChar} implementation.
*/
public static TextField createTextField(List<MaskChar> mask) {
final var field = new TextField();
create(field, mask);
return field;
}
/**
* Creates a new mask text formatter with the provided string input mask and
* applies itself to the specified text field. Use this if you create your
* controls from FXML and don't need to modify the default {@link MaskChar}
* implementation.
*/
public static MaskTextFormatter create(TextField field, String mask) {
return create(field, fromString(mask));
}
/**
* Creates a new mask text formatter with the provided input mask and
* applies itself to the specified text field. Use this if you create your
* controls from FXML and want to modify the default {@link MaskChar} implementation.
*/
public static MaskTextFormatter create(TextField field, List<MaskChar> mask) {
Objects.requireNonNull(field, "Text field can't be null");
if (mask == null || mask.isEmpty()) {
throw new IllegalArgumentException("Input mask can't be null or empty.");
}
final var placeholder = createPlaceholderMask(mask);
final var filter = new MaskTextFilter(mask);
field.focusedProperty().addListener((obs, old, val) -> {
var text = field.getText();
var prompt = field.getPromptText();
if (val) {
// always show input mask to the user when control is focused and has no text
if (text == null || text.isBlank()) {
filter.doInternalChange(() -> field.setText(placeholder));
final int caretPos = IntStream.range(0, mask.size())
.filter(i -> !mask.get(i).isFixed())
.findFirst()
.orElse(0);
Platform.runLater(() -> {
field.deselect();
field.positionCaret(caretPos);
});
}
} else {
// remove the input mask, but only if control prompt text is set
if (prompt != null && !prompt.isBlank() && Objects.equals(placeholder, text)) {
filter.doInternalChange(() -> field.setText(""));
}
}
});
field.promptTextProperty().addListener((obs, old, val) -> {
if (val == null || val.isBlank()) {
filter.doInternalChange(() -> field.setText(placeholder));
} else {
filter.doInternalChange(() -> field.setText(""));
}
});
var formatter = new MaskTextFormatter(filter);
field.setTextFormatter(formatter);
// default text, will be changed after/if prompt text is set
filter.doInternalChange(() -> field.setText(placeholder));
return formatter;
}
///////////////////////////////////////////////////////////////////////////
// Helpers //
///////////////////////////////////////////////////////////////////////////
protected static String createPlaceholderMask(String inputMask) {
return createPlaceholderMask(fromString(inputMask));
}
protected static String createPlaceholderMask(List<MaskChar> mask) {
return mask.stream()
.map(mc -> Character.toString(mc.getPlaceholder()))
.collect(Collectors.joining());
}
protected static List<MaskChar> fromString(String inputMask) {
if (inputMask == null || inputMask.trim().isEmpty()) {
throw new IllegalArgumentException("Input mask can't be null or empty.");
}
var mask = new ArrayList<MaskChar>(inputMask.trim().length());
for (int i = 0; i < inputMask.length(); i++) {
char curChar = inputMask.charAt(i);
SimpleMaskChar maskChar = switch (curChar) {
case MaskChar.INPUT_MASK_LETTER -> new SimpleMaskChar(Character::isLetter);
case MaskChar.INPUT_MASK_DIGIT_OR_LETTER -> new SimpleMaskChar(Character::isLetterOrDigit);
case MaskChar.INPUT_MASK_ANY_NON_SPACE -> new SimpleMaskChar(ch -> !Character.isSpaceChar(ch));
case MaskChar.INPUT_MASK_HEX -> new SimpleMaskChar(ch ->
(ch >= 'A' && ch <= 'F') || (ch >= 'a' && ch <= 'f') || Character.isDigit(ch)
);
case MaskChar.INPUT_MASK_DIGIT_NON_ZERO -> new SimpleMaskChar(ch -> Character.isDigit(ch) && ch != '0');
case MaskChar.INPUT_MASK_DIGIT -> new SimpleMaskChar(Character::isDigit);
case MaskChar.INPUT_MASK_DIGIT_0_TO_8 -> new SimpleMaskChar(ch -> ch >= '0' && ch <= '8');
case MaskChar.INPUT_MASK_DIGIT_0_TO_7 -> new SimpleMaskChar(ch -> ch >= '0' && ch <= '7');
case MaskChar.INPUT_MASK_DIGIT_0_TO_6 -> new SimpleMaskChar(ch -> ch >= '0' && ch <= '6');
case MaskChar.INPUT_MASK_DIGIT_0_TO_5 -> new SimpleMaskChar(ch -> ch >= '0' && ch <= '5');
case MaskChar.INPUT_MASK_DIGIT_0_TO_4 -> new SimpleMaskChar(ch -> ch >= '0' && ch <= '4');
case MaskChar.INPUT_MASK_DIGIT_0_TO_3 -> new SimpleMaskChar(ch -> ch >= '0' && ch <= '3');
case MaskChar.INPUT_MASK_DIGIT_0_TO_2 -> new SimpleMaskChar(ch -> ch >= '0' && ch <= '2');
case MaskChar.INPUT_MASK_DIGIT_0_TO_1 -> new SimpleMaskChar(ch -> ch >= '0' && ch <= '1');
case MaskChar.INPUT_MASK_DIGIT_ZERO -> new SimpleMaskChar(ch -> ch == '0');
default -> SimpleMaskChar.fixed(curChar);
};
mask.add(maskChar);
}
return Collections.unmodifiableList(mask);
}
///////////////////////////////////////////////////////////////////////////
// Filter Implementation //
///////////////////////////////////////////////////////////////////////////
protected static class MaskTextFilter implements UnaryOperator<Change> {
protected final List<MaskChar> mask;
protected boolean ignoreFilter;
public MaskTextFilter(List<MaskChar> mask) {
this.mask = Objects.requireNonNull(mask);
}
public boolean isInternalChange() {
return ignoreFilter;
}
public void doInternalChange(Runnable r) {
try {
this.ignoreFilter = true;
r.run();
} finally {
this.ignoreFilter = false;
}
}
@Override
public Change apply(Change change) {
if (!change.isContentChange() || isInternalChange()) {
return change;
}
return correctContentChange(change);
}
/**
* Corrects and returns the content change if it matches the mask.
* Otherwise, returns null to drop the change.
*/
protected @Nullable Change correctContentChange(Change change) {
String newText = null;
if (change.isReplaced()) {
newText = correctReplacedText(change);
} else if (change.isAdded()) {
newText = correctAddedText(change);
} else if (change.isDeleted()) {
newText = correctDeletedText(change);
}
if (newText != null) {
int start = change.getRangeStart();
change.setRange(start, Math.min(start + newText.length(), change.getControlText().length()));
change.setText(newText);
}
// only adjust caret pos for content changes, as it allows
// to select and copy arbitrary part of the text including fixed chars
adjustCaretPosition(change);
return newText != null ? change : null;
}
/**
* Corrects the replaced text. For any replaced character it checks whether it matches
* the mask character at the same position and applies {@link MaskChar#transform(char)}.
* if true. If not, returns null, thus signifying that added text is not valid.
*/
protected @Nullable String correctReplacedText(Change change) {
final int start = change.getRangeStart();
final int end = change.getRangeEnd();
final var changedText = change.getText();
final var newText = new StringBuilder(end - start);
// replaces new text with transformed according to mask
// or exits if the new text doesn't match the mask
for (int i = start; i - start < changedText.length() && i < end && i < mask.size(); i++) {
final char ch = changedText.charAt(i - start);
if (mask.get(i).isAllowed(ch)) {
newText.append(mask.get(i).transform(ch));
} else {
return null; // mark all replaced text as invalid
}
}
// replace is basically 'remove + add' and this handles the situation when
// removed text length is greater than added text length, e.g. select 'abc' and type 'd',
// in that case the rest of the text (bc) should be replaced with placeholders
for (int i = start + changedText.length(); i < end && i < mask.size(); i++) {
newText.append(mask.get(i).getPlaceholder());
}
return newText.toString();
}
/**
* Corrects added text. For any input character it checks whether it matches
* the mask character at the same position and applies {@link MaskChar#transform(char)}.
* if true. If not, returns null, thus signifying that added text is not valid.
*/
protected @Nullable String correctAddedText(Change change) {
final int start = change.getRangeStart();
final var changedText = change.getText();
final var newText = new StringBuilder(changedText.length());
for (int i = start; i - start < changedText.length() && i < mask.size(); i++) {
final char ch = changedText.charAt(i - start);
if (mask.get(i).isAllowed(ch)) {
newText.append(mask.get(i).transform(ch));
} else {
return null; // mark all added text as invalid
}
}
return newText.toString();
}
/**
* Corrects the deleted text. Basically, replaces all deleted characters with
* placeholders and returns the resulting text which is always not null.
*/
protected String correctDeletedText(Change change) {
int start = change.getRangeStart();
final int end = change.getRangeEnd();
final var newText = new StringBuilder(end - start);
// replaces deleted text with placeholders
for (int i = start; i < end; i++) {
newText.append(mask.get(i).getPlaceholder());
}
// handles the situation when backspace is pressed to delete a fixes char (separator),
// in that case the character before the separator, if any, should be removed
for (int i = start; i > 0 && mask.get(i).isFixed(); i--, start--) {
newText.insert(0, mask.get(i - 1).getPlaceholder());
}
change.setRange(start, end);
return newText.toString();
}
protected void adjustCaretPosition(Change change) {
final int oldPos = change.getControlCaretPosition();
int newPos = Math.min(change.getCaretPosition(), mask.size());
if (oldPos != newPos) {
// caret can't be placed before a fixed character,
// it jumps over it to the previous or the next character
final int sign = newPos > oldPos ? 1 : -1;
while (newPos > 0 && newPos < mask.size() && mask.get(newPos).isFixed()) {
newPos += sign;
}
// prevents caret from moving before a fixed prefix,
while (newPos < mask.size() && mask.get(newPos).isFixed()) {
newPos++;
}
}
// make sure caret position won't exceed control text length
newPos = Math.min(newPos, change.getControlNewText().length());
if (change.getAnchor() == change.getCaretPosition()) {
change.setAnchor(newPos);
}
change.setCaretPosition(newPos);
}
}
}

@ -0,0 +1,68 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.base.util;
import java.util.Objects;
import java.util.function.Predicate;
import java.util.function.UnaryOperator;
/**
* The default {@link MaskChar} implementation that should be suitable
* for anything except heavily custom logic.
*/
final class SimpleMaskChar implements MaskChar {
private final Predicate<Character> matchExpr;
private final UnaryOperator<Character> transform;
private final char placeholder;
private final boolean fixed;
public SimpleMaskChar(Predicate<Character> matchExpr) {
this(matchExpr, UnaryOperator.identity(), UNDERSCORE, false);
}
public SimpleMaskChar(Predicate<Character> matchExpr,
UnaryOperator<Character> transform) {
this(matchExpr, transform, UNDERSCORE, false);
}
public SimpleMaskChar(Predicate<Character> matchExpr,
UnaryOperator<Character> transform,
char placeholder) {
this(matchExpr, transform, placeholder, false);
}
public SimpleMaskChar(Predicate<Character> matchExpr,
UnaryOperator<Character> transform,
char placeholder,
boolean fixed) {
this.matchExpr = Objects.requireNonNull(matchExpr);
this.transform = Objects.requireNonNull(transform);
this.placeholder = placeholder;
this.fixed = fixed;
}
@Override
public boolean isAllowed(final char ch) {
return matchExpr.test(ch);
}
@Override
public char transform(final char ch) {
return transform.apply(ch);
}
@Override
public char getPlaceholder() {
return placeholder;
}
@Override
public boolean isFixed() {
return fixed;
}
public static SimpleMaskChar fixed(char ch) {
return new SimpleMaskChar(c -> c == ch, UnaryOperator.identity(), ch, true);
}
}

@ -0,0 +1,197 @@
package atlantafx.base.util;
import static atlantafx.base.util.MaskTextFormatter.createPlaceholderMask;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
import atlantafx.base.JavaFXTest;
import javafx.beans.property.SimpleStringProperty;
import javafx.scene.control.TextFormatter.Change;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ExtendWith({JavaFXTest.class})
public class MaskTextFormatterTest {
@Test
public void testDefaultTextIsPlaceholderMask() {
var field = MaskTextFormatter.createTextField("+DD-DD");
assertThat(field.getText()).isEqualTo(createPlaceholderMask("+DD-DD"));
}
@Test
public void testValidInitialTextSet() {
var field = MaskTextFormatter.createTextField("+DD-DD");
field.setText("+11-22");
assertThat(field.getText()).isEqualTo("+11-22");
}
@Test
public void testInvalidInitialTextRejected() {
var field = MaskTextFormatter.createTextField("+DD-DD");
field.setText("123");
assertThat(field.getText()).isEqualTo(createPlaceholderMask("+DD-DD"));
}
@Test
public void testPromptTextResetsPlaceholderMask() {
var field = MaskTextFormatter.createTextField("+DD-DD");
field.setText("+11-22");
field.setPromptText("whatever");
assertThat(field.getText()).isEmpty();
}
/**
* This one is a #javafx-bug. Calling the {@link Change#getControlNewText()}
* in text formatter when text is set to null produces IndexOutOfBoundsException
* or IllegalArgumentException (start must <= end). Ironically, there won't be
* exception if you made some text manipulations first, like:
* <pre>
* field.setText("foo");
* field.deleteText(0, 1);
* field.setText(null); // no exception here
* </pre>
* It's worth to mention that the default text field value is empty string precisely,
* not null.
*/
@Test
public void testInitialTextCanNotBeNull() {
var field = MaskTextFormatter.createTextField("+DD-DD");
assertThatThrownBy(() -> field.setText(null)).isInstanceOf(IndexOutOfBoundsException.class);
}
@Test
public void testAppendText() {
var field = MaskTextFormatter.createTextField("+DD-DD");
field.setText(createPlaceholderMask("+DD-DD"));
field.appendText("12");
assertThat(field.getText()).isEqualTo("+__-__");
}
@Test
public void testInsertText() {
var field = MaskTextFormatter.createTextField("+DD-DD");
field.setText(createPlaceholderMask("+DD-DD"));
field.insertText(1, "12");
assertThat(field.getText()).isEqualTo("+12-__");
field.insertText(4, "34");
assertThat(field.getText()).isEqualTo("+12-34");
}
@Test
public void testInsertInvalidText() {
var field = MaskTextFormatter.createTextField("+DD-DD");
field.setText(createPlaceholderMask("+DD-DD"));
field.insertText(1, "12");
assertThat(field.getText()).isEqualTo("+12-__");
field.insertText(4, "A2");
assertThat(field.getText()).isEqualTo("+12-__");
}
@Test
public void testDeleteSomeText() {
var field = MaskTextFormatter.createTextField("+DD-DD");
field.setText("+12-34");
field.deleteText(2, 3);
assertThat(field.getText()).isEqualTo("+1_-34");
field.deleteText(4, 6);
assertThat(field.getText()).isEqualTo("+1_-__");
}
@Test
public void testDeleteAllText() {
var field = MaskTextFormatter.createTextField("+DD-DD");
field.setText("+12-34");
field.deleteText(0, field.getText().length());
assertThat(field.getText()).isEqualTo(createPlaceholderMask("+DD-DD"));
}
@Test
public void testSetTextToNull() {
var field = MaskTextFormatter.createTextField("+DD-DD");
field.setText("+12-34");
field.deleteText(2, 3);
assertThat(field.getText()).isEqualTo("+1_-34");
field.setText(null);
assertThat(field.getText()).isEqualTo(createPlaceholderMask("+DD-DD"));
}
@Test
public void testReplaceSelection() {
var field = MaskTextFormatter.createTextField("+DD-DD");
field.setText("+12-34");
field.selectRange(1, 3);
field.replaceSelection("56");
assertThat(field.getText()).isEqualTo("+56-34");
}
@Test
public void testReplaceSelectionWithInvalidText() {
var field = MaskTextFormatter.createTextField("+DD-DD");
field.setText("+12-34");
field.selectRange(1, 3);
field.replaceSelection("A2");
assertThat(field.getText()).isEqualTo("+12-34");
}
@Test
public void testReplaceAll() {
var field = MaskTextFormatter.createTextField("+DD-DD");
field.setText("+12-34");
field.selectRange(0, field.getText().length());
field.replaceSelection("+43-21");
assertThat(field.getText()).isEqualTo("+43-21");
}
@Test
public void testReplaceAllWithInvalidText() {
var field = MaskTextFormatter.createTextField("+DD-DD");
field.setText("+12-34");
field.selectRange(0, field.getText().length());
field.replaceSelection("+A2-21");
assertThat(field.getText()).isEqualTo("+12-34");
}
@Test
public void testBindingValidText() {
var field = MaskTextFormatter.createTextField("+DD-DD");
var prop = new SimpleStringProperty();
prop.bindBidirectional(field.textProperty());
assertThat(prop.get()).isEqualTo(createPlaceholderMask("+DD-DD"));
assertThat(field.getText()).isEqualTo(createPlaceholderMask("+DD-DD"));
prop.set("+12-34");
assertThat(field.getText()).isEqualTo("+12-34");
}
@Test
public void testBindingInvalidText() {
var field = MaskTextFormatter.createTextField("+DD-DD");
var prop = new SimpleStringProperty();
prop.bindBidirectional(field.textProperty());
assertThat(prop.get()).isEqualTo(createPlaceholderMask("+DD-DD"));
assertThat(field.getText()).isEqualTo(createPlaceholderMask("+DD-DD"));
prop.set("+12-34");
assertThat(field.getText()).isEqualTo("+12-34");
prop.set("+A2-34");
assertThat(field.getText()).isEqualTo("+12-34");
}
}

@ -236,7 +236,7 @@ class Sidebar extends StackPane {
navLink(CheckBoxPage.NAME, CheckBoxPage.class), navLink(CheckBoxPage.NAME, CheckBoxPage.class),
navLink(ColorPickerPage.NAME, ColorPickerPage.class), navLink(ColorPickerPage.NAME, ColorPickerPage.class),
navLink(ComboBoxPage.NAME, ComboBoxPage.class, "ChoiceBox"), navLink(ComboBoxPage.NAME, ComboBoxPage.class, "ChoiceBox"),
navLink(CustomTextFieldPage.NAME, CustomTextFieldPage.class), navLink(CustomTextFieldPage.NAME, CustomTextFieldPage.class, "MaskTextField"),
navLink(DatePickerPage.NAME, DatePickerPage.class), navLink(DatePickerPage.NAME, DatePickerPage.class),
navLink(DialogPage.NAME, DialogPage.class), navLink(DialogPage.NAME, DialogPage.class),
navLink(HtmlEditorPage.NAME, HtmlEditorPage.class), navLink(HtmlEditorPage.NAME, HtmlEditorPage.class),

@ -4,15 +4,25 @@ package atlantafx.sampler.page.components;
import static atlantafx.base.theme.Styles.STATE_DANGER; import static atlantafx.base.theme.Styles.STATE_DANGER;
import static atlantafx.base.theme.Styles.STATE_SUCCESS; import static atlantafx.base.theme.Styles.STATE_SUCCESS;
import static atlantafx.sampler.page.SampleBlock.BLOCK_HGAP;
import atlantafx.base.controls.CustomTextField; import atlantafx.base.controls.CustomTextField;
import atlantafx.base.controls.MaskTextField;
import atlantafx.base.util.PasswordTextFormatter; import atlantafx.base.util.PasswordTextFormatter;
import atlantafx.sampler.page.AbstractPage; import atlantafx.sampler.page.AbstractPage;
import atlantafx.sampler.page.SampleBlock; import atlantafx.sampler.page.SampleBlock;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import javafx.geometry.Pos;
import javafx.scene.Cursor; import javafx.scene.Cursor;
import javafx.scene.control.Label;
import javafx.scene.layout.FlowPane; import javafx.scene.layout.FlowPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.kordamp.ikonli.feather.Feather; import org.kordamp.ikonli.feather.Feather;
import org.kordamp.ikonli.javafx.FontIcon; import org.kordamp.ikonli.javafx.FontIcon;
import org.kordamp.ikonli.material2.Material2OutlinedAL;
import org.kordamp.ikonli.material2.Material2OutlinedMZ;
public class CustomTextFieldPage extends AbstractPage { public class CustomTextFieldPage extends AbstractPage {
@ -33,7 +43,8 @@ public class CustomTextFieldPage extends AbstractPage {
bothIconsSample(), bothIconsSample(),
successSample(), successSample(),
dangerSample(), dangerSample(),
passwordSample() passwordSample(),
maskSample()
)); ));
} }
@ -99,4 +110,41 @@ public class CustomTextFieldPage extends AbstractPage {
return new SampleBlock("Password", tf); return new SampleBlock("Password", tf);
} }
private SampleBlock maskSample() {
var phoneField = new MaskTextField("(999) 999 99 99");
phoneField.setPromptText("(999) 999 99 99");
phoneField.setLeft(new FontIcon(Material2OutlinedMZ.PHONE));
phoneField.setPrefWidth(180);
var cardField = new MaskTextField("9999-9999-9999-9999");
cardField.setLeft(new FontIcon(Material2OutlinedAL.CREDIT_CARD));
cardField.setPrefWidth(200);
var timeFormatter = DateTimeFormatter.ofPattern("HH:mm");
var timeField = new MaskTextField("29:59");
timeField.setText(LocalTime.now().format(timeFormatter));
timeField.setLeft(new FontIcon(Material2OutlinedMZ.TIMER));
timeField.setPrefWidth(120);
timeField.textProperty().addListener((obs, old, val) -> {
if (val != null) {
try {
LocalTime.parse(val, timeFormatter);
timeField.pseudoClassStateChanged(STATE_DANGER, false);
} catch (Exception e) {
timeField.pseudoClassStateChanged(STATE_DANGER, true);
}
}
});
var content = new HBox(
BLOCK_HGAP,
new VBox(5, new Label("Phone Number"), phoneField),
new VBox(5, new Label("Bank Card"), cardField),
new VBox(5, new Label("Time"), timeField)
);
content.setAlignment(Pos.CENTER_LEFT);
return new SampleBlock("Input Mask", content);
}
} }