diff --git a/components/src/main/java/org/patternfly/component/ComponentType.java b/components/src/main/java/org/patternfly/component/ComponentType.java
index c381d9790..1c0335f4e 100644
--- a/components/src/main/java/org/patternfly/component/ComponentType.java
+++ b/components/src/main/java/org/patternfly/component/ComponentType.java
@@ -105,6 +105,8 @@ public enum ComponentType {
NotificationDrawer("nd", "PF6/Component/NotificationDrawer"),
+ NumberInput("ni", "PF6/Component/NumberInput"),
+
Page("pg", "PF6/Component/Page"),
Panel("pnl", "PF6/Component/Panel"),
diff --git a/components/src/main/java/org/patternfly/component/numberinput/NumberInput.java b/components/src/main/java/org/patternfly/component/numberinput/NumberInput.java
new file mode 100644
index 000000000..4415184b5
--- /dev/null
+++ b/components/src/main/java/org/patternfly/component/numberinput/NumberInput.java
@@ -0,0 +1,353 @@
+/*
+ * Copyright 2023 Red Hat
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.patternfly.component.numberinput;
+
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.UnaryOperator;
+
+import org.jboss.elemento.EventType;
+import org.jboss.elemento.Id;
+import org.patternfly.component.BaseComponent;
+import org.patternfly.component.ComponentType;
+import org.patternfly.component.HasValue;
+import org.patternfly.component.ValidationStatus;
+import org.patternfly.component.button.Button;
+import org.patternfly.component.form.TextInput;
+import org.patternfly.component.form.TextInputType;
+import org.patternfly.component.inputgroup.InputGroup;
+import org.patternfly.component.inputgroup.InputGroupItem;
+import org.patternfly.core.Aria;
+import org.patternfly.handler.ChangeHandler;
+import org.patternfly.icon.IconSets.fas;
+import org.patternfly.style.Modifiers.Disabled;
+import org.patternfly.style.Variable;
+
+import elemental2.dom.Event;
+import elemental2.dom.HTMLDivElement;
+import elemental2.dom.HTMLElement;
+import elemental2.dom.KeyboardEvent;
+
+import static org.jboss.elemento.Elements.div;
+import static org.jboss.elemento.Elements.failSafeRemoveFromParent;
+import static org.jboss.elemento.EventType.blur;
+import static org.jboss.elemento.EventType.change;
+import static org.jboss.elemento.EventType.keydown;
+import static org.jboss.elemento.Key.ArrowDown;
+import static org.jboss.elemento.Key.ArrowUp;
+import static org.patternfly.style.Classes.component;
+import static org.patternfly.style.Classes.numberInput;
+import static org.patternfly.style.Classes.unit;
+import static org.patternfly.style.Variable.componentVar;
+
+/**
+ * A number input combines a text input field with buttons to provide users with
+ * a quick and effective way to enter and modify a numeric value.
+ *
+ * @see https://www.patternfly.org/components/number-input
+ */
+public class NumberInput extends BaseComponent implements
+ Disabled,
+ HasValue {
+
+ // ------------------------------------------------------ factory
+
+ public static NumberInput numberInput(double initialValue) {
+ return new NumberInput(initialValue);
+ }
+
+ public static NumberInput numberInput() {
+ return new NumberInput(0d);
+ }
+
+ // ------------------------------------------------------ instance
+
+ private static final Variable INPUT_WIDTH_VARIABLE = componentVar(component(numberInput),
+ "c-form-control", "width-chars");
+
+ private final InputGroup inputGroup;
+ private final InputGroupItem minusButtonItem;
+ private final Button minusButton;
+ private final InputGroupItem textInputItem;
+ private final TextInput textInput;
+ private final InputGroupItem plusButtonItem;
+ private final Button plusButton;
+
+ private final List> blurHandlers;
+ private final List> changeHandlers;
+ private final List> minusHandlers;
+ private final List> plusHandlers;
+
+ private double value;
+ private double min = Double.NEGATIVE_INFINITY;
+ private double max = Double.POSITIVE_INFINITY;
+ private boolean disabled;
+ private String minusButtonAriaLabel = "Minus";
+ private String plusButtonAriaLabel = "Plus";
+ private HTMLElement unitElement;
+
+ private UnaryOperator plusOperation;
+ private UnaryOperator minusOperation;
+
+ private NumberInput(double initialValue) {
+ super(ComponentType.NumberInput, div().css(component(numberInput)).element());
+ this.value = clamp(initialValue);
+
+ this.blurHandlers = new LinkedList<>();
+ this.changeHandlers = new LinkedList<>();
+ this.minusHandlers = new LinkedList<>();
+ this.plusHandlers = new LinkedList<>();
+
+ // default increment and decrement operations (by +1/-1)
+ step(1);
+
+ // Default handlers
+ this.minusHandlers.add((e, component, value) -> component.value(this.minusOperation.apply(value)));
+ this.plusHandlers.add((e, component, value) -> component.value(this.plusOperation.apply(value)));
+
+ inputGroup = InputGroup.inputGroup();
+
+ // Minus button
+ minusButtonItem = InputGroupItem.inputGroupItem();
+ minusButton = Button.button()
+ .control()
+ .icon(fas.minus())
+ .aria(Aria.label, minusButtonAriaLabel)
+ .on(EventType.click, this::fireMinusHandler);
+ minusButtonItem.addButton(minusButton);
+ inputGroup.addItem(minusButtonItem);
+
+ // Text input
+ textInputItem = InputGroupItem.inputGroupItem();
+ textInput = TextInput.textInput(TextInputType.number, Id.unique(),
+ String.valueOf(initialValue));
+ textInput.input().on(change, this::handleInputChange);
+ textInput.input().on(blur, this::fireBlurHandler);
+ textInput.input().on(keydown, this::handleInputKeydown);
+ textInputItem.add(textInput);
+ inputGroup.addItem(textInputItem);
+
+ // Plus button
+ plusButtonItem = InputGroupItem.inputGroupItem();
+ plusButton = Button.button()
+ .control()
+ .icon(fas.plus())
+ .aria(Aria.label, plusButtonAriaLabel)
+ .on(EventType.click, this::firePlusHandler);
+ plusButtonItem.addButton(plusButton);
+ inputGroup.addItem(plusButtonItem);
+
+ element().appendChild(inputGroup.element());
+ storeComponent();
+ }
+
+ // ------------------------------------------------------ builder
+
+ public NumberInput widthChars(int widthChars) {
+ INPUT_WIDTH_VARIABLE.applyTo(element()).set(widthChars);
+ return this;
+ }
+
+ public NumberInput min(double min) {
+ this.min = min;
+ // clamp could be different after changing min
+ value(value);
+ return this;
+ }
+
+ public NumberInput max(double max) {
+ this.max = max;
+ // clamp could be different after changing max
+ value(value);
+ return this;
+ }
+
+ public NumberInput range(double min, double max) {
+ this.min = min;
+ this.max = max;
+ // clamp could be different after changing range (min and max)
+ value(value);
+ return this;
+ }
+
+ public NumberInput inputName(String inputName) {
+ textInput.attr("name", inputName);
+ return this;
+ }
+
+ public NumberInput inputAriaLabel(String label) {
+ textInput.aria(Aria.label, label);
+ return this;
+ }
+
+ public NumberInput minusButtonAriaLabel(String label) {
+ this.minusButtonAriaLabel = label;
+ minusButton.aria(Aria.label, label);
+ return this;
+ }
+
+ public NumberInput plusButtonAriaLabel(String label) {
+ this.plusButtonAriaLabel = label;
+ plusButton.aria(Aria.label, label);
+ return this;
+ }
+
+ public NumberInput unit(String value) {
+ return unit(value, UnitPosition.after);
+ }
+
+ public NumberInput unit(String value, UnitPosition position) {
+ failSafeRemoveFromParent(unitElement);
+ if (value != null && !value.isEmpty()) {
+ unitElement = div().css(component(numberInput, unit)).text(value).element();
+ switch (position) {
+ case before -> element().insertBefore(unitElement, inputGroup.element());
+ case after -> element().appendChild(unitElement);
+ }
+ }
+ return this;
+ }
+
+ @Override
+ public NumberInput disabled(boolean disabled) {
+ this.disabled = disabled;
+ minusButton.disabled(disabled);
+ textInput.disabled(disabled);
+ plusButton.disabled(disabled);
+ return Disabled.super.disabled(disabled);
+ }
+
+ public NumberInput validated(ValidationStatus validated) {
+ textInput.validated(validated);
+ return this;
+ }
+
+ public NumberInput value(double value) {
+ double clampedValue = clamp(value);
+ boolean changed = this.value != clampedValue;
+ this.value = clampedValue;
+ textInput.value(String.valueOf(this.value));
+ if (changed) {
+ fireChangeHandler(null);
+ }
+ updateButtonStates();
+ return this;
+ }
+
+ public NumberInput plusOperation(UnaryOperator operation) {
+ this.plusOperation = Objects.requireNonNull(operation, "operation must not be null");
+ return this;
+ }
+
+ public NumberInput minusOperation(UnaryOperator operation) {
+ this.minusOperation = Objects.requireNonNull(operation, "operation must not be null");
+ return this;
+ }
+
+ public NumberInput operations(UnaryOperator minusOperation,
+ UnaryOperator plusOperation) {
+ return minusOperation(minusOperation).plusOperation(plusOperation);
+ }
+
+ public NumberInput step(double steps) {
+ return operations(v -> v - steps, v -> v + steps);
+ }
+
+ @Override
+ public NumberInput that() {
+ return this;
+ }
+
+ // ------------------------------------------------------ events
+
+ public NumberInput onBlur(ChangeHandler blurHandler) {
+ this.blurHandlers.add(Objects.requireNonNull(blurHandler, "blurHandler must not be null"));
+ return this;
+ }
+
+ public NumberInput onChange(ChangeHandler changeHandler) {
+ this.changeHandlers.add(Objects.requireNonNull(changeHandler, "changeHandler must not be null"));
+ return this;
+ }
+
+ public NumberInput onMinus(ChangeHandler minusHandler) {
+ this.minusHandlers.add(Objects.requireNonNull(minusHandler, "minusHandler must not be null"));
+ return this;
+ }
+
+ public NumberInput onPlus(ChangeHandler plusHandler) {
+ this.plusHandlers.add(Objects.requireNonNull(plusHandler, "plusHandler must not be null"));
+ return this;
+ }
+
+ // ------------------------------------------------------ api
+
+ @Override
+ public Double value() {
+ return this.value;
+ }
+
+ // ------------------------------------------------------ internal
+
+ private double clamp(double value) {
+ return Math.max(min, Math.min(max, value));
+ }
+
+ private void handleInputChange(Event event) {
+ try {
+ value(Double.parseDouble(textInput.value()));
+ } catch (NumberFormatException e) {
+ // revert to previous value
+ textInput.value(String.valueOf(value));
+ }
+ }
+
+ private void handleInputKeydown(KeyboardEvent event) {
+ if (ArrowUp.match(event) && !plusButton.isDisabled()) {
+ event.preventDefault();
+ firePlusHandler(event);
+ } else if (ArrowDown.match(event) && !minusButton.isDisabled()) {
+ event.preventDefault();
+ fireMinusHandler(event);
+ }
+ }
+
+ private void updateButtonStates() {
+ // Disable minus button if at minimum
+ minusButton.disabled(disabled || (value <= min && !Double.isInfinite(min)));
+
+ // Disable plus button if at maximum
+ plusButton.disabled(disabled || (value >= max && !Double.isInfinite(max)));
+ }
+
+ private void fireBlurHandler(Event event) {
+ blurHandlers.forEach(handler -> handler.onChange(event, this, value));
+ }
+
+ private void fireChangeHandler(Event event) {
+ changeHandlers.forEach(handler -> handler.onChange(event, this, value));
+ }
+
+ private void fireMinusHandler(Event event) {
+ minusHandlers.forEach(handler -> handler.onChange(event, this, value));
+ }
+
+ private void firePlusHandler(Event event) {
+ plusHandlers.forEach(handler -> handler.onChange(event, this, value));
+ }
+}
diff --git a/components/src/main/java/org/patternfly/component/numberinput/UnitPosition.java b/components/src/main/java/org/patternfly/component/numberinput/UnitPosition.java
new file mode 100644
index 000000000..adc867765
--- /dev/null
+++ b/components/src/main/java/org/patternfly/component/numberinput/UnitPosition.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2023 Red Hat
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.patternfly.component.numberinput;
+
+public enum UnitPosition {
+ before,
+ after
+}
diff --git a/core/src/main/java/org/patternfly/style/Classes.java b/core/src/main/java/org/patternfly/style/Classes.java
index ef824ba5c..699d7c5e4 100644
--- a/core/src/main/java/org/patternfly/style/Classes.java
+++ b/core/src/main/java/org/patternfly/style/Classes.java
@@ -190,6 +190,7 @@ public interface Classes {
String noSidebar = "no-sidebar";
String notificationDrawer = "notification-drawer";
String notify = "notify";
+ String numberInput = "number-input";
String off = "off";
String on = "on";
String open = "open";
@@ -306,6 +307,7 @@ public interface Classes {
String treeView = "tree-view";
String truncate = "truncate";
String typeahead = "typeahead";
+ String unit = "unit";
String unread = "unread";
String utilities = "utilities";
String value = "value";
diff --git a/showcase/src/main/java/org/patternfly/showcase/component/NumberInputComponent.java b/showcase/src/main/java/org/patternfly/showcase/component/NumberInputComponent.java
new file mode 100644
index 000000000..18040406c
--- /dev/null
+++ b/showcase/src/main/java/org/patternfly/showcase/component/NumberInputComponent.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright 2023 Red Hat
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.patternfly.showcase.component;
+
+import org.jboss.elemento.router.Route;
+import org.patternfly.component.numberinput.NumberInput;
+import org.patternfly.component.numberinput.UnitPosition;
+import org.patternfly.showcase.Snippet;
+import org.patternfly.showcase.SnippetPage;
+
+import static elemental2.dom.DomGlobal.console;
+import static org.jboss.elemento.Elements.br;
+import static org.jboss.elemento.Elements.div;
+import static org.patternfly.component.numberinput.NumberInput.numberInput;
+import static org.patternfly.component.numberinput.UnitPosition.before;
+import static org.patternfly.showcase.ApiDoc.Type.component;
+import static org.patternfly.showcase.ApiDoc.Type.other;
+import static org.patternfly.showcase.Code.code;
+import static org.patternfly.showcase.Data.components;
+import static org.patternfly.component.ValidationStatus.error;
+import static org.patternfly.component.ValidationStatus.success;
+import static org.patternfly.component.ValidationStatus.warning;
+
+@Route(value = "/components/number-input", title = "Number input")
+public class NumberInputComponent extends SnippetPage {
+
+ public NumberInputComponent() {
+ super(components.get("number-input"));
+
+ startExamples();
+
+ // Default
+ addSnippet(new Snippet("number-input-default", "Default",
+ code("number-input-default"), () ->
+ // @code-start:number-input-default
+ div()
+ .add(numberInput(90).onBlur((e, c, v) -> {
+ console.log("blur");
+ }).onChange((e, c, v) -> {
+ console.log("change");
+ }))
+ .element()
+ // @code-end:number-input-default
+ ));
+
+ // With unit
+ addSnippet(new Snippet("number-input-with-unit", "With unit",
+ code("number-input-with-unit"), () ->
+ // @code-start:number-input-with-unit
+ div()
+ .add(numberInput(90)
+ .unit("%"))
+ .add(br())
+ .add(br())
+ .add(numberInput(90)
+ .unit("$", before))
+ .element()
+ // @code-end:number-input-with-unit
+ ));
+
+ // With unit and thresholds
+ addSnippet(new Snippet("number-input-with-unit-and-thresholds", "With unit and thresholds",
+ "To enable a user entered value to snap to the nearest threshold if the entered input is out of bounds, define the blur event handler",
+ code("number-input-with-unit-and-thresholds"), () -> {
+ // @code-start:number-input-with-unit-and-thresholds
+
+ NumberInput numberInput = numberInput(0)
+ .min(0)
+ .max(10)
+ .unit("%");
+
+ return div()
+ .add(" With a minimum value of 0 and maximum value of 10")
+ .add(br())
+ .add(numberInput)
+ .element();
+ // @code-end:number-input-with-unit-and-thresholds
+ }));
+
+ // Disabled
+ addSnippet(new Snippet("number-input-disabled", "Disabled",
+ code("number-input-disabled"), () ->
+ // @code-start:number-input-disabled
+ div()
+ .add(numberInput(100)
+ .disabled(true))
+ .element()
+ // @code-end:number-input-disabled
+ ));
+
+ // With status
+ addSnippet(new Snippet("number-input-with-status", "With status",
+ code("number-input-with-status"), () -> {
+ // @code-start:number-input-with-status
+ NumberInput statusInput = numberInput(5)
+ .validated(success)
+ .onChange((e, component, value) -> {
+ double distance = Math.abs(value - 5);
+ if (distance == 0) {
+ component.validated(success);
+ } else if (distance <= 2) {
+ component.validated(warning);
+ } else {
+ component.validated(error);
+ }
+ });
+ return div()
+ .add(statusInput)
+ .element();
+ // @code-end:number-input-with-status
+ }));
+
+ // Varying sizes
+ addSnippet(new Snippet("number-input-varying-sizes", "Varying sizes",
+ code("number-input-varying-sizes"), () ->
+ // @code-start:number-input-varying-sizes
+ div()
+ .add(numberInput(1).widthChars(1))
+ .add(br())
+ .add(br())
+ .add(numberInput(1234567890).widthChars(10))
+ .add(br())
+ .add(br())
+ .add(numberInput(5).widthChars(5))
+ .add(br())
+ .add(br())
+ .add(numberInput(12345).widthChars(5))
+ .element()
+ // @code-end:number-input-varying-sizes
+ ));
+
+ // Custom increment/decrement
+ addSnippet(new Snippet("number-input-custom-increment-decrement", "Custom increment/decrement",
+ code("number-input-custom-increment-decrement"), () ->
+ // @code-start:number-input-custom-increment-decrement
+ div()
+ .add(numberInput(90).step(3))
+ .element()
+ // @code-end:number-input-custom-increment-decrement
+ ));
+
+ // Custom increment/decrement and thresholds
+ addSnippet(new Snippet("number-input-custom-increment-decrement-thresholds",
+ "Custom increment/decrement and thresholds",
+ code("number-input-custom-increment-decrement-thresholds"), () ->
+ // @code-start:number-input-custom-increment-decrement-thresholds
+ div()
+ .add(numberInput(90)
+ .min(90)
+ .max(100)
+ .step(3))
+ .element()
+ // @code-end:number-input-custom-increment-decrement-thresholds
+ ));
+
+ startApiDocs(NumberInput.class);
+ addApiDoc(NumberInput.class, component);
+ addApiDoc(UnitPosition.class, other);
+ }
+}
diff --git a/showcase/src/main/resources/org/patternfly/showcase/components.json b/showcase/src/main/resources/org/patternfly/showcase/components.json
index 3aa715f1c..8bfc1c1d1 100644
--- a/showcase/src/main/resources/org/patternfly/showcase/components.json
+++ b/showcase/src/main/resources/org/patternfly/showcase/components.json
@@ -413,6 +413,7 @@
"name": "number-input",
"title": "Number input",
"route": "/components/number-input",
+ "clazz": "org.patternfly.component.notification.NumberInput",
"illustration": "number-input.png",
"summary": "A number input combines a text input field with buttons to provide users with a quick and effective way to enter and modify a numeric value."
},