diff --git a/CHANGELOG.md b/CHANGELOG.md index a97eafc..210db8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features - (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 [Dracula](https://ui.draculatheme.com/) theme. - (CSS) New `TabPane` style. There are three styles supported: default, floating and classic (new one). diff --git a/base/src/main/java/atlantafx/base/controls/MaskTextField.java b/base/src/main/java/atlantafx/base/controls/MaskTextField.java new file mode 100644 index 0000000..5224dfe --- /dev/null +++ b/base/src/main/java/atlantafx/base/controls/MaskTextField.java @@ -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 mask) { + super(text); + this.formatter = MaskTextFormatter.create(this, mask); + } +} diff --git a/base/src/main/java/atlantafx/base/util/MaskChar.java b/base/src/main/java/atlantafx/base/util/MaskChar.java new file mode 100644 index 0000000..577ef97 --- /dev/null +++ b/base/src/main/java/atlantafx/base/util/MaskChar.java @@ -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(); +} \ No newline at end of file diff --git a/base/src/main/java/atlantafx/base/util/MaskTextFormatter.java b/base/src/main/java/atlantafx/base/util/MaskTextFormatter.java new file mode 100644 index 0000000..58a3775 --- /dev/null +++ b/base/src/main/java/atlantafx/base/util/MaskTextFormatter.java @@ -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.

+ * + *

Input Mask

+ * 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: + * + * + *

Behavior

+ * 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. + *

The caret will be positioned before the first not fixed character (see {@link MaskChar#isFixed()}) + * starting from the beginning of the input mask.

+ * + *

Validation

+ * 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 { + + 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 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 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 mask) { + return mask.stream() + .map(mc -> Character.toString(mc.getPlaceholder())) + .collect(Collectors.joining()); + } + + protected static List fromString(String inputMask) { + if (inputMask == null || inputMask.trim().isEmpty()) { + throw new IllegalArgumentException("Input mask can't be null or empty."); + } + + var mask = new ArrayList(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 { + + protected final List mask; + protected boolean ignoreFilter; + + public MaskTextFilter(List 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); + } + } +} diff --git a/base/src/main/java/atlantafx/base/util/SimpleMaskChar.java b/base/src/main/java/atlantafx/base/util/SimpleMaskChar.java new file mode 100644 index 0000000..663c43f --- /dev/null +++ b/base/src/main/java/atlantafx/base/util/SimpleMaskChar.java @@ -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 matchExpr; + private final UnaryOperator transform; + private final char placeholder; + private final boolean fixed; + + public SimpleMaskChar(Predicate matchExpr) { + this(matchExpr, UnaryOperator.identity(), UNDERSCORE, false); + } + + public SimpleMaskChar(Predicate matchExpr, + UnaryOperator transform) { + this(matchExpr, transform, UNDERSCORE, false); + } + + public SimpleMaskChar(Predicate matchExpr, + UnaryOperator transform, + char placeholder) { + this(matchExpr, transform, placeholder, false); + } + + public SimpleMaskChar(Predicate matchExpr, + UnaryOperator 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); + } +} \ No newline at end of file diff --git a/base/src/test/java/atlantafx/base/util/MaskTextFormatterTest.java b/base/src/test/java/atlantafx/base/util/MaskTextFormatterTest.java new file mode 100644 index 0000000..df0b03e --- /dev/null +++ b/base/src/test/java/atlantafx/base/util/MaskTextFormatterTest.java @@ -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: + *
+     * field.setText("foo");
+     * field.deleteText(0, 1);
+     * field.setText(null); // no exception here
+     * 
+ * 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"); + } +} \ No newline at end of file diff --git a/sampler/src/main/java/atlantafx/sampler/layout/Sidebar.java b/sampler/src/main/java/atlantafx/sampler/layout/Sidebar.java index 527346f..1d33086 100644 --- a/sampler/src/main/java/atlantafx/sampler/layout/Sidebar.java +++ b/sampler/src/main/java/atlantafx/sampler/layout/Sidebar.java @@ -236,7 +236,7 @@ class Sidebar extends StackPane { navLink(CheckBoxPage.NAME, CheckBoxPage.class), navLink(ColorPickerPage.NAME, ColorPickerPage.class), navLink(ComboBoxPage.NAME, ComboBoxPage.class, "ChoiceBox"), - navLink(CustomTextFieldPage.NAME, CustomTextFieldPage.class), + navLink(CustomTextFieldPage.NAME, CustomTextFieldPage.class, "MaskTextField"), navLink(DatePickerPage.NAME, DatePickerPage.class), navLink(DialogPage.NAME, DialogPage.class), navLink(HtmlEditorPage.NAME, HtmlEditorPage.class), diff --git a/sampler/src/main/java/atlantafx/sampler/page/components/CustomTextFieldPage.java b/sampler/src/main/java/atlantafx/sampler/page/components/CustomTextFieldPage.java index 6000317..fef3a6e 100644 --- a/sampler/src/main/java/atlantafx/sampler/page/components/CustomTextFieldPage.java +++ b/sampler/src/main/java/atlantafx/sampler/page/components/CustomTextFieldPage.java @@ -4,15 +4,25 @@ package atlantafx.sampler.page.components; import static atlantafx.base.theme.Styles.STATE_DANGER; 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.MaskTextField; import atlantafx.base.util.PasswordTextFormatter; import atlantafx.sampler.page.AbstractPage; 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.control.Label; 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.javafx.FontIcon; +import org.kordamp.ikonli.material2.Material2OutlinedAL; +import org.kordamp.ikonli.material2.Material2OutlinedMZ; public class CustomTextFieldPage extends AbstractPage { @@ -33,7 +43,8 @@ public class CustomTextFieldPage extends AbstractPage { bothIconsSample(), successSample(), dangerSample(), - passwordSample() + passwordSample(), + maskSample() )); } @@ -99,4 +110,41 @@ public class CustomTextFieldPage extends AbstractPage { 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); + } }