فهرست منبع

:boom: New control MFXSpinner, as requested by #4

Controls Package
:bug: BoundTextField: remove text formatter binding as it was causing an exception, also it works anyway, so it was unnecessary
:bug: MFXIconWrapper: fix NullPointerException when creating an empty wrapper
:recycle: MFXIconWrapper: add handler to acquire focus

Utils Package
:bug: ListChangeProcessor: fix computeRemoval() method returning negative indexes
:bug: ListChangeProcessor: fix findShift() method by including the given index in the count too

Signed-off-by: palexdev <alessandro.parisi406@gmail.com>
palexdev 3 سال پیش
والد
کامیت
6d98cbd98c
23فایلهای تغییر یافته به همراه2135 افزوده شده و 71 حذف شده
  1. 8 0
      CHANGELOG.md
  2. 47 18
      demo/src/test/java/Playground.java
  3. 0 2
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/BoundTextField.java
  4. 3 2
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/MFXIconWrapper.java
  5. 355 0
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/MFXSpinner.java
  6. 98 0
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/AbstractSpinnerModel.java
  7. 90 0
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/DoubleSpinnerModel.java
  8. 90 0
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/IntegerSpinnerModel.java
  9. 240 0
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/ListSpinnerModel.java
  10. 156 0
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/LocalDateSpinnerModel.java
  11. 106 0
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/NumberSpinnerModel.java
  12. 84 0
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/SpinnerModel.java
  13. 32 0
      materialfx/src/main/java/io/github/palexdev/materialfx/enums/ScaleBehavior.java
  14. 45 44
      materialfx/src/main/java/io/github/palexdev/materialfx/font/FontResources.java
  15. 397 0
      materialfx/src/main/java/io/github/palexdev/materialfx/layout/ScalableContentPane.java
  16. 307 0
      materialfx/src/main/java/io/github/palexdev/materialfx/skins/MFXSpinnerSkin.java
  17. 2 2
      materialfx/src/main/java/io/github/palexdev/materialfx/skins/MFXTextFieldSkin.java
  18. 3 3
      materialfx/src/main/java/io/github/palexdev/materialfx/utils/ListChangeProcessor.java
  19. 1 0
      materialfx/src/main/java/io/github/palexdev/materialfx/utils/others/observables/OnChanged.java
  20. 1 0
      materialfx/src/main/java/io/github/palexdev/materialfx/utils/others/observables/OnInvalidated.java
  21. 4 0
      materialfx/src/main/java/module-info.java
  22. 66 0
      materialfx/src/main/resources/io/github/palexdev/materialfx/css/MFXSpinner.css
  23. BIN
      materialfx/src/main/resources/io/github/palexdev/materialfx/fonts/MFXResources.ttf

+ 8 - 0
CHANGELOG.md

@@ -20,13 +20,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - New utils class SwingFXUtils (copied from javafx.embed.swing)
 - New utils class SwingFXUtils (copied from javafx.embed.swing)
 - Added fluent API builders for MaterialFX components and JavaFX Panes, as requested by #78
 - Added fluent API builders for MaterialFX components and JavaFX Panes, as requested by #78
 - Added resource bundles and API for internationalization
 - Added resource bundles and API for internationalization
+- Added new control, MFXSpinner
 
 
 ### Changed
 ### Changed
 - ColorUtils: changed some method to be null-safe
 - ColorUtils: changed some method to be null-safe
 - MFXFilterPaneSkin: properly compute the minimum width
 - MFXFilterPaneSkin: properly compute the minimum width
 - MFXTableViewSkin: allow to drag the filter dialog
 - MFXTableViewSkin: allow to drag the filter dialog
+- MFXIconWrapper: added handler to acquire focus
 
 
 ### Fixed
 ### Fixed
+
 - MFXComboBoxSkin: ensure the caret position is at 0 if the combo box is not selectable
 - MFXComboBoxSkin: ensure the caret position is at 0 if the combo box is not selectable
 - MFXTableViewSkin: ensure the dialog is on foreground
 - MFXTableViewSkin: ensure the dialog is on foreground
 - MFXTextField and all subclasses: fixed an issue with CSS and :focused PseudoClass. It was being ignored in some cases,
 - MFXTextField and all subclasses: fixed an issue with CSS and :focused PseudoClass. It was being ignored in some cases,
@@ -36,6 +39,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - I18N: do not use URLClassLoader to load the ResourceBundles as using MaterialFX is other projects would lead to a
 - I18N: do not use URLClassLoader to load the ResourceBundles as using MaterialFX is other projects would lead to a
   MissingResourceException, instead change the bundle base name returned by getBundleBaseName() with the complete path
   MissingResourceException, instead change the bundle base name returned by getBundleBaseName() with the complete path
   to the bundles
   to the bundles
+- BoundTextField: remove text formatter binding as it was causing an exception, also it works anyway, so it was
+  unnecessary
+- MFXIconWrapper: fix NullPointerException when creating an empty wrapper
+- ListChangeProcessor: fix computeRemoval() method returning negative indexes
+- ListChangeProcessor: fix findShift() method by including the given index in the count too
 
 
 ## [11.13.0] - 22-01-2022
 ## [11.13.0] - 22-01-2022
 _This version won't follow the above scheme as the amount of changes and commits is simply too huge and there would be
 _This version won't follow the above scheme as the amount of changes and commits is simply too huge and there would be

+ 47 - 18
demo/src/test/java/Playground.java

@@ -1,17 +1,20 @@
 import fr.brouillard.oss.cssfx.CSSFX;
 import fr.brouillard.oss.cssfx.CSSFX;
+import io.github.palexdev.materialfx.MFXResourcesLoader;
 import io.github.palexdev.materialfx.controls.MFXButton;
 import io.github.palexdev.materialfx.controls.MFXButton;
-import io.github.palexdev.materialfx.controls.MFXPasswordField;
-import io.github.palexdev.materialfx.controls.MFXRectangleToggleNode;
-import io.github.palexdev.materialfx.factories.InsetsFactory;
-import io.github.palexdev.materialfx.font.MFXFontIcon;
+import io.github.palexdev.materialfx.controls.MFXSpinner;
+import io.github.palexdev.materialfx.controls.models.spinner.ListSpinnerModel;
 import javafx.application.Application;
 import javafx.application.Application;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
 import javafx.geometry.Pos;
 import javafx.geometry.Pos;
 import javafx.scene.Scene;
 import javafx.scene.Scene;
 import javafx.scene.layout.BorderPane;
 import javafx.scene.layout.BorderPane;
-import javafx.scene.layout.Region;
+import javafx.scene.layout.HBox;
 import javafx.stage.Stage;
 import javafx.stage.Stage;
 import org.scenicview.ScenicView;
 import org.scenicview.ScenicView;
 
 
+import java.util.List;
+
 public class Playground extends Application {
 public class Playground extends Application {
 
 
 	@Override
 	@Override
@@ -19,24 +22,50 @@ public class Playground extends Application {
 		CSSFX.start();
 		CSSFX.start();
 		BorderPane borderPane = new BorderPane();
 		BorderPane borderPane = new BorderPane();
 
 
-		MFXPasswordField textField = new MFXPasswordField("", "Prompt", "Floating Text");
+		ObservableList<String> strings = FXCollections.observableArrayList(
+				"String 1",
+				"String 2",
+				"String 3",
+				"String 4",
+				"String 5",
+				"String 6",
+				"String 7",
+				"String 8"
+		);
 
 
-		MFXRectangleToggleNode toggleNode = new MFXRectangleToggleNode("This should be a long text");
-		toggleNode.setLabelLeadingIcon(new MFXFontIcon("mfx-google", 48));
-		toggleNode.setLabelTrailingIcon(new MFXFontIcon("mfx-google", 24));
+		MFXSpinner<String> spinner = new MFXSpinner<>();
+		spinner.getStylesheets().add(MFXResourcesLoader.load("css/MFXSpinner.css"));
+		spinner.setSpinnerModel(new ListSpinnerModel<>());
+		spinner.getSpinnerModel().setWrapAround(true);
+		((ListSpinnerModel<String>) spinner.getSpinnerModel()).setItems(strings);
+		spinner.setTextTransformer((focused, text) -> ((!focused || !spinner.isEditable()) && !text.isEmpty()) ? text + " cm" : text);
 
 
-		MFXButton button = new MFXButton("Click Me!");
-		button.setOnAction(event -> textField.setShowPassword(!textField.isShowPassword()));
-		button.setMaxSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE);
-		BorderPane.setAlignment(button, Pos.TOP_CENTER);
-		BorderPane.setMargin(button, InsetsFactory.top(10));
+		MFXButton add = new MFXButton("Add");
+		add.setOnAction(event -> ((ListSpinnerModel<String>) spinner.getSpinnerModel()).getItems().addAll(2, List.of("String Added 1", "String Added 2")));
+		MFXButton remove = new MFXButton("Remove");
+		remove.setOnAction(event -> ((ListSpinnerModel<String>) spinner.getSpinnerModel()).getItems().clear());
+		MFXButton removeSel = new MFXButton("Remove Selected");
+		removeSel.setOnAction(event -> ((ListSpinnerModel<String>) spinner.getSpinnerModel()).getItems().remove(((ListSpinnerModel<String>) spinner.getSpinnerModel()).getCurrentIndex()));
+		MFXButton replace = new MFXButton("Replace");
+		replace.setOnAction(event -> ((ListSpinnerModel<String>) spinner.getSpinnerModel()).getItems().set(((ListSpinnerModel<String>) spinner.getSpinnerModel()).getCurrentIndex(), "Replaced"));
+		MFXButton change = new MFXButton("Change List");
+		change.setOnAction(event -> {
+			ListSpinnerModel<String> model = (ListSpinnerModel<String>) spinner.getSpinnerModel();
+			model.setItems(FXCollections.observableArrayList(
+					"String 9",
+					"String 10",
+					"String 11",
+					"String 12",
+					"String 1234567890"
+			));
+		});
+		HBox box = new HBox(15, add, remove, removeSel, replace, change);
+		box.setAlignment(Pos.CENTER);
 
 
-		borderPane.getStylesheets().add(Playground.class.getResource("CustomField.css").toString());
-		borderPane.setTop(button);
-		borderPane.setCenter(toggleNode);
+		borderPane.setCenter(spinner);
+		borderPane.setBottom(box);
 		Scene scene = new Scene(borderPane, 800, 600);
 		Scene scene = new Scene(borderPane, 800, 600);
 		primaryStage.setScene(scene);
 		primaryStage.setScene(scene);
-		primaryStage.setOnShown(event -> button.requestFocus());
 		primaryStage.show();
 		primaryStage.show();
 		ScenicView.show(scene);
 		ScenicView.show(scene);
 	}
 	}

+ 0 - 2
materialfx/src/main/java/io/github/palexdev/materialfx/controls/BoundTextField.java

@@ -63,7 +63,6 @@ public class BoundTextField extends TextField {
 		setEditable(textField.isEditable());
 		setEditable(textField.isEditable());
 		setAlignment(textField.getAlignment());
 		setAlignment(textField.getAlignment());
 		setPrefColumnCount(textField.getPrefColumnCount());
 		setPrefColumnCount(textField.getPrefColumnCount());
-		setTextFormatter(textField.getTextFormatter());
 		selectRange(textField.getSelection().getStart(), textField.getSelection().getEnd());
 		selectRange(textField.getSelection().getStart(), textField.getSelection().getEnd());
 		positionCaret(textField.getCaretPosition());
 		positionCaret(textField.getCaretPosition());
 
 
@@ -74,7 +73,6 @@ public class BoundTextField extends TextField {
 		editableProperty().bind(textField.editableProperty());
 		editableProperty().bind(textField.editableProperty());
 		alignmentProperty().bind(textField.alignmentProperty());
 		alignmentProperty().bind(textField.alignmentProperty());
 		prefColumnCountProperty().bind(textField.prefColumnCountProperty());
 		prefColumnCountProperty().bind(textField.prefColumnCountProperty());
-		textFormatterProperty().bind(textField.textFormatterProperty());
 	}
 	}
 
 
 	@Override
 	@Override

+ 3 - 2
materialfx/src/main/java/io/github/palexdev/materialfx/controls/MFXIconWrapper.java

@@ -140,6 +140,7 @@ public class MFXIconWrapper extends StackPane {
 		setMinSize(USE_PREF_SIZE, USE_PREF_SIZE);
 		setMinSize(USE_PREF_SIZE, USE_PREF_SIZE);
 		setMaxSize(USE_PREF_SIZE, USE_PREF_SIZE);
 		setMaxSize(USE_PREF_SIZE, USE_PREF_SIZE);
 
 
+		addEventHandler(MouseEvent.MOUSE_PRESSED, event -> requestFocus());
 		icon.addListener((observable, oldValue, newValue) -> {
 		icon.addListener((observable, oldValue, newValue) -> {
 			super.getChildren().remove(oldValue);
 			super.getChildren().remove(oldValue);
 			manageIcon(newValue);
 			manageIcon(newValue);
@@ -187,8 +188,8 @@ public class MFXIconWrapper extends StackPane {
 	protected void layoutChildren() {
 	protected void layoutChildren() {
 		super.layoutChildren();
 		super.layoutChildren();
 
 
-		if (getSize() == -1) {
-			Node icon = getIcon();
+		Node icon = getIcon();
+		if (icon != null && getSize() == -1) {
 			double iW = icon.prefWidth(-1);
 			double iW = icon.prefWidth(-1);
 			double iH = icon.prefHeight(-1);
 			double iH = icon.prefHeight(-1);
 			Insets padding = getPadding();
 			Insets padding = getPadding();

+ 355 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/controls/MFXSpinner.java

@@ -0,0 +1,355 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX 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 Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.controls;
+
+import io.github.palexdev.materialfx.MFXResourcesLoader;
+import io.github.palexdev.materialfx.beans.properties.functional.BiFunctionProperty;
+import io.github.palexdev.materialfx.beans.properties.functional.ConsumerProperty;
+import io.github.palexdev.materialfx.beans.properties.functional.SupplierProperty;
+import io.github.palexdev.materialfx.controls.models.spinner.SpinnerModel;
+import io.github.palexdev.materialfx.skins.MFXSpinnerSkin;
+import io.github.palexdev.materialfx.utils.NodeUtils;
+import javafx.beans.property.*;
+import javafx.geometry.Orientation;
+import javafx.scene.Node;
+import javafx.scene.control.Control;
+import javafx.scene.control.Skin;
+import javafx.scene.input.MouseEvent;
+
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+/**
+ * MaterialFX implementation of {@link javafx.scene.control.Spinner} with a modern UI.
+ * <p>
+ * The spinner can work on any object you want but you will have to implement your own
+ * {@link SpinnerModel}. MaterialFX just like JavaFX offers 4 default models for: doubles,
+ * integers, local dates and lists.
+ * <p>
+ * <b>Without a {@link SpinnerModel} the spinner is useless!</b>
+ * <p></p>
+ * {@code MFXSpinner} offers the following features:
+ * <p> - You can set a prompt text for the field, {@link #promptTextProperty()}
+ * <p> - You can set the spinner to be editable or not, {@link #editableProperty()}
+ * <p> - You can allow/disallow the selection of the text, {@link #selectableProperty()}
+ * <p> - You can specify the action to run when the spinner is editable and ENTER is pressed,
+ * {@link #onCommitProperty()}
+ * <p> You can specify a function to transform the spinner's text. This can be useful for example when
+ * you want to add unit of measures to the text. The function carries the focus state of the editor
+ * (this way you can remove/add text according to the focus state) and the T value converted to a String
+ * <p> - You can easily change the orientation of the spinner, {@link #orientationProperty()}
+ * <p> - You can easily change the icons, {@link #prevIconSupplierProperty()}, {@link #nextIconSupplierProperty()}
+ * <p> - You can specify the gap between the text and the icon, {@link #graphicTextGapProperty()}
+ */
+public class MFXSpinner<T> extends Control {
+	//================================================================================
+	// Properties
+	//================================================================================
+	private final String STYLE_CLASS = "mfx-spinner";
+	private final String STYLESHEET = MFXResourcesLoader.load("css/MFXSpinner.css");
+
+	private final ReadOnlyObjectWrapper<T> value = new ReadOnlyObjectWrapper<>();
+	private final ObjectProperty<SpinnerModel<T>> spinnerModel = new SimpleObjectProperty<>();
+	private final StringProperty promptText = new SimpleStringProperty("");
+	private final BooleanProperty editable = new SimpleBooleanProperty(false);
+	private final BooleanProperty selectable = new SimpleBooleanProperty(true);
+	private final ConsumerProperty<String> onCommit = new ConsumerProperty<>();
+	private final BiFunctionProperty<Boolean, String, String> textTransformer = new BiFunctionProperty<>();
+
+	private final ObjectProperty<Orientation> orientation = new SimpleObjectProperty<>(Orientation.HORIZONTAL);
+	private final SupplierProperty<Node> prevIconSupplier = new SupplierProperty<>();
+	private final SupplierProperty<Node> nextIconSupplier = new SupplierProperty<>();
+	private final DoubleProperty graphicTextGap = new SimpleDoubleProperty(10.0);
+
+	//================================================================================
+	// Constructors
+	//================================================================================
+	public MFXSpinner() {
+		this(null);
+	}
+
+	public MFXSpinner(SpinnerModel<T> spinnerModel) {
+		initialize();
+		setSpinnerModel(spinnerModel);
+	}
+
+	//================================================================================
+	// Methods
+	//================================================================================
+	private void initialize() {
+		getStyleClass().add(STYLE_CLASS);
+		spinnerModelProperty().addListener((observable, oldValue, newValue) -> {
+			value.unbind();
+			if (newValue != null) {
+				value.bind(newValue.valueProperty());
+			}
+		});
+
+		defaultIcons();
+	}
+
+	/**
+	 * Restores the defaults for {@link #prevIconSupplierProperty()} and {@link #nextIconSupplierProperty()}.
+	 */
+	public void defaultIcons() {
+		setPrevIconSupplier(() -> {
+			MFXIconWrapper icon = new MFXIconWrapper("mfx-minus", 16, -1).defaultRippleGeneratorBehavior();
+			icon.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
+				SpinnerModel<T> model = getSpinnerModel();
+				if (model != null) model.previous();
+			});
+			NodeUtils.makeRegionCircular(icon);
+			return icon;
+		});
+		setNextIconSupplier(() -> {
+			MFXIconWrapper icon = new MFXIconWrapper("mfx-plus", 16, -1).defaultRippleGeneratorBehavior();
+			icon.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
+				SpinnerModel<T> model = getSpinnerModel();
+				if (model != null) model.next();
+			});
+			NodeUtils.makeRegionCircular(icon);
+			return icon;
+		});
+	}
+
+	/**
+	 * If the spinner is editable, {@link #editableProperty()}, pressing the ENTER key will
+	 * trigger the action specified by {@link #onCommitProperty()}.
+	 */
+	public void commit(String text) {
+		Consumer<String> onCommit = getOnCommit();
+		if (onCommit != null) onCommit.accept(text);
+	}
+
+	//================================================================================
+	// Overridden Methods
+	//================================================================================
+	@Override
+	protected Skin<?> createDefaultSkin() {
+		return new MFXSpinnerSkin<>(this);
+	}
+
+	@Override
+	public String getUserAgentStylesheet() {
+		return STYLESHEET;
+	}
+
+	//================================================================================
+	// Getters/Setters
+	//================================================================================
+
+	public T getValue() {
+		return value.get();
+	}
+
+	/**
+	 * Specifies the current selected value for the spinner.
+	 * <p>
+	 * Note that this property is read-only, you can set the value with {@link #setValue(Object)}
+	 * but it will fail with an exception if the {@link #spinnerModelProperty()} is null.
+	 */
+	public ReadOnlyObjectProperty<T> valueProperty() {
+		return value.getReadOnlyProperty();
+	}
+
+	public void setValue(T value) {
+		getSpinnerModel().setValue(value);
+	}
+
+	public SpinnerModel<T> getSpinnerModel() {
+		return spinnerModel.get();
+	}
+
+	/**
+	 * Specifies the spinner's model, responsible for handling the spinner's value
+	 * according to the data type.
+	 */
+	public ObjectProperty<SpinnerModel<T>> spinnerModelProperty() {
+		return spinnerModel;
+	}
+
+	public void setSpinnerModel(SpinnerModel<T> spinnerModel) {
+		this.spinnerModel.set(spinnerModel);
+	}
+
+	public String getPromptText() {
+		return promptText.get();
+	}
+
+	/**
+	 * Specifies the prompt text for the spinner's text field.
+	 */
+	public StringProperty promptTextProperty() {
+		return promptText;
+	}
+
+	public void setPromptText(String promptText) {
+		this.promptText.set(promptText);
+	}
+
+	public boolean isEditable() {
+		return editable.get();
+	}
+
+	/**
+	 * Specifies whether the spinner's text field is editable.
+	 * <p>
+	 * If you edit the text you must confirm the change by pressing the ENTER
+	 * key, this will trigger the {@link #commit(String)} method, more info here {@link #onCommitProperty()}.
+	 */
+	public BooleanProperty editableProperty() {
+		return editable;
+	}
+
+	public void setEditable(boolean editable) {
+		this.editable.set(editable);
+	}
+
+	public boolean isSelectable() {
+		return selectable.get();
+	}
+
+	/**
+	 * Specifies whether the spinner's text is selectable.
+	 */
+	public BooleanProperty selectableProperty() {
+		return selectable;
+	}
+
+	public void setSelectable(boolean selectable) {
+		this.selectable.set(selectable);
+	}
+
+	public Consumer<String> getOnCommit() {
+		return onCommit.get();
+	}
+
+	/**
+	 * Specifies the action to perform when editing the spinner's text
+	 * and confirming the changes by pressing ENTER.
+	 * <p>
+	 * The action is a {@link Consumer} which carries the modified text.
+	 * To change the spinner's value with that for example you probably want to validate
+	 * the text, parse a valid T object and then set the value.
+	 */
+	public ConsumerProperty<String> onCommitProperty() {
+		return onCommit;
+	}
+
+	public void setOnCommit(Consumer<String> onCommit) {
+		this.onCommit.set(onCommit);
+	}
+
+	public BiFunction<Boolean, String, String> getTextTransformer() {
+		return textTransformer.get();
+	}
+
+	/**
+	 * The text transformer is a {@link BiFunction} that allows you to change the
+	 * spinner's text when the spinner's text field acquires/loses focus.
+	 * <p>
+	 * This can be useful for example when you want to add the unit of measure to the
+	 * spinner's text. Usually in such controls the unit of measure is added when the control
+	 * is not focused and removed when editing the text.
+	 * <p></p>
+	 * An example could be:
+	 * <pre>
+	 * {@code
+	 *      MFXSpinner spinner = ...;
+	 *      spinner.setTextTransformer((focused, text) -> (!focused || !spinner.isEditable()) ? text + " meters" : text);
+	 * }
+	 * </pre>
+	 */
+	public BiFunctionProperty<Boolean, String, String> textTransformerProperty() {
+		return textTransformer;
+	}
+
+	public void setTextTransformer(BiFunction<Boolean, String, String> textTransformer) {
+		this.textTransformer.set(textTransformer);
+	}
+
+	public Orientation getOrientation() {
+		return orientation.get();
+	}
+
+	/**
+	 * Specifies the spinner's orientation.
+	 */
+	public ObjectProperty<Orientation> orientationProperty() {
+		return orientation;
+	}
+
+	public void setOrientation(Orientation orientation) {
+		this.orientation.set(orientation);
+	}
+
+	public Supplier<Node> getPrevIconSupplier() {
+		return prevIconSupplier.get();
+	}
+
+	/**
+	 * The {@link Supplier} used to build the icons which should
+	 * trigger {@link SpinnerModel#previous()}.
+	 * <p>
+	 * Note that the {@link #defaultIcons()} add the needed event handlers
+	 * to use the {@link SpinnerModel}, it is not handled automatically!
+	 */
+	public SupplierProperty<Node> prevIconSupplierProperty() {
+		return prevIconSupplier;
+	}
+
+	public void setPrevIconSupplier(Supplier<Node> prevIconSupplier) {
+		this.prevIconSupplier.set(prevIconSupplier);
+	}
+
+	public Supplier<Node> getNextIconSupplier() {
+		return nextIconSupplier.get();
+	}
+
+	/**
+	 * The {@link Supplier} used to build the icons which should
+	 * trigger {@link SpinnerModel#next()}.
+	 * <p>
+	 * Note that the {@link #defaultIcons()} add the needed event handlers
+	 * to use the {@link SpinnerModel}, it is not handled automatically!
+	 */
+	public SupplierProperty<Node> nextIconSupplierProperty() {
+		return nextIconSupplier;
+	}
+
+	public void setNextIconSupplier(Supplier<Node> nextIconSupplier) {
+		this.nextIconSupplier.set(nextIconSupplier);
+	}
+
+	public double getGraphicTextGap() {
+		return graphicTextGap.get();
+	}
+
+	/**
+	 * Specifies the space between the spinner's text and the two icons.
+	 */
+	public DoubleProperty graphicTextGapProperty() {
+		return graphicTextGap;
+	}
+
+	public void setGraphicTextGap(double graphicTextGap) {
+		this.graphicTextGap.set(graphicTextGap);
+	}
+}

+ 98 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/AbstractSpinnerModel.java

@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX 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 Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.controls.models.spinner;
+
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+
+/**
+ * Base implementation for {@link SpinnerModel}.
+ * <p>
+ * This is still not enough to use with a spinner but it's a good base to implement
+ * new models.
+ * Defines all the specification from the {@link SpinnerModel} interface and adds a
+ * new property, {@link #defaultValueProperty()}, which is the spinner's value when calling {@link #reset()}.
+ */
+public abstract class AbstractSpinnerModel<T> implements SpinnerModel<T> {
+	//================================================================================
+	// Properties
+	//================================================================================
+	protected final ObjectProperty<T> value = new SimpleObjectProperty<>();
+	protected final ObjectProperty<T> defaultValue = new SimpleObjectProperty<>();
+	protected boolean wrapAround;
+
+	//================================================================================
+	// Overridden Methods
+	//================================================================================
+
+	/**
+	 * Sets the spinner's value to the value specified by {@link #defaultValueProperty()}.
+	 */
+	@Override
+	public void reset() {
+		setValue(getDefaultValue());
+	}
+
+	@Override
+	public T getValue() {
+		return value.get();
+	}
+
+	@Override
+	public ObjectProperty<T> valueProperty() {
+		return value;
+	}
+
+	@Override
+	public void setValue(T value) {
+		this.value.set(value);
+	}
+
+	@Override
+	public boolean isWrapAround() {
+		return wrapAround;
+	}
+
+	@Override
+	public void setWrapAround(boolean wrapAround) {
+		this.wrapAround = wrapAround;
+	}
+
+	//================================================================================
+	// Getters/Setters
+	//================================================================================
+	public T getDefaultValue() {
+		return defaultValue.get();
+	}
+
+	/**
+	 * Specifies the default value of the spinner.
+	 * <p></p>
+	 * Note that this may not be the spinner's initial value, and usually this
+	 * is used when calling the {@link #reset()} method (depends on the implementation).
+	 */
+	public ObjectProperty<T> defaultValueProperty() {
+		return defaultValue;
+	}
+
+	public void setDefaultValue(T defaultValue) {
+		this.defaultValue.set(defaultValue);
+	}
+}
+

+ 90 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/DoubleSpinnerModel.java

@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX 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 Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.controls.models.spinner;
+
+import io.github.palexdev.materialfx.utils.others.FunctionalStringConverter;
+
+import java.util.Objects;
+
+/**
+ * Concrete implementation of {@link NumberSpinnerModel} to work with double value.
+ * <p></p>
+ * The constructor initializes the model with these values:
+ * <p> - The converter uses {@link Double#parseDouble(String)} and {@link Objects#toString(Object)}
+ * <p> - The default value is 0.0
+ * <p> - The min value is 0.0
+ * <p> - The max value is {@link Double#MAX_VALUE}
+ * <p> - The increment is 1.0
+ * <p> - The initial value depends on the chosen constructor
+ */
+public class DoubleSpinnerModel extends NumberSpinnerModel<Double> {
+
+	//================================================================================
+	// Constructors
+	//================================================================================
+	public DoubleSpinnerModel() {
+		this(0.0);
+	}
+
+	public DoubleSpinnerModel(double initialValue) {
+		setConverter(FunctionalStringConverter.converter(
+				Double::parseDouble,
+				Objects::toString
+		));
+		setDefaultValue(0.0);
+		setMin(0.0);
+		setMax(Double.MAX_VALUE);
+		setIncrement(1.0);
+		setValue(initialValue);
+	}
+
+	//================================================================================
+	// Overridden Methods
+	//================================================================================
+
+	/**
+	 * Increments the current value by {@link #incrementProperty()}.
+	 * <p></p>
+	 * If the new value is greater than the {@link #maxProperty()} and {@link #isWrapAround()} is true
+	 * the new value will be the {@link #minProperty()}.
+	 */
+	@Override
+	public void next() {
+		double newVal = getValue() + getIncrement();
+		if (newVal > getMax()) {
+			newVal = isWrapAround() ? getMin() : getMax();
+		}
+		setValue(newVal);
+	}
+
+	/**
+	 * Decrements the current value by {@link #incrementProperty()}.
+	 * <p></p>
+	 * If the new value is lesser than the {@link #minProperty()} and {@link #isWrapAround()} is true
+	 * the new value will be the {@link #maxProperty()}.
+	 */
+	@Override
+	public void previous() {
+		double newVal = getValue() - getIncrement();
+		if (newVal < getMin()) {
+			newVal = isWrapAround() ? getMax() : getMin();
+		}
+		setValue(newVal);
+	}
+}

+ 90 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/IntegerSpinnerModel.java

@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX 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 Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.controls.models.spinner;
+
+import io.github.palexdev.materialfx.utils.others.FunctionalStringConverter;
+
+import java.util.Objects;
+
+/**
+ * Concrete implementation of {@link NumberSpinnerModel} to work with integer value.
+ * <p></p>
+ * The constructor initializes the model with these values:
+ * <p> - The converter uses {@link Integer#parseInt(String)} and {@link Objects#toString(Object)}
+ * <p> - The default value is 0
+ * <p> - The min value is 0
+ * <p> - The max value is {@link Integer#MAX_VALUE}
+ * <p> - The increment is 1
+ * <p> - The initial value depends on the chosen constructor
+ */
+public class IntegerSpinnerModel extends NumberSpinnerModel<Integer> {
+
+	//================================================================================
+	// Constructors
+	//================================================================================
+	public IntegerSpinnerModel() {
+		this(0);
+	}
+
+	public IntegerSpinnerModel(int initialValue) {
+		setConverter(FunctionalStringConverter.converter(
+				Integer::parseInt,
+				Objects::toString
+		));
+		setDefaultValue(0);
+		setMin(0);
+		setMax(Integer.MAX_VALUE);
+		setIncrement(1);
+		setValue(initialValue);
+	}
+
+	//================================================================================
+	// Overridden Methods
+	//================================================================================
+
+	/**
+	 * Increments the current value by {@link #incrementProperty()}.
+	 * <p></p>
+	 * If the new value is greater than the {@link #maxProperty()} and {@link #isWrapAround()} is true
+	 * the new value will be the {@link #minProperty()}.
+	 */
+	@Override
+	public void next() {
+		int newVal = getValue() + getIncrement();
+		if (newVal > getMax()) {
+			newVal = isWrapAround() ? getMin() : getMax();
+		}
+		setValue(newVal);
+	}
+
+	/**
+	 * Decrements the current value by {@link #incrementProperty()}.
+	 * <p></p>
+	 * If the new value is lesser than the {@link #minProperty()} and {@link #isWrapAround()} is true
+	 * the new value will be the {@link #maxProperty()}.
+	 */
+	@Override
+	public void previous() {
+		int newVal = getValue() - getIncrement();
+		if (newVal < getMin()) {
+			newVal = isWrapAround() ? getMax() : getMin();
+		}
+		setValue(newVal);
+	}
+}

+ 240 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/ListSpinnerModel.java

@@ -0,0 +1,240 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX 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 Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.controls.models.spinner;
+
+import io.github.palexdev.materialfx.utils.ListChangeProcessor;
+import io.github.palexdev.materialfx.utils.others.FunctionalStringConverter;
+import io.github.palexdev.virtualizedfx.beans.NumberRange;
+import io.github.palexdev.virtualizedfx.utils.ListChangeHelper;
+import javafx.beans.property.*;
+import javafx.collections.FXCollections;
+import javafx.collections.ListChangeListener;
+import javafx.collections.ObservableList;
+import javafx.util.StringConverter;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Concrete implementation of {@link AbstractSpinnerModel} to work with lists of any type.
+ * <p></p>
+ * {@code ListSpinnerModel} adds the {@link #converterProperty()} (since we know the kind of data the model will deal with),
+ * and three new properties, {@link #itemsProperty()}, {@link #getCurrentIndex()}, {@link #incrementProperty()}.
+ * <p></p>
+ * The model works by keeping the current value's index in the list and updating that index, even
+ * when the list is modified, see {@link #updateCurrentIndex(ListChangeListener.Change)}.
+ * <p></p>
+ * The constructor initializes the model with these values:
+ * <p> - The converter uses {@code toString(T)} on the value or empty string if the value is null
+ * <p> - The default value is an empty {@link ObservableList}
+ * <p> - The increment is 1
+ * <p> - The initial value is the first element of the list (if not empty)
+ */
+public class ListSpinnerModel<T> extends AbstractSpinnerModel<T> {
+	//================================================================================
+	// Properties
+	//================================================================================
+	private final ListProperty<T> items = new SimpleListProperty<>();
+	private int currentIndex = -1;
+	private final ObjectProperty<StringConverter<T>> converter = new SimpleObjectProperty<>();
+	private final IntegerProperty increment = new SimpleIntegerProperty();
+
+	//================================================================================
+	// Constructors
+	//================================================================================
+	public ListSpinnerModel() {
+		this(FXCollections.observableArrayList());
+	}
+
+	public ListSpinnerModel(ObservableList<T> items) {
+		setConverter(FunctionalStringConverter.to(t -> t != null ? t.toString() : ""));
+		setItems(items);
+		setIncrement(1);
+
+		this.items.addListener((observable, oldValue, newValue) -> {
+			if (oldValue != newValue) reset();
+		});
+		this.items.addListener((ListChangeListener<? super T>) this::updateCurrentIndex);
+
+		if (!items.isEmpty()) {
+			currentIndex = 0;
+			setValue(items.get(currentIndex));
+		}
+	}
+
+	//================================================================================
+	// Methods
+	//================================================================================
+
+	/**
+	 * Responsible for updating the current value's index when the list changes.
+	 * <p></p>
+	 * The value won't change as the index will always be updated according to the value,
+	 * unless the current value is removed from the list.
+	 */
+	private void updateCurrentIndex(ListChangeListener.Change<? extends T> change) {
+		if (currentIndex == -1 && !change.getList().isEmpty()) {
+			currentIndex = 0;
+			setValue(change.getList().get(currentIndex));
+			return;
+		}
+
+		if (change.getList().isEmpty()) {
+			reset();
+			return;
+		}
+
+		ListChangeHelper.Change c = ListChangeHelper.processChange(change, NumberRange.of(0, Integer.MAX_VALUE));
+		ListChangeProcessor updater = new ListChangeProcessor(Set.of(currentIndex));
+		c.processReplacement((replaced, removed) -> {
+			T value = items.get(currentIndex);
+			setValue(value);
+		});
+		c.processAddition((from, to, added) -> {
+			updater.computeAddition(added.size(), from);
+			List<Integer> indexes = new ArrayList<>(updater.getIndexes());
+			if (!indexes.isEmpty()) {
+				currentIndex = indexes.get(0);
+				T value = items.get(currentIndex);
+				setValue(value);
+			}
+		});
+		c.processRemoval((from, to, removed) -> {
+			updater.computeRemoval(removed, from);
+			List<Integer> indexes = new ArrayList<>(updater.getIndexes());
+			if (!indexes.isEmpty()) {
+				currentIndex = indexes.get(0);
+				T value = items.get(currentIndex);
+				setValue(value);
+			}
+		});
+	}
+
+	//================================================================================
+	// Overridden Methods
+	//================================================================================
+
+	/**
+	 * If the items list is empty exits immediately.
+	 * <p></p>
+	 * Increments the current index by {@link #incrementProperty()}, if the new index
+	 * is greater than the list's last index and {@link #isWrapAround()} is true, the new index
+	 * will be set to 0, otherwise to {@code items.size() - 1}
+	 * <p></p>
+	 * At the end the value is updated with the new index.
+	 */
+	@Override
+	public void next() {
+		if (items.isEmpty()) return;
+		int newIndex = currentIndex + getIncrement();
+		if (newIndex > items.size() - 1) {
+			newIndex = isWrapAround() ? 0 : items.size() - 1;
+		}
+		currentIndex = newIndex;
+		setValue(items.get(newIndex));
+	}
+
+	/**
+	 * If the items list is empty exits immediately.
+	 * <p></p>
+	 * Decrements the current index by {@link #incrementProperty()}, if the new index
+	 * is lesser than 0 and {@link #isWrapAround()} is true, the new index
+	 * will be set to {@code items.size() - 1}, otherwise to 0
+	 * <p></p>
+	 * At the end the value is updated with the new index.
+	 */
+	@Override
+	public void previous() {
+		if (items.isEmpty()) return;
+		int newIndex = currentIndex - getIncrement();
+		if (newIndex < 0) {
+			newIndex = isWrapAround() ? items.size() - 1 : 0;
+		}
+		currentIndex = newIndex;
+		setValue(items.get(newIndex));
+	}
+
+	/**
+	 * Resets the spinner's value to the value specified by {@link #defaultValueProperty()},
+	 * and the current index to -1;
+	 */
+	@Override
+	public void reset() {
+		super.reset();
+		currentIndex = -1;
+	}
+
+	@Override
+	public StringConverter<T> getConverter() {
+		return converter.get();
+	}
+
+	@Override
+	public ObjectProperty<StringConverter<T>> converterProperty() {
+		return converter;
+	}
+
+	@Override
+	public void setConverter(StringConverter<T> converter) {
+		this.converter.set(converter);
+	}
+
+	//================================================================================
+	// Getters/Setters
+	//================================================================================
+
+	/**
+	 * @return the current value's index in the items list
+	 */
+	public int getCurrentIndex() {
+		return currentIndex;
+	}
+
+	public ObservableList<T> getItems() {
+		return items.get();
+	}
+
+	/**
+	 * Specifies the items list.
+	 */
+	public ListProperty<T> itemsProperty() {
+		return items;
+	}
+
+	public void setItems(ObservableList<T> items) {
+		this.items.set(items);
+	}
+
+	public int getIncrement() {
+		return increment.get();
+	}
+
+	/**
+	 * Specifies the increment/decrement value to add/subtract from
+	 * the current index when calling {@link #next()} or {@link #previous()}.
+	 */
+	public IntegerProperty incrementProperty() {
+		return increment;
+	}
+
+	public void setIncrement(int increment) {
+		this.increment.set(increment);
+	}
+}

+ 156 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/LocalDateSpinnerModel.java

@@ -0,0 +1,156 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX 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 Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.controls.models.spinner;
+
+import io.github.palexdev.materialfx.utils.others.dates.DateStringConverter;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.util.StringConverter;
+
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.format.FormatStyle;
+import java.time.temporal.TemporalAmount;
+
+/**
+ * Concrete implementation of {@link AbstractSpinnerModel} to work with {@link LocalDate} values.
+ * <p></p>
+ * {@code LocalDateSpinnerModel} adds the {@link #converterProperty()} (since we know the kind of data the model will deal with),
+ * and three new properties, {@link #minProperty()}, {@link #maxProperty()} and {@link #incrementProperty()}.
+ * <p></p>
+ * The constructor initializes the model with these values:
+ * <p> - The converter uses {@link DateStringConverter} with format {@link FormatStyle#MEDIUM}
+ * <p> - The default value is {@link LocalDate#EPOCH}
+ * <p> - The min value is {@link LocalDate#EPOCH}
+ * <p> - The max value is {@link LocalDate#MAX}
+ * <p> - The increment is {@link Duration#ofDays(long)}, 1 day
+ * <p> - The initial value depends on the chosen constructor
+ */
+public class LocalDateSpinnerModel extends AbstractSpinnerModel<LocalDate> {
+	//================================================================================
+	// Properties
+	//================================================================================
+	private final ObjectProperty<StringConverter<LocalDate>> converter = new SimpleObjectProperty<>();
+	private final ObjectProperty<LocalDate> min = new SimpleObjectProperty<>();
+	private final ObjectProperty<LocalDate> max = new SimpleObjectProperty<>();
+	private final ObjectProperty<TemporalAmount> increment = new SimpleObjectProperty<>();
+
+	//================================================================================
+	// Constructors
+	//================================================================================
+	public LocalDateSpinnerModel() {
+		this(LocalDate.EPOCH);
+	}
+
+	public LocalDateSpinnerModel(LocalDate initialValue) {
+		setConverter(new DateStringConverter(FormatStyle.MEDIUM));
+		setDefaultValue(LocalDate.EPOCH);
+		setMin(LocalDate.EPOCH);
+		setMax(LocalDate.MAX);
+		setIncrement(Duration.ofDays(1));
+		setValue(initialValue);
+	}
+
+	//================================================================================
+	// Overridden Methods
+	//================================================================================
+	@Override
+	public void next() {
+		LocalDate next = getValue().plus(getIncrement());
+		if (next.isAfter(getMax())) {
+			next = isWrapAround() ? getMin() : getMax();
+		}
+		setValue(next);
+	}
+
+	@Override
+	public void previous() {
+		LocalDate prev = getValue().minus(getIncrement());
+		if (prev.isBefore(getMin())) {
+			prev = isWrapAround() ? getMax() : getMin();
+		}
+		setValue(prev);
+	}
+
+	@Override
+	public StringConverter<LocalDate> getConverter() {
+		return converter.get();
+	}
+
+	@Override
+	public ObjectProperty<StringConverter<LocalDate>> converterProperty() {
+		return converter;
+	}
+
+	@Override
+	public void setConverter(StringConverter<LocalDate> converter) {
+		this.converter.set(converter);
+	}
+
+	//================================================================================
+	// Getters/Setters
+	//================================================================================
+	public LocalDate getMin() {
+		return min.get();
+	}
+
+	/**
+	 * Specifies the minimum date reachable by the spinner.
+	 */
+	public ObjectProperty<LocalDate> minProperty() {
+		return min;
+	}
+
+	public void setMin(LocalDate min) {
+		this.min.set(min);
+	}
+
+	public LocalDate getMax() {
+		return max.get();
+	}
+
+	/**
+	 * Specifies the maximum date reachable by the spinner.
+	 */
+	public ObjectProperty<LocalDate> maxProperty() {
+		return max;
+	}
+
+	public void setMax(LocalDate max) {
+		this.max.set(max);
+	}
+
+	public TemporalAmount getIncrement() {
+		return increment.get();
+	}
+
+	/**
+	 * Specifies the increment/decrement value to add/subtract from
+	 * the current index when calling {@link #next()} or {@link #previous()}.
+	 * <p></p>
+	 * The amount is a generic {@link TemporalAmount}.
+	 */
+	public ObjectProperty<TemporalAmount> incrementProperty() {
+		return increment;
+	}
+
+	public void setIncrement(TemporalAmount increment) {
+		this.increment.set(increment);
+	}
+}

+ 106 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/NumberSpinnerModel.java

@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX 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 Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.controls.models.spinner;
+
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.util.StringConverter;
+
+/**
+ * Base class to easily implement spinner models for numeric values, extends {@link AbstractSpinnerModel}.
+ * <p></p>
+ * {@code NumberSpinnerModel} adds the {@link #converterProperty()} (since we know the kind of data the model will deal with),
+ * and three new properties, {@link #minProperty()}, {@link #maxProperty()}, {@link #incrementProperty()}.
+ */
+public abstract class NumberSpinnerModel<T extends Number> extends AbstractSpinnerModel<T> {
+	//================================================================================
+	// Properties
+	//================================================================================
+	private final ObjectProperty<StringConverter<T>> converter = new SimpleObjectProperty<>();
+	private final ObjectProperty<T> min = new SimpleObjectProperty<>();
+	private final ObjectProperty<T> max = new SimpleObjectProperty<>();
+	private final ObjectProperty<T> increment = new SimpleObjectProperty<>();
+
+	//================================================================================
+	// Overridden Methods
+	//================================================================================
+	@Override
+	public StringConverter<T> getConverter() {
+		return converter.get();
+	}
+
+	@Override
+	public ObjectProperty<StringConverter<T>> converterProperty() {
+		return converter;
+	}
+
+	@Override
+	public void setConverter(StringConverter<T> converter) {
+		this.converter.set(converter);
+	}
+
+	//================================================================================
+	// Getters/Setters
+	//================================================================================
+	public T getMin() {
+		return min.get();
+	}
+
+	/**
+	 * Specifies the minimum number reachable by the spinner.
+	 */
+	public ObjectProperty<T> minProperty() {
+		return min;
+	}
+
+	public void setMin(T min) {
+		this.min.set(min);
+	}
+
+	public T getMax() {
+		return max.get();
+	}
+
+	/**
+	 * Specifies the maximum number reachable by the spinner.
+	 */
+	public ObjectProperty<T> maxProperty() {
+		return max;
+	}
+
+	public void setMax(T max) {
+		this.max.set(max);
+	}
+
+	public T getIncrement() {
+		return increment.get();
+	}
+
+	/**
+	 * Specifies the increment/decrement value to add/subtract from
+	 * the current value when calling {@link #next()} or {@link #previous()}.
+	 */
+	public ObjectProperty<T> incrementProperty() {
+		return increment;
+	}
+
+	public void setIncrement(T increment) {
+		this.increment.set(increment);
+	}
+}

+ 84 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/SpinnerModel.java

@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX 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 Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.controls.models.spinner;
+
+import io.github.palexdev.materialfx.controls.MFXSpinner;
+import javafx.beans.property.ObjectProperty;
+import javafx.util.StringConverter;
+
+/**
+ * Defines the public API for all models to be used with {@link MFXSpinner}.
+ * <p>
+ * {@code SpinnerModel} is basically an helper class to allow the spinner to work on any object
+ * type as long as a model exists for it. The model is responsible for changing the spinner's value by
+ * going forward or backwards ({@link #next() or {@link #previous()}}.
+ * <p>
+ * Along this core functionality the model also specifies a {@link StringConverter} which will be
+ * used to convert the T value to a String, which will be the spinner's text.
+ * <p>
+ * The spinner should also allow to cycle through the values, meaning that when reaching the the last value,
+ * {@link #next()} will go to the first value (and the other way around)
+ */
+public interface SpinnerModel<T> {
+
+	/**
+	 * Steps to the next value.
+	 */
+	void next();
+
+	/**
+	 * Steps to the previous value.
+	 */
+	void previous();
+
+	/**
+	 * Resets the spinner's value.
+	 */
+	void reset();
+
+	T getValue();
+
+	/**
+	 * Specifies the spinner's value.
+	 */
+	ObjectProperty<T> valueProperty();
+
+	void setValue(T value);
+
+	StringConverter<T> getConverter();
+
+	/**
+	 * Specifies the {@link StringConverter} used to convert the spinner value to a String.
+	 */
+	ObjectProperty<StringConverter<T>> converterProperty();
+
+	void setConverter(StringConverter<T> converter);
+
+	/**
+	 * @return whether the spinner can cycle through the values when
+	 * reaching the last/first value
+	 */
+	boolean isWrapAround();
+
+	/**
+	 * Sets whether the spinner can cycle through the values when
+	 * reaching the last/first value
+	 */
+	void setWrapAround(boolean wrapAround);
+}

+ 32 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/enums/ScaleBehavior.java

@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX 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 Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.enums;
+
+public enum ScaleBehavior {
+	/**
+	 * Always scale the content.
+	 */
+	ALWAYS,
+
+	/**
+	 * Rescale content only if requested by the layout properties of the content
+	 * node.
+	 */
+	IF_NECESSARY
+}

+ 45 - 44
materialfx/src/main/java/io/github/palexdev/materialfx/font/FontResources.java

@@ -110,50 +110,51 @@ public enum FontResources {
 	MODENA_MARK("mfx-modena-mark", '\uE955'),
 	MODENA_MARK("mfx-modena-mark", '\uE955'),
 	MUSIC("mfx-music", '\uE956'),
 	MUSIC("mfx-music", '\uE956'),
 	NEXT("mfx-next", '\uE957'),
 	NEXT("mfx-next", '\uE957'),
-	PROGRESS_BARS("mfx-progress-bars", '\uE958'),
-	PROGRESS_BARS_ALT("mfx-progress-bars-alt", '\uE959'),
-	REDO("mfx-redo", '\uE95A'),
-	RESTORE("mfx-restore", '\uE95B'),
-	SCROLL_BAR("mfx-scroll-bar", '\uE95C'),
-	SEARCH("mfx-search", '\uE961'),
-	SEARCH_PLUS("mfx-search-plus", '\uE962'),
-	SELECT_ALL("mfx-select-all", '\uE963'),
-	SHORTCUT("mfx-shortcut", '\uE964'),
-	SIDEBAR_CLOSE("mfx-sidebar-close", '\uE965'),
-	SIDEBAR_OPEN("mfx-sidebar-open", '\uE966'),
-	SLIDERS("mfx-sliders", '\uE967'),
-	SPREADSHEET("mfx-spreadsheet", '\uE968'),
-	SQUARE_LIST("mfx-square-list", '\uE969'),
-	STEP_BACKWARD("mfx-step-backward", '\uE96A'),
-	STEP_FORWARD("mfx-step-forward", '\uE96B'),
-	STEPPER("mfx-stepper", '\uE96C'),
-	SYNC("mfx-sync", '\uE970'),
-	SYNC_LIGHT("mfx-sync-light", '\uE971'),
-	TABLE("mfx-table", '\uE972'),
-	TABLE_ALT("mfx-table-alt", '\uE973'),
-	TOGGLE_OFF("mfx-toggle-off", '\uE974'),
-	TOGGLE_ON("mfx-toggle-on", '\uE975'),
-	UNDO("mfx-undo", '\uE976'),
-	USER("mfx-user", '\uE977'),
-	USERS("mfx-users", '\uE978'),
-	VARIANT10_MARK("mfx-variant10-mark", '\uE979'),
-	VARIANT11_MARK("mfx-variant11-mark", '\uE97A'),
-	VARIANT12_MARK("mfx-variant12-mark", '\uE97B'),
-	VARIANT13_MARK("mfx-variant13-mark", '\uE97C'),
-	VARIANT14_MARK("mfx-variant14-mark", '\uE97D'),
-	VARIANT3_MARK("mfx-variant3-mark", '\uE97E'),
-	VARIANT4_MARK("mfx-variant4-mark", '\uE97F'),
-	VARIANT5_MARK("mfx-variant5-mark", '\uE980'),
-	VARIANT6_MARK("mfx-variant6-mark", '\uE981'),
-	VARIANT7_MARK("mfx-variant7-mark", '\uE982'),
-	VARIANT8_MARK("mfx-variant8-mark", '\uE983'),
-	VARIANT9_MARK("mfx-variant9-mark", '\uE984'),
-	VIDEO("mfx-video", '\uE985'),
-	X("mfx-x", '\uE986'),
-	X_ALT("mfx-x-alt", '\uE987'),
-	X_CIRCLE("mfx-x-circle", '\uE988'),
-	X_CIRCLE_LIGHT("mfx-x-circle-light", '\uE989'),
-	X_LIGHT("mfx-x-light", '\uE98A');
+	PLUS("mfx-plus", '\uE958'),
+	PROGRESS_BARS("mfx-progress-bars", '\uE959'),
+	PROGRESS_BARS_ALT("mfx-progress-bars-alt", '\uE95A'),
+	REDO("mfx-redo", '\uE95B'),
+	RESTORE("mfx-restore", '\uE95C'),
+	SCROLL_BAR("mfx-scroll-bar", '\uE95D'),
+	SEARCH("mfx-search", '\uE95E'),
+	SEARCH_PLUS("mfx-search-plus", '\uE95F'),
+	SELECT_ALL("mfx-select-all", '\uE960'),
+	SHORTCUT("mfx-shortcut", '\uE961'),
+	SIDEBAR_CLOSE("mfx-sidebar-close", '\uE962'),
+	SIDEBAR_OPEN("mfx-sidebar-open", '\uE963'),
+	SLIDERS("mfx-sliders", '\uE964'),
+	SPREADSHEET("mfx-spreadsheet", '\uE965'),
+	SQUARE_LIST("mfx-square-list", '\uE966'),
+	STEP_BACKWARD("mfx-step-backward", '\uE967'),
+	STEP_FORWARD("mfx-step-forward", '\uE968'),
+	STEPPER("mfx-stepper", '\uE969'),
+	SYNC("mfx-sync", '\uE96A'),
+	SYNC_LIGHT("mfx-sync-light", '\uE96B'),
+	TABLE("mfx-table", '\uE96C'),
+	TABLE_ALT("mfx-table-alt", '\uE96D'),
+	TOGGLE_OFF("mfx-toggle-off", '\uE96E'),
+	TOGGLE_ON("mfx-toggle-on", '\uE96F'),
+	UNDO("mfx-undo", '\uE970'),
+	USER("mfx-user", '\uE971'),
+	USERS("mfx-users", '\uE972'),
+	VARIANT10_MARK("mfx-variant10-mark", '\uE973'),
+	VARIANT11_MARK("mfx-variant11-mark", '\uE974'),
+	VARIANT12_MARK("mfx-variant12-mark", '\uE975'),
+	VARIANT13_MARK("mfx-variant13-mark", '\uE976'),
+	VARIANT14_MARK("mfx-variant14-mark", '\uE977'),
+	VARIANT3_MARK("mfx-variant3-mark", '\uE978'),
+	VARIANT4_MARK("mfx-variant4-mark", '\uE979'),
+	VARIANT5_MARK("mfx-variant5-mark", '\uE97A'),
+	VARIANT6_MARK("mfx-variant6-mark", '\uE97B'),
+	VARIANT7_MARK("mfx-variant7-mark", '\uE97C'),
+	VARIANT8_MARK("mfx-variant8-mark", '\uE97D'),
+	VARIANT9_MARK("mfx-variant9-mark", '\uE97E'),
+	VIDEO("mfx-video", '\uE97F'),
+	X("mfx-x", '\uE980'),
+	X_ALT("mfx-x-alt", '\uE981'),
+	X_CIRCLE("mfx-x-circle", '\uE982'),
+	X_CIRCLE_LIGHT("mfx-x-circle-light", '\uE983'),
+	X_LIGHT("mfx-x-light", '\uE984');
 
 
 	public static FontResources findByDescription(String description) {
 	public static FontResources findByDescription(String description) {
 		for (FontResources font : values()) {
 		for (FontResources font : values()) {

+ 397 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/layout/ScalableContentPane.java

@@ -0,0 +1,397 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX 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 Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.layout;
+
+import io.github.palexdev.materialfx.enums.ScaleBehavior;
+import javafx.beans.property.*;
+import javafx.beans.value.ChangeListener;
+import javafx.collections.ListChangeListener;
+import javafx.geometry.Bounds;
+import javafx.scene.Node;
+import javafx.scene.Parent;
+import javafx.scene.layout.Pane;
+import javafx.scene.layout.Region;
+import javafx.scene.transform.Scale;
+import javafx.scene.transform.Transform;
+
+public class ScalableContentPane extends Region {
+	//================================================================================
+	// Properties
+	//================================================================================
+	private final ObjectProperty<Node> content = new SimpleObjectProperty<>();
+	private final DoubleProperty minScaleX = new SimpleDoubleProperty(Double.MIN_VALUE);
+	private final DoubleProperty maxScaleX = new SimpleDoubleProperty(Double.MAX_VALUE);
+	private final DoubleProperty minScaleY = new SimpleDoubleProperty(Double.MIN_VALUE);
+	private final DoubleProperty maxScaleY = new SimpleDoubleProperty(Double.MAX_VALUE);
+	private final BooleanProperty fitToWidth = new SimpleBooleanProperty(true);
+	private final BooleanProperty fitToHeight = new SimpleBooleanProperty(true);
+	private final ObjectProperty<ScaleBehavior> scaleBehavior = new SimpleObjectProperty<>(ScaleBehavior.ALWAYS);
+
+	private Scale scale;
+	private double scaleWidth;
+	private double scaleHeight;
+	private boolean aspectScale = true;
+	private boolean autoRescale = true;
+	private boolean manualReset;
+
+	//================================================================================
+	// Constructors
+	//================================================================================
+	public ScalableContentPane() {
+		this(new Pane());
+	}
+
+	public ScalableContentPane(Node content) {
+		initialize();
+		setContent(content);
+	}
+
+	//================================================================================
+	// Methods
+	//================================================================================
+	private void initialize() {
+		needsLayoutProperty().addListener((observable, oldValue, newValue) -> {
+			boolean wCondition = getWidth() <= getPrefWidth();
+			boolean hCondition = getHeight() <= getPrefHeight();
+			boolean prefWCondition = getPrefWidth() == USE_COMPUTED_SIZE;
+			boolean prefHCondition = getPrefHeight() == USE_COMPUTED_SIZE;
+			if (newValue && (wCondition || hCondition) || prefWCondition || prefHCondition) computeScale();
+		});
+
+		fitToWidth.addListener((observable, oldValue, newValue) -> requestLayout());
+		fitToHeight.addListener((observable, oldValue, newValue) -> requestLayout());
+		scaleBehavior.addListener((observable, oldValue, newValue) -> requestLayout());
+		content.addListener((observable, oldValue, newValue) -> initializeContent());
+	}
+
+	private void initializeContent() {
+		ChangeListener<? super Bounds> boundsListener = (observable, oldValue, newValue) -> {
+			if (isAutoRescale()) {
+				Node cnt = getContent();
+				if (cnt instanceof Region) {
+					Region rgn = (Region) cnt;
+					rgn.requestLayout();
+				}
+				requestLayout();
+				setNeedsLayout(false);
+			}
+		};
+
+		ChangeListener<? super Number> layoutListener = (observable, oldValue, newValue) -> {
+			if (isAutoRescale()) {
+				Node cnt = getContent();
+				if (cnt instanceof Parent) {
+					Parent prt = (Parent) cnt;
+					prt.requestLayout();
+				}
+				requestLayout();
+				setNeedsLayout(false);
+			}
+		};
+
+		Node content = getContent();
+		if (getContent() instanceof Pane) {
+			Pane pane = (Pane) content;
+			ListChangeListener<? super Node> childrenListener = c -> {
+				while (c.next()) {
+					if (c.wasRemoved()) {
+						for (Node node : c.getRemoved()) {
+							node.boundsInLocalProperty().removeListener(boundsListener);
+							node.layoutXProperty().removeListener(layoutListener);
+							node.layoutYProperty().removeListener(layoutListener);
+						}
+					} else if (c.wasAdded()) {
+						for (Node node : c.getAddedSubList()) {
+							node.boundsInLocalProperty().addListener(boundsListener);
+							node.layoutXProperty().addListener(layoutListener);
+							node.layoutYProperty().addListener(layoutListener);
+						}
+					}
+				}
+			};
+			pane.getChildren().addListener(childrenListener);
+		}
+
+		scale = Transform.scale(1, 1, 0, 0);
+		content.getTransforms().add(scale);
+		getChildren().add(content);
+
+		ChangeListener<? super Number> scaleListener = (observable, oldValue, newValue) -> requestScale();
+		scale.setOnTransformChanged(event -> {
+			requestLayout();
+			setNeedsLayout(false);
+		});
+
+		minScaleX.addListener(scaleListener);
+		minScaleY.addListener(scaleListener);
+		maxScaleX.addListener(scaleListener);
+		maxScaleY.addListener(scaleListener);
+	}
+
+	private void computeScale() {
+		double realWidth = getContent().prefWidth(getLayoutBounds().getHeight());
+		double realHeight = getContent().prefHeight(getLayoutBounds().getWidth());
+		double leftAndRight = getInsets().getLeft() + getInsets().getRight();
+		double topAndBottom = getInsets().getTop() + getInsets().getBottom();
+
+		double contentWidth = getLayoutBounds().getWidth() - leftAndRight;
+		double contentHeight = getLayoutBounds().getHeight() - topAndBottom;
+
+		scaleWidth = contentWidth / realWidth;
+		scaleHeight = contentHeight / realHeight;
+
+		scaleWidth = Math.max(scaleWidth, getMinScaleX());
+		scaleWidth = Math.min(scaleWidth, getMaxScaleX());
+
+		scaleHeight = Math.max(scaleHeight, getMinScaleY());
+		scaleHeight = Math.min(scaleHeight, getMaxScaleY());
+
+		double resizeScaleW;
+		double resizeScaleH;
+
+		if (isAspectScale()) {
+			double scaleValue = Math.min(scaleWidth, scaleHeight);
+
+			if (getScaleBehavior() == ScaleBehavior.ALWAYS || manualReset) {
+				scale.setX(scaleValue);
+				scale.setY(scaleValue);
+			} else if (getScaleBehavior() == ScaleBehavior.IF_NECESSARY) {
+				if (scaleValue < scale.getX() && getLayoutBounds().getWidth() > 0) {
+					scale.setX(scaleValue);
+					scale.setY(scaleValue);
+				}
+			}
+
+		} else if (getScaleBehavior() == ScaleBehavior.ALWAYS || manualReset) {
+			scale.setX(scaleWidth);
+			scale.setY(scaleHeight);
+		} else if (getScaleBehavior() == ScaleBehavior.IF_NECESSARY) {
+			if (scaleWidth < scale.getX() && getLayoutBounds().getWidth() > 0) {
+				scale.setX(scaleWidth);
+			}
+			if (scaleHeight < scale.getY() && getLayoutBounds().getHeight() > 0) {
+				scale.setY(scaleHeight);
+			}
+		}
+
+		resizeScaleW = scale.getX();
+		resizeScaleH = scale.getY();
+
+		getContent().relocate(getInsets().getLeft(), getInsets().getTop());
+
+		double realContentWidth;
+		double realContentHeight;
+
+		if (isFitToWidth()) {
+			realContentWidth = contentWidth / resizeScaleW;
+		} else {
+			realContentWidth = contentWidth / scaleWidth;
+		}
+
+		if (isFitToHeight()) {
+			realContentHeight = contentHeight / resizeScaleH;
+		} else {
+			realContentHeight = contentHeight / scaleHeight;
+		}
+
+		getContent().resize(realContentWidth, realContentHeight);
+	}
+
+	public void requestScale() {
+		computeScale();
+	}
+
+	public void resetScale() {
+		if (manualReset) {
+			return;
+		}
+
+		manualReset = true;
+
+		try {
+			computeScale();
+		} finally {
+			manualReset = false;
+		}
+	}
+
+	//================================================================================
+	// Overridden Methods
+	//================================================================================
+	@Override
+	protected double computeMinWidth(double height) {
+		double result = getInsets().getLeft() + getInsets().getRight();
+
+		// apply content width (including scale)
+		result += getContent().prefWidth(height) * getMinScaleX();
+		return result;
+	}
+
+	@Override
+	protected double computeMinHeight(double width) {
+		double result = getInsets().getTop() + getInsets().getBottom();
+
+		// apply content width (including scale)
+		result += getContent().prefHeight(width) * getMinScaleY();
+		return result;
+	}
+
+	@Override
+	protected double computePrefWidth(double height) {
+		double result = getInsets().getLeft() + getInsets().getRight();
+
+		// apply content width (including scale)
+		result += getContent().prefWidth(height) * scaleWidth;
+		return result;
+	}
+
+	@Override
+	protected double computePrefHeight(double width) {
+		double result = getInsets().getTop() + getInsets().getBottom();
+
+		// apply content width (including scale)
+		result += getContent().prefHeight(width) * scaleHeight;
+		return result;
+	}
+
+	@Override
+	protected void layoutChildren() {
+		super.layoutChildren();
+		computeScale();
+	}
+
+	//================================================================================
+	// Getters/Setters
+	//================================================================================
+	public Node getContent() {
+		return content.get();
+	}
+
+	public ObjectProperty<Node> contentProperty() {
+		return content;
+	}
+
+	public void setContent(Node content) {
+		this.content.set(content);
+	}
+
+	public double getMinScaleX() {
+		return minScaleX.get();
+	}
+
+	public DoubleProperty minScaleXProperty() {
+		return minScaleX;
+	}
+
+	public void setMinScaleX(double minScaleX) {
+		this.minScaleX.set(minScaleX);
+	}
+
+	public double getMaxScaleX() {
+		return maxScaleX.get();
+	}
+
+	public DoubleProperty maxScaleXProperty() {
+		return maxScaleX;
+	}
+
+	public void setMaxScaleX(double maxScaleX) {
+		this.maxScaleX.set(maxScaleX);
+	}
+
+	public double getMinScaleY() {
+		return minScaleY.get();
+	}
+
+	public DoubleProperty minScaleYProperty() {
+		return minScaleY;
+	}
+
+	public void setMinScaleY(double minScaleY) {
+		this.minScaleY.set(minScaleY);
+	}
+
+	public double getMaxScaleY() {
+		return maxScaleY.get();
+	}
+
+	public DoubleProperty maxScaleYProperty() {
+		return maxScaleY;
+	}
+
+	public void setMaxScaleY(double maxScaleY) {
+		this.maxScaleY.set(maxScaleY);
+	}
+
+	public boolean isFitToWidth() {
+		return fitToWidth.get();
+	}
+
+	public BooleanProperty fitToWidthProperty() {
+		return fitToWidth;
+	}
+
+	public void setFitToWidth(boolean fitToWidth) {
+		this.fitToWidth.set(fitToWidth);
+	}
+
+	public boolean isFitToHeight() {
+		return fitToHeight.get();
+	}
+
+	public BooleanProperty fitToHeightProperty() {
+		return fitToHeight;
+	}
+
+	public void setFitToHeight(boolean fitToHeight) {
+		this.fitToHeight.set(fitToHeight);
+	}
+
+	public ScaleBehavior getScaleBehavior() {
+		return scaleBehavior.get();
+	}
+
+	public ObjectProperty<ScaleBehavior> scaleBehaviorProperty() {
+		return scaleBehavior;
+	}
+
+	public void setScaleBehavior(ScaleBehavior scaleBehavior) {
+		this.scaleBehavior.set(scaleBehavior);
+	}
+
+	public Scale getScale() {
+		return scale;
+	}
+
+	public boolean isAspectScale() {
+		return aspectScale;
+	}
+
+	public void setAspectScale(boolean aspectScale) {
+		this.aspectScale = aspectScale;
+	}
+
+	public boolean isAutoRescale() {
+		return autoRescale;
+	}
+
+	public void setAutoRescale(boolean autoRescale) {
+		this.autoRescale = autoRescale;
+	}
+}

+ 307 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/skins/MFXSpinnerSkin.java

@@ -0,0 +1,307 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX 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 Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.skins;
+
+import io.github.palexdev.materialfx.controls.MFXSpinner;
+import io.github.palexdev.materialfx.controls.MFXTextField;
+import io.github.palexdev.materialfx.controls.models.spinner.SpinnerModel;
+import io.github.palexdev.materialfx.enums.FloatMode;
+import io.github.palexdev.materialfx.factories.InsetsFactory;
+import io.github.palexdev.materialfx.utils.NodeUtils;
+import io.github.palexdev.materialfx.utils.TextUtils;
+import javafx.beans.binding.Bindings;
+import javafx.beans.binding.StringBinding;
+import javafx.beans.value.ChangeListener;
+import javafx.css.PseudoClass;
+import javafx.geometry.Orientation;
+import javafx.scene.Node;
+import javafx.scene.control.SkinBase;
+import javafx.scene.input.KeyEvent;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.BorderPane;
+import javafx.util.StringConverter;
+
+import java.util.function.BiFunction;
+
+/**
+ * This is the default skin implementation for {@link MFXSpinner}.
+ * <p>
+ * There are two main components:
+ * <p> 1) The top container, a {@link BorderPane}, which contains the spinner's text field
+ * and the two icons. The usage of a {@code BorderPane} simplifies a lot the layout since it's
+ * everything automatically handled. This allows to easily switch the spinner's orientation,
+ * {@link MFXSpinner#orientationProperty()}, and handle the icons.
+ * <p> 2) A {@link MFXTextField} to show the spinner's selected value as text. Here things are a bit
+ * more complicated. The field's text is not simply bound but it's computed by a {@link StringBinding}.
+ * This is needed because there are several things to consider since the spinner relies on {@link SpinnerModel}
+ * to handle the selected value and also the way to convert the value to text.
+ * It's needed, for example, to keep track of both the {@link MFXSpinner#spinnerModelProperty()} and
+ * the {@link SpinnerModel#converterProperty()} at the same time.
+ * <p></p>
+ * The skin also introduces a new {@link PseudoClass}: ":focus-within", it is activated when
+ * the text field is focused or when one of the icons is focused.
+ */
+public class MFXSpinnerSkin<T> extends SkinBase<MFXSpinner<T>> {
+	//================================================================================
+	// Properties
+	//================================================================================
+	private final BorderPane container;
+	private final MFXTextField field;
+	private Node nextIcon;
+	private Node prevIcon;
+
+	private ChangeListener<SpinnerModel<T>> modelListener;
+	private ChangeListener<StringConverter<T>> converterListener;
+	private StringBinding textBinding;
+
+	private static final PseudoClass FOCUS_WITHIN_PSEUDO_CLASS = PseudoClass.getPseudoClass("focus-within");
+
+	//================================================================================
+	// Constructors
+	//================================================================================
+	public MFXSpinnerSkin(MFXSpinner<T> spinner) {
+		super(spinner);
+
+		// Text Field
+		field = new MFXTextField() {
+			@Override
+			public String getUserAgentStylesheet() {
+				return spinner.getUserAgentStylesheet();
+			}
+		};
+		field.setFloatMode(FloatMode.DISABLED);
+		field.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE);
+		field.allowEditProperty().bind(spinner.editableProperty());
+		field.selectableProperty().bind(spinner.selectableProperty());
+		field.promptTextProperty().bind(spinner.promptTextProperty());
+
+		textBinding = Bindings.createStringBinding(
+				() -> {
+					T value = spinner.getValue();
+					SpinnerModel<T> model = spinner.getSpinnerModel();
+					String s;
+					if (model == null) {
+						s = value != null ? value.toString() : "";
+					} else {
+						s = model.getConverter().toString(value);
+					}
+
+					BiFunction<Boolean, String, String> textTransformer = spinner.getTextTransformer();
+					return (textTransformer != null) ? textTransformer.apply(field.delegateIsFocused(), s) : s;
+				},
+				spinner.valueProperty(), spinner.spinnerModelProperty(), field.delegateFocusedProperty()
+		);
+		if (textBinding.get() != null && !textBinding.get().isEmpty()) field.setText(textBinding.get());
+
+		// Model Listeners
+		modelListener = (observable, oldValue, newValue) -> initializeModel(oldValue, newValue);
+		converterListener = (observable, oldValue, newValue) -> textBinding.invalidate();
+
+		// Icons Initialization
+		nextIcon = (spinner.getNextIconSupplier() != null) ? spinner.getNextIconSupplier().get() : null;
+		prevIcon = (spinner.getPrevIconSupplier() != null) ? spinner.getPrevIconSupplier().get() : null;
+
+		// Top Container Initialization
+		container = new BorderPane();
+		manageContainer();
+		manageGap();
+
+		// Skin Initialization
+		getChildren().setAll(container);
+		addListeners();
+	}
+
+	//================================================================================
+	// Methods
+	//================================================================================
+
+	/**
+	 * Adds the following listeners/handlers:
+	 * <p> - A listener to the {@link MFXSpinner#spinnerModelProperty()} to call {@link #initializeModel(SpinnerModel, SpinnerModel)} when it changes
+	 * <p> - A listener to update the field's text
+	 * <p> - A listener to activate the ":focus-within" PseudoClass when the text field is focused
+	 * <p> - A listener to manage the {@link MFXSpinner#orientationProperty()}
+	 * <p> - A listener to manage the {@link MFXSpinner#graphicTextGapProperty()}
+	 * <p> - Two listeners to handle {@link MFXSpinner#prevIconSupplierProperty()} and {@link MFXSpinner#nextIconSupplierProperty()}
+	 * <p> - A MOUSE_PRESSED handler to handle the spinner's focus and also activate the ":focus-within" PseudoClass if needed
+	 * <p> - A KEY_PRESSED filter to handle the edit/cancel features. Pressing ENTER will trigger the {@link MFXSpinner#commit(String)}
+	 * method. Pressing Ctrl+Shift+Z will cancel the edit and reset the text for the current selected value
+	 */
+	private void addListeners() {
+		MFXSpinner<T> spinner = getSkinnable();
+
+		spinner.spinnerModelProperty().addListener(modelListener);
+		textBinding.addListener(invalidated -> field.setText(textBinding.getValue()));
+		field.delegateFocusedProperty().addListener((observable, oldValue, newValue) -> spinner.pseudoClassStateChanged(FOCUS_WITHIN_PSEUDO_CLASS, newValue));
+
+		spinner.orientationProperty().addListener(invalidated -> manageContainer());
+		spinner.graphicTextGapProperty().addListener(invalidated -> manageGap());
+		spinner.prevIconSupplierProperty().addListener((observable, oldValue, newValue) -> {
+			if (newValue != null) {
+				prevIcon = newValue.get();
+			} else {
+				prevIcon = null;
+			}
+			manageContainer();
+		});
+		spinner.nextIconSupplierProperty().addListener((observable, oldValue, newValue) -> {
+			if (newValue != null) {
+				nextIcon = newValue.get();
+			} else {
+				nextIcon = null;
+			}
+			manageContainer();
+		});
+
+		spinner.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
+			if (NodeUtils.inHierarchy(event, nextIcon) || NodeUtils.inHierarchy(event, prevIcon)) {
+				boolean iconsFocused = (nextIcon != null && nextIcon.isFocused()) || (prevIcon != null && prevIcon.isFocused());
+				spinner.pseudoClassStateChanged(FOCUS_WITHIN_PSEUDO_CLASS, iconsFocused);
+				return;
+			}
+			spinner.pseudoClassStateChanged(FOCUS_WITHIN_PSEUDO_CLASS, false);
+			spinner.requestFocus();
+		});
+		spinner.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
+			switch (event.getCode()) {
+				case ENTER: {
+					spinner.commit(field.getText());
+					break;
+				}
+				case Z: {
+					if (event.isControlDown() && event.isShiftDown()) {
+						SpinnerModel<T> model = spinner.getSpinnerModel();
+						if (model != null) {
+							String s = model.getConverter().toString(spinner.getValue());
+							field.setText(s);
+						}
+					}
+					break;
+				}
+			}
+		});
+	}
+
+	/**
+	 * Manages the top container, {@link BorderPane}, according to the spinner's current
+	 * {@link Orientation}.
+	 * <p>
+	 * Also calls {@link #manageGap()}.
+	 */
+	private void manageContainer() {
+		MFXSpinner<T> spinner = getSkinnable();
+		Orientation orientation = spinner.getOrientation();
+
+		container.getChildren().clear();
+		if (orientation == Orientation.HORIZONTAL) {
+			if (prevIcon != null) container.setLeft(prevIcon);
+			if (nextIcon != null) container.setRight(nextIcon);
+		} else {
+			if (nextIcon != null) container.setTop(nextIcon);
+			if (prevIcon != null) container.setBottom(prevIcon);
+		}
+		container.setCenter(field);
+		manageGap();
+	}
+
+	/**
+	 * Responsible for applying the {@link MFXSpinner#graphicTextGapProperty()} to the
+	 * the spinner's icons.
+	 */
+	private void manageGap() {
+		MFXSpinner<T> spinner = getSkinnable();
+		Orientation orientation = spinner.getOrientation();
+		double gap = spinner.getGraphicTextGap();
+
+		if (orientation == Orientation.HORIZONTAL) {
+			if (prevIcon != null) BorderPane.setMargin(prevIcon, InsetsFactory.right(gap));
+			if (nextIcon != null) BorderPane.setMargin(nextIcon, InsetsFactory.left(gap));
+		} else {
+			if (prevIcon != null) BorderPane.setMargin(prevIcon, InsetsFactory.top(gap));
+			if (nextIcon != null) BorderPane.setMargin(nextIcon, InsetsFactory.bottom(gap));
+		}
+	}
+
+	/**
+	 * Responsible for adding a listener to the spinner's {@link SpinnerModel#converterProperty()}
+	 * when it changes.
+	 * <p></p>
+	 * The listener is responsible for updating the text when the converter changes.
+	 */
+	private void initializeModel(SpinnerModel<T> oldModel, SpinnerModel<T> newModel) {
+		if (oldModel != newModel) {
+			oldModel.converterProperty().removeListener(converterListener);
+		}
+		if (newModel != null) {
+			newModel.converterProperty().addListener(converterListener);
+		}
+	}
+
+	//================================================================================
+	// Overridden Methods
+	//================================================================================
+	@Override
+	protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
+		return super.computeMinWidth(height, topInset, rightInset, bottomInset, leftInset) + 5;
+	}
+
+	@Override
+	protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
+		MFXSpinner<T> spinner = getSkinnable();
+		Orientation orientation = spinner.getOrientation();
+		double gap = spinner.getGraphicTextGap();
+
+		double textW = field.snappedLeftInset() + Math.max(
+				TextUtils.computeTextWidth(field.getFont(), field.getText()),
+				TextUtils.computeTextWidth(field.getFont(), field.getPromptText())
+		) + field.snappedRightInset();
+		double prevIconW = prevIcon.prefWidth(-1);
+		double nextIconW = nextIcon.prefWidth(-1);
+
+		if (orientation == Orientation.HORIZONTAL) {
+			return leftInset +
+					(prevIcon != null ? prevIconW + gap : 0) +
+					textW +
+					(nextIcon != null ? nextIconW + gap : 0) +
+					rightInset;
+		} else {
+			return leftInset +
+					Math.max(textW, Math.max(prevIconW, nextIconW)) +
+					rightInset;
+		}
+	}
+
+	@Override
+	protected double computeMaxWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
+		return getSkinnable().prefWidth(-1);
+	}
+
+	@Override
+	protected double computeMaxHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
+		return getSkinnable().prefHeight(-1);
+	}
+
+	@Override
+	public void dispose() {
+		super.dispose();
+		textBinding = null;
+		modelListener = null;
+		converterListener = null;
+	}
+}

+ 2 - 2
materialfx/src/main/java/io/github/palexdev/materialfx/skins/MFXTextFieldSkin.java

@@ -63,7 +63,7 @@ public class MFXTextFieldSkin extends SkinBase<MFXTextField> {
 	private final BoundTextField boundField;
 	private final BoundTextField boundField;
 	private final Label floatingText;
 	private final Label floatingText;
 
 
-	private static final PseudoClass FOCUSED_PSEUDO_CLASS = PseudoClass.getPseudoClass("focused");
+	private static final PseudoClass FOCUS_WITHIN_PSEUDO_CLASS = PseudoClass.getPseudoClass("focus-within");
 
 
 	private final ObjectProperty<PositionBean> floatingPos = new SimpleObjectProperty<>(PositionBean.of(0, 0));
 	private final ObjectProperty<PositionBean> floatingPos = new SimpleObjectProperty<>(PositionBean.of(0, 0));
 	private final BooleanExpression floating;
 	private final BooleanExpression floating;
@@ -148,7 +148,7 @@ public class MFXTextFieldSkin extends SkinBase<MFXTextField> {
 		textField.focusedProperty().addListener((observable, oldValue, newValue) -> boundField.requestFocus());
 		textField.focusedProperty().addListener((observable, oldValue, newValue) -> boundField.requestFocus());
 		boundField.focusedProperty().addListener((observable, oldValue, newValue) -> {
 		boundField.focusedProperty().addListener((observable, oldValue, newValue) -> {
 			textField.requestLayout();
 			textField.requestLayout();
-			pseudoClassStateChanged(FOCUSED_PSEUDO_CLASS, newValue);
+			pseudoClassStateChanged(FOCUS_WITHIN_PSEUDO_CLASS, newValue);
 		});
 		});
 
 
 		// Icons
 		// Icons

+ 3 - 3
materialfx/src/main/java/io/github/palexdev/materialfx/utils/ListChangeProcessor.java

@@ -84,7 +84,7 @@ public class ListChangeProcessor {
 				tmp.add(i);
 				tmp.add(i);
 				continue;
 				continue;
 			}
 			}
-			int index = i - findShift(removed, i);
+			int index = Math.max(i - findShift(removed, i), 0);
 			tmp.add(index);
 			tmp.add(index);
 		}
 		}
 		indexes = tmp;
 		indexes = tmp;
@@ -92,10 +92,10 @@ public class ListChangeProcessor {
 
 
 	/**
 	/**
 	 * Iterates over the given Set of removed indexes to count the number
 	 * Iterates over the given Set of removed indexes to count the number
-	 * of indexes that are lesser than the given index.
+	 * of indexes that are lesser or equal to the given index.
 	 */
 	 */
 	private int findShift(Set<Integer> removed, int index) {
 	private int findShift(Set<Integer> removed, int index) {
-		return (int) removed.stream().filter(i -> i < index).count();
+		return (int) removed.stream().filter(i -> i <= index).count();
 	}
 	}
 
 
 	//================================================================================
 	//================================================================================

+ 1 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/utils/others/observables/OnChanged.java

@@ -88,6 +88,7 @@ public class OnChanged<T> extends When<T> {
 	 * then adds the listener to the specified {@link ObservableValue} and finally puts the Observable and
 	 * then adds the listener to the specified {@link ObservableValue} and finally puts the Observable and
 	 * the OnChanged construct in the map.
 	 * the OnChanged construct in the map.
 	 */
 	 */
+	@Override
 	public OnChanged<T> listen() {
 	public OnChanged<T> listen() {
 		if (oneShot) {
 		if (oneShot) {
 			listener = (observable, oldValue, newValue) -> {
 			listener = (observable, oldValue, newValue) -> {

+ 1 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/utils/others/observables/OnInvalidated.java

@@ -88,6 +88,7 @@ public class OnInvalidated<T> extends When<T> {
 	 * then adds the listener to the specified {@link ObservableValue} and finally puts the Observable and
 	 * then adds the listener to the specified {@link ObservableValue} and finally puts the Observable and
 	 * the OnInvalidated construct in the map.
 	 * the OnInvalidated construct in the map.
 	 */
 	 */
+	@Override
 	public OnInvalidated<T> listen() {
 	public OnInvalidated<T> listen() {
 		if (oneShot) {
 		if (oneShot) {
 			listener = invalidated -> {
 			listener = invalidated -> {

+ 4 - 0
materialfx/src/main/java/module-info.java

@@ -34,6 +34,7 @@ module MaterialFX {
 	exports io.github.palexdev.materialfx.controls.base;
 	exports io.github.palexdev.materialfx.controls.base;
 	exports io.github.palexdev.materialfx.controls.cell;
 	exports io.github.palexdev.materialfx.controls.cell;
 	exports io.github.palexdev.materialfx.controls.legacy;
 	exports io.github.palexdev.materialfx.controls.legacy;
+	exports io.github.palexdev.materialfx.controls.models.spinner;
 
 
 	// CSS Package
 	// CSS Package
 	exports io.github.palexdev.materialfx.css;
 	exports io.github.palexdev.materialfx.css;
@@ -62,6 +63,9 @@ module MaterialFX {
 	// I18N Package
 	// I18N Package
 	exports io.github.palexdev.materialfx.i18n;
 	exports io.github.palexdev.materialfx.i18n;
 
 
+	// Layout Package
+	exports io.github.palexdev.materialfx.layout;
+
 	// Notifications Package
 	// Notifications Package
 	exports io.github.palexdev.materialfx.notifications;
 	exports io.github.palexdev.materialfx.notifications;
 	exports io.github.palexdev.materialfx.notifications.base;
 	exports io.github.palexdev.materialfx.notifications.base;

+ 66 - 0
materialfx/src/main/resources/io/github/palexdev/materialfx/css/MFXSpinner.css

@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX 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 Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+@import "MFXColors.css";
+@import "MFXTextField.css";
+
+.mfx-spinner {
+	-fx-background-color: white;
+	-fx-background-radius: 5;
+	-fx-border-color: lightgray;
+	-fx-border-radius: 5;
+	-fx-padding: 5 10 5 10;
+}
+
+.mfx-spinner:focused,
+.mfx-spinner:focus-within {
+	-fx-border-color: -mfx-purple;
+}
+
+.mfx-spinner .mfx-text-field {
+	-fx-background-insets: 0;
+	-fx-border-color: transparent;
+	-fx-border-width: 1;
+	-fx-border-insets: 0;
+	-fx-padding: 5 10 5 10;
+}
+
+.mfx-spinner .mfx-icon-wrapper {
+	-fx-background-color: derive(-mfx-purple, 155%);
+	-mfx-size: 27;
+}
+
+.mfx-spinner .mfx-icon-wrapper:focused,
+.mfx-spinner .mfx-icon-wrapper:hover {
+	-fx-background-color: derive(-mfx-purple, 50%);
+}
+
+.mfx-spinner .mfx-icon-wrapper:focused .mfx-font-icon,
+.mfx-spinner .mfx-icon-wrapper:hover .mfx-font-icon {
+	-mfx-color: white;
+}
+
+.mfx-spinner .mfx-icon-wrapper .mfx-font-icon {
+	-mfx-color: -mfx-purple;
+	-mfx-size: 14;
+}
+
+.mfx-spinner .mfx-icon-wrapper .mfx-ripple-generator {
+	-mfx-ripple-radius: 24;
+	-mfx-ripple-color: rgba(255, 255, 255, 0.5);
+}

BIN
materialfx/src/main/resources/io/github/palexdev/materialfx/fonts/MFXResources.ttf