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." },