From 91639ec07729ca687e37571ec5da56ef3c275e0f Mon Sep 17 00:00:00 2001 From: Zhihao Cui <5257855+origami-z@users.noreply.github.com> Date: Mon, 30 Sep 2024 11:10:01 +0100 Subject: [PATCH] Refactor stepper input (#3976) --- .changeset/polite-bags-act.md | 21 + docs/components/ResponsiveContainer.tsx | 4 +- .../StepperInput.accessibility.cy.tsx | 26 +- .../__e2e__/stepper-input/StepperInput.cy.tsx | 163 ++++++-- .../lab/src/stepper-input/StepperInput.css | 279 ++++++++++++- .../lab/src/stepper-input/StepperInput.tsx | 388 +++++++++++++++--- .../internal/useActivateWhileMouseDown.ts | 56 +++ .../src/stepper-input/internal/useSpinner.ts | 35 -- .../lab/src/stepper-input/internal/utils.ts | 44 ++ .../lab/src/stepper-input/useStepperInput.ts | 349 ++++++---------- .../stepper-input.qa.stories.tsx | 15 +- .../stepper-input/stepper-input.stories.tsx | 161 ++++++-- 12 files changed, 1138 insertions(+), 403 deletions(-) create mode 100644 .changeset/polite-bags-act.md create mode 100644 packages/lab/src/stepper-input/internal/useActivateWhileMouseDown.ts delete mode 100644 packages/lab/src/stepper-input/internal/useSpinner.ts create mode 100644 packages/lab/src/stepper-input/internal/utils.ts diff --git a/.changeset/polite-bags-act.md b/.changeset/polite-bags-act.md new file mode 100644 index 0000000000..bbca6ad5b7 --- /dev/null +++ b/.changeset/polite-bags-act.md @@ -0,0 +1,21 @@ +--- +"@salt-ds/lab": minor +--- + +## StepperInput updates + +- Added `bordered` prop for a full border style +- Changed `StepperInputProps` to extend `div` props instead of `Input`, to align with other input components +- Added an optional event to `onChange`, when triggered by synthetic event +- Added more keyboard interactions, e.g. Shift + Up / Down, Home, End. +- Replaced `block` with `stepBlock` prop, which now explicitly defines the value that is increment or decrement, not a multiplier of `step`. + +```tsx + { + setValue(value); + } +/> +``` diff --git a/docs/components/ResponsiveContainer.tsx b/docs/components/ResponsiveContainer.tsx index d01df4eba7..f2cb82001e 100644 --- a/docs/components/ResponsiveContainer.tsx +++ b/docs/components/ResponsiveContainer.tsx @@ -31,7 +31,7 @@ export const ResponsiveContainer = ({ children }: { children?: ReactNode }) => { value={containerWidth[0]} max={maxUnits} min={10} - onChange={(nextValue) => setWidth([nextValue] as number[])} + onChange={(_event, nextValue) => setWidth([nextValue as number])} /> { value={containerHeight[0]} max={maxUnits} min={10} - onChange={(nextValue) => setHeight([nextValue] as number[])} + onChange={(_event, nextValue) => setHeight([nextValue as number])} /> { + checkAccessibility(composedStories); + it("sets the correct default ARIA attributes on input", () => { cy.mount( - { }); it("has the correct labelling when wrapped in a `FormField`", () => { - cy.mount( - - Stepper Input - - Please enter a value - , - ); + cy.mount(); cy.findByRole("spinbutton").should("have.accessibleName", "Stepper Input"); cy.findByRole("spinbutton").should( "have.accessibleDescription", - "Please enter a value", + "Please enter a number", ); }); it("sets `aria-invalid=false` on input when the value is out of range", () => { - cy.mount(); + cy.mount(); cy.findByRole("spinbutton").should("have.attr", "aria-invalid", "true"); }); it("sets the correct default ARIA attributes on the increment/decrement buttons", () => { - cy.mount(); + cy.mount(); cy.findByLabelText("increment value") .should("have.attr", "tabindex", "-1") .and("have.attr", "aria-hidden", "true"); diff --git a/packages/lab/src/__tests__/__e2e__/stepper-input/StepperInput.cy.tsx b/packages/lab/src/__tests__/__e2e__/stepper-input/StepperInput.cy.tsx index 30ebf59e7b..37d9d67c48 100644 --- a/packages/lab/src/__tests__/__e2e__/stepper-input/StepperInput.cy.tsx +++ b/packages/lab/src/__tests__/__e2e__/stepper-input/StepperInput.cy.tsx @@ -1,8 +1,13 @@ -import { StepperInput } from "@salt-ds/lab"; +import * as stepperInputStories from "@stories/stepper-input/stepper-input.stories"; +import { composeStories } from "@storybook/react"; + +const composedStories = composeStories(stepperInputStories); + +const { Default, MinAndMaxValue, RefreshAdornment } = composedStories; describe("Stepper Input", () => { it("renders with default props", () => { - cy.mount(); + cy.mount(); // Component should render with two buttons - increment, and decrement cy.findAllByRole("button", { hidden: true }).should("have.length", 2); @@ -11,7 +16,7 @@ describe("Stepper Input", () => { }); it("increments the default value on button click", () => { - cy.mount(); + cy.mount(); cy.findByLabelText("increment value").realClick({ clickCount: 2 }); @@ -19,7 +24,7 @@ describe("Stepper Input", () => { }); it("decrements the default value on button click", () => { - cy.mount(); + cy.mount(); cy.findByLabelText("decrement value").realClick({ clickCount: 2 }); @@ -27,7 +32,7 @@ describe("Stepper Input", () => { }); it("increments from an empty value on button click", () => { - cy.mount(); + cy.mount(); cy.findByRole("spinbutton").clear(); cy.findByRole("spinbutton").should("have.value", ""); @@ -38,7 +43,7 @@ describe("Stepper Input", () => { }); it("decrements from an empty value on button click", () => { - cy.mount(); + cy.mount(); cy.findByRole("spinbutton").clear(); cy.findByRole("spinbutton").should("have.value", ""); @@ -49,7 +54,7 @@ describe("Stepper Input", () => { }); it("increments to `1` from a minus symbol on button click", () => { - cy.mount(); + cy.mount(); cy.findByRole("spinbutton").focus(); cy.findByRole("spinbutton").clear(); @@ -61,7 +66,7 @@ describe("Stepper Input", () => { }); it("decrements to `-1` from a minus symbol on button click", () => { - cy.mount(); + cy.mount(); cy.findByRole("spinbutton").focus(); cy.findByRole("spinbutton").clear(); @@ -73,56 +78,82 @@ describe("Stepper Input", () => { }); it("renders with specified `defaultValue` and number of `decimalPlaces`", () => { - cy.mount(); + cy.mount(); cy.findByRole("spinbutton").should("have.value", "10.0000"); }); - it("increments by specified `step` value", () => { - cy.mount(); + it("increments specified `step` value when clicking increment button", () => { + cy.mount(); cy.findByLabelText("increment value").realClick(); cy.findByRole("spinbutton").should("have.value", "20"); }); - it("increments by specified floating point `step` value", () => { - cy.mount( - , - ); + it("increments specified floating point `step` value when clicking increment button", () => { + cy.mount(); cy.findByLabelText("increment value").realClick(); cy.findByRole("spinbutton").should("have.value", "3.15"); }); - it("decrements by specified `step` value", () => { - cy.mount(); + it("increments specified `step` and `stepBlock` value when using keyboards", () => { + cy.mount( + , + ); + + cy.findByRole("spinbutton").focus().realPress("ArrowUp"); + cy.findByRole("spinbutton").should("have.value", "20").realPress("PageUp"); + cy.findByRole("spinbutton") + .should("have.value", "120") + .realPress(["Shift", "ArrowUp"]); + cy.findByRole("spinbutton").should("have.value", "220").realPress("End"); + cy.findByRole("spinbutton").should("have.value", "2000"); + }); + + it("decrements specified `step` value when clicking decrement button", () => { + cy.mount(); cy.findByLabelText("decrement value").realClick(); cy.findByRole("spinbutton").should("have.value", "-10"); }); - it("decrements by specified floating point `step` value", () => { - cy.mount(); + it("decrements specified floating point `step` value when clicking decrement button", () => { + cy.mount(); cy.findByLabelText("decrement value").realClick(); cy.findByRole("spinbutton").should("have.value", "-0.01"); }); + it("decrements specified `step` and `stepBlock` value when using keyboards", () => { + cy.mount( + , + ); + + cy.findByRole("spinbutton").focus().realPress("ArrowDown"); + cy.findByRole("spinbutton").should("have.value", "0").realPress("PageDown"); + cy.findByRole("spinbutton") + .should("have.value", "-100") + .realPress(["Shift", "ArrowDown"]); + cy.findByRole("spinbutton").should("have.value", "-200").realPress("Home"); + cy.findByRole("spinbutton").should("have.value", "-2000"); + }); + it("disables the increment button at `max`", () => { - cy.mount(); + cy.mount(); cy.findByLabelText("increment value").realClick(); cy.findByLabelText("increment value").should("be.disabled"); }); it("disables the decrement button at `min`", () => { - cy.mount(); + cy.mount(); cy.findByLabelText("decrement value").realClick(); cy.findByLabelText("decrement value").should("be.disabled"); }); it("displays value with correct number of decimal places on blur", () => { - cy.mount(); + cy.mount(); cy.findByRole("spinbutton").focus(); cy.findByRole("spinbutton").clear(); @@ -134,17 +165,21 @@ describe("Stepper Input", () => { it("calls the `onChange` callback when the value is decremented", () => { const changeSpy = cy.stub().as("changeSpy"); - cy.mount(); + cy.mount(); cy.findByLabelText("decrement value").realClick(); - cy.get("@changeSpy").should("have.been.calledWith", "15"); + cy.get("@changeSpy").should( + "have.been.calledWith", + Cypress.sinon.match.any, + "15", + ); }); it("calls the `onChange` callback when the value is incremented", () => { const changeSpy = cy.stub().as("changeSpy"); cy.mount( - { ); cy.findByLabelText("increment value").realClick(); - cy.get("@changeSpy").should("have.been.calledWith", "-109.44"); + cy.get("@changeSpy").should( + "have.been.calledWith", + Cypress.sinon.match.any, + "-109.44", + ); }); it("allows maximum safe integer", () => { - cy.mount(); + cy.mount(); cy.findByRole("spinbutton").should( "have.value", @@ -166,7 +205,7 @@ describe("Stepper Input", () => { }); it("allows minimum safe integer", () => { - cy.mount(); + cy.mount(); cy.findByRole("spinbutton").should( "have.value", @@ -178,10 +217,7 @@ describe("Stepper Input", () => { const changeSpy = cy.stub().as("changeSpy"); cy.mount( - , + , ); cy.findByLabelText("increment value").realClick(); @@ -198,10 +234,7 @@ describe("Stepper Input", () => { const changeSpy = cy.stub().as("changeSpy"); cy.mount( - , + , ); cy.findByLabelText("decrement value").realClick(); @@ -214,8 +247,30 @@ describe("Stepper Input", () => { ); }); + it("does not decrement below the minimum value", () => { + const changeSpy = cy.stub().as("changeSpy"); + cy.mount(); + + cy.findByRole("spinbutton").should("have.value", -1); + + cy.findByLabelText("decrement value").realClick(); + cy.get("@changeSpy").should("not.have.been.called"); + cy.findByRole("spinbutton").should("have.value", -1); + }); + + it("does not increment above the maximum value", () => { + const changeSpy = cy.stub().as("changeSpy"); + cy.mount(); + + cy.findByRole("spinbutton").should("have.value", 1); + + cy.findByLabelText("increment value").realClick(); + cy.get("@changeSpy").should("not.have.been.called"); + cy.findByRole("spinbutton").should("have.value", 1); + }); + it("rounds up to correct number of decimal places", () => { - cy.mount(); + cy.mount(); cy.findByRole("spinbutton").focus(); cy.realPress("Tab"); @@ -223,7 +278,7 @@ describe("Stepper Input", () => { }); it("rounds down to correct number of decimal places", () => { - cy.mount(); + cy.mount(); cy.findByRole("spinbutton").focus(); cy.realPress("Tab"); @@ -231,7 +286,7 @@ describe("Stepper Input", () => { }); it("pads with zeros to correct number of decimal places", () => { - cy.mount(); + cy.mount(); cy.findByRole("spinbutton").focus(); cy.realPress("Tab"); @@ -239,7 +294,7 @@ describe("Stepper Input", () => { }); it("increments the value on arrow up key press", () => { - cy.mount(); + cy.mount(); cy.findByRole("spinbutton").focus(); cy.realPress("ArrowUp"); @@ -248,7 +303,7 @@ describe("Stepper Input", () => { }); it("decrements the value on arrow down key press", () => { - cy.mount(); + cy.mount(); cy.findByRole("spinbutton").focus(); cy.realPress("ArrowDown"); @@ -257,7 +312,7 @@ describe("Stepper Input", () => { }); it("is disabled when the `disabled` prop is true", () => { - cy.mount(); + cy.mount(); cy.findByRole("spinbutton").should("be.disabled"); cy.findByLabelText("increment value").should("be.disabled"); @@ -265,7 +320,7 @@ describe("Stepper Input", () => { }); it("is controlled when the `value` prop is provided", () => { - cy.mount(); + cy.mount(); cy.findByRole("spinbutton").should("have.value", "5"); @@ -277,7 +332,7 @@ describe("Stepper Input", () => { }); it("sanitizes input to only allow numbers, decimal points, and plus/minus symbols", () => { - cy.mount(); + cy.mount(); cy.findByRole("spinbutton").focus(); cy.findByRole("spinbutton").clear(); @@ -285,4 +340,24 @@ describe("Stepper Input", () => { cy.findByRole("spinbutton").should("have.value", "-12.3"); }); + + it("allows out of range input remains in the input and show status by the user", () => { + cy.mount(); + cy.findByRole("spinbutton").focus(); + cy.realType("2"); + cy.realPress("Tab"); + + cy.findByRole("spinbutton").should("have.value", "22"); + cy.findByLabelText("increment value").should("be.disabled"); + cy.findByTestId("ErrorSolidIcon").should("exist"); + }); + + it("refreshes to default value in RefreshAdornment example", () => { + cy.mount(); + + cy.findByRole("spinbutton").focus().realPress("ArrowUp"); + cy.findByRole("spinbutton").should("have.value", "11"); + cy.findByRole("button", { name: "refresh" }).realClick(); + cy.findByRole("spinbutton").should("have.value", "10"); + }); }); diff --git a/packages/lab/src/stepper-input/StepperInput.css b/packages/lab/src/stepper-input/StepperInput.css index b45994e2a4..83a7b80d4f 100644 --- a/packages/lab/src/stepper-input/StepperInput.css +++ b/packages/lab/src/stepper-input/StepperInput.css @@ -1,20 +1,291 @@ /* Styles applied to stepper container */ .saltStepperInput { + --stepperInput-border: none; + --stepperInput-borderColor: var(--salt-editable-borderColor); + --stepperInput-borderStyle: var(--salt-editable-borderStyle); + --stepperInput-outlineColor: var(--salt-focused-outlineColor); + --stepperInput-borderWidth: var(--salt-size-border); + align-items: center; - display: flex; - flex-direction: row; + color: var(--salt-content-primary-foreground); + display: inline-flex; + font-family: var(--salt-text-fontFamily); + font-size: var(--salt-text-fontSize); + height: var(--salt-size-base); + line-height: var(--salt-text-lineHeight); + min-height: var(--salt-size-base); + min-width: 4em; + width: 100%; + box-sizing: border-box; + gap: var(--salt-spacing-50); } +.saltStepperInput:hover { + --stepperInput-borderStyle: var(--salt-editable-borderStyle-hover); + --stepperInput-borderColor: var(--salt-editable-borderColor-hover); + + background: var(--stepperInput-background-hover); + cursor: var(--salt-editable-cursor-hover); +} + +.saltStepperInput:active { + --stepperInput-borderColor: var(--salt-editable-borderColor-active); + --stepperInput-borderStyle: var(--salt-editable-borderStyle-active); + --stepperInput-borderWidth: var(--salt-editable-borderWidth-active); + + background: var(--stepperInput-background-active); + cursor: var(--salt-editable-cursor-active); +} + +/* Class applied if `variant="primary"` */ +.saltStepperInput-primary { + --stepperInput-background: var(--salt-editable-primary-background); + --stepperInput-background-active: var(--salt-editable-primary-background-active); + --stepperInput-background-hover: var(--salt-editable-primary-background-hover); + --stepperInput-background-disabled: var(--salt-editable-primary-background-disabled); + --stepperInput-background-readonly: var(--salt-editable-primary-background-readonly); +} + +/* Class applied if `variant="secondary"` */ +.saltStepperInput-secondary { + --stepperInput-background: var(--salt-editable-secondary-background); + --stepperInput-background-active: var(--salt-editable-secondary-background-active); + --stepperInput-background-hover: var(--salt-editable-secondary-background-active); + --stepperInput-background-disabled: var(--salt-editable-secondary-background-disabled); + --stepperInput-background-readonly: var(--salt-editable-secondary-background-readonly); +} + +/* Style applied to input if `validationState="error"` */ +.saltStepperInput-error, +.saltStepperInput-error:hover { + --stepperInput-background: var(--salt-status-error-background); + --stepperInput-background-active: var(--salt-status-error-background); + --stepperInput-background-hover: var(--salt-status-error-background); + --stepperInput-borderColor: var(--salt-status-error-borderColor); + --stepperInput-outlineColor: var(--salt-status-error-borderColor); + --stepperInput-background-readonly: var(--salt-status-error-background); +} + +/* Style applied to input if `validationState="warning"` */ +.saltStepperInput-warning, +.saltStepperInput-warning:hover { + --stepperInput-background: var(--salt-status-warning-background); + --stepperInput-background-active: var(--salt-status-warning-background); + --stepperInput-background-hover: var(--salt-status-warning-background); + --stepperInput-borderColor: var(--salt-status-warning-borderColor); + --stepperInput-outlineColor: var(--salt-status-warning-borderColor); + --stepperInput-background-readonly: var(--salt-status-warning-background); +} + +/* Style applied to input if `validationState="success"` */ +.saltStepperInput-success, +.saltStepperInput-success:hover { + --stepperInput-background: var(--salt-status-success-background); + --stepperInput-background-active: var(--salt-status-success-background); + --stepperInput-background-hover: var(--salt-status-success-background); + --stepperInput-borderColor: var(--salt-status-success-borderColor); + --stepperInput-outlineColor: var(--salt-status-success-borderColor); + --stepperInput-background-readonly: var(--salt-status-success-background); +} + +.saltStepperInput-inputContainer { + display: flex; + background: var(--stepperInput-background); + border-radius: var(--salt-palette-corner-weak, 0); + border: var(--stepperInput-border); + box-sizing: border-box; + height: var(--salt-size-base); + min-height: var(--salt-size-base); + overflow: hidden; + padding-left: var(--salt-spacing-100); + padding-right: var(--salt-spacing-100); + position: relative; + flex-grow: 1; +} + +/* Style applied to inner input component */ +.saltStepperInput-input { + background: none; + border: none; + box-sizing: content-box; + color: inherit; + cursor: inherit; + display: block; + flex: 1; + font: inherit; + height: 100%; + letter-spacing: var(--saltStepperInput-letterSpacing, 0); + margin: 0; + min-width: 0; + overflow: hidden; + padding: 0; + text-align: var(--stepperInput-textAlign); + width: 100%; +} + +.saltStepperInput-input:focus { + outline: none; +} + +/* Style applied to selected input */ +.saltStepperInput-input::selection { + background: var(--salt-content-foreground-highlight); +} + +/* Style applied to placeholder text */ +.saltStepperInput-input::placeholder { + color: var(--salt-content-secondary-foreground); + font-weight: var(--salt-text-fontWeight-small); +} + +/* Styling when focused */ +.saltStepperInput-focused { + --stepperInput-borderColor: var(--stepperInput-outlineColor); + --stepperInput-borderWidth: var(--salt-editable-borderWidth-active); + + outline: var(--saltStepperInput-outline, var(--salt-focused-outlineWidth) var(--salt-focused-outlineStyle) var(--stepperInput-outlineColor)); +} + +/* Style applied if `readOnly={true}` */ +.saltStepperInput-readOnly { + --stepperInput-borderColor: var(--salt-editable-borderColor-readonly); + --stepperInput-borderStyle: var(--salt-editable-borderStyle-readonly); + --stepperInput-borderWidth: var(--salt-size-border); + + background: var(--stepperInput-background-readonly); + cursor: var(--salt-editable-cursor-readonly); +} + +/* Styling when focused if `disabled={true}` */ +.saltStepperInput-focused.saltStepperInput-disabled { + --stepperInput-borderWidth: var(--salt-size-border); + outline: none; +} + +/* Styling when focused if `readOnly={true}` */ +.saltStepperInput-focused.saltStepperInput-readOnly { + --stepperInput-borderWidth: var(--salt-size-border); +} + +/* Style applied to selected input if `disabled={true}` */ +.saltStepperInput-disabled .saltStepperInput-input::selection { + background: none; +} + +/* Style applied to input if `disabled={true}` */ +.saltStepperInput-disabled, +.saltStepperInput-disabled:hover, +.saltStepperInput-disabled:active { + --stepperInput-borderColor: var(--salt-editable-borderColor-disabled); + --stepperInput-borderStyle: var(--salt-editable-borderStyle-disabled); + --stepperInput-borderWidth: var(--salt-size-border); + + background: var(--stepperInput-background-disabled); + cursor: var(--salt-editable-cursor-disabled); + color: var(--saltStepperInput-color-disabled, var(--salt-content-primary-foreground-disabled)); +} + +.saltStepperInput-activationIndicator { + left: 0; + bottom: 0; + width: 100%; + position: absolute; + border-bottom: var(--stepperInput-borderWidth) var(--stepperInput-borderStyle) var(--stepperInput-borderColor); +} + +/* Style applied if `bordered={true}` */ +.saltStepperInput-bordered { + --stepperInput-border: var(--salt-size-border) var(--salt-container-borderStyle) var(--stepperInput-borderColor); + --stepperInput-borderWidth: 0; +} + +/* Style applied if focused or active when `bordered={true}` */ +.saltStepperInput-bordered.saltStepperInput-focused, +.saltStepperInput-bordered:active { + --stepperInput-borderWidth: var(--salt-editable-borderWidth-active); +} + +/* Styling when focused if `disabled={true}` or `readOnly={true}` when `bordered={true}` */ +.saltStepperInput-bordered.saltStepperInput-readOnly, +.saltStepperInput-bordered.saltStepperInput-disabled:hover { + --stepperInput-borderWidth: 0; +} + +/* Style applied to start adornments */ +.saltStepperInput-startAdornmentContainer { + align-items: center; + display: inline-flex; + padding-right: var(--salt-spacing-100); + column-gap: var(--salt-spacing-100); +} + +/* Style applied to end adornments */ +.saltStepperInput-endAdornmentContainer { + align-items: center; + display: inline-flex; + padding-left: var(--salt-spacing-100); + column-gap: var(--salt-spacing-100); +} + +.saltStepperInput-readOnly .saltStepperInput-startAdornmentContainer { + margin-left: var(--salt-spacing-50); +} + +.saltStepperInput-startAdornmentContainer .saltButton ~ .saltButton { + margin-left: calc(-1 * var(--salt-spacing-50)); +} + +.saltStepperInput-endAdornmentContainer .saltButton ~ .saltButton { + margin-left: calc(-1 * var(--salt-spacing-50)); +} + +.saltStepperInput-startAdornmentContainer .saltButton:first-child { + margin-left: calc(var(--salt-spacing-50) * -1); +} + +.saltStepperInput-endAdornmentContainer .saltButton:last-child { + margin-right: calc(var(--salt-spacing-50) * -1); +} + +.saltStepperInput-startAdornmentContainer > .saltButton, +.saltStepperInput-endAdornmentContainer > .saltButton { + --saltButton-padding: calc(var(--salt-spacing-50) - var(--salt-size-border)); + --saltButton-height: calc(var(--salt-size-base) - var(--salt-spacing-100)); + --saltButton-borderRadius: var(--salt-palette-corner-weaker); +} + +.saltStepperInput-inputTextAlignLeft { + text-align: left; +} + +.saltStepperInput-inputTextAlignCenter { + text-align: center; +} + +.saltStepperInput-inputTextAlignRight { + text-align: right; +} + +/* --- Buttons --- */ + /* Styles applied to stepper buttons container */ .saltStepperInput-buttonContainer { + --stepperInput-buttonGap: var(--salt-size-border-strong); display: flex; flex-direction: column; - gap: var(--salt-spacing-50); + gap: var(--stepperInput-buttonGap); } /* Styles applied to stepper buttons */ .saltStepperInput-stepperButton { - --saltButton-height: calc((var(--salt-size-base) - var(--salt-spacing-50)) * 0.5); + --saltButton-height: calc((var(--salt-size-base) - var(--stepperInput-buttonGap)) * 0.5); --saltButton-width: var(--salt-size-base); } + +.saltStepperInput-stepperButtonIncrement { + --saltButton-borderRadius: var(--salt-palette-corner-weak, 0) var(--salt-palette-corner-weak, 0) 0 0; +} +.saltStepperInput-stepperButtonDecrement { + --saltButton-borderRadius: 0 0 var(--salt-palette-corner-weak, 0) var(--salt-palette-corner-weak, 0); +} diff --git a/packages/lab/src/stepper-input/StepperInput.tsx b/packages/lab/src/stepper-input/StepperInput.tsx index 534ed35d5c..33b44bacd4 100644 --- a/packages/lab/src/stepper-input/StepperInput.tsx +++ b/packages/lab/src/stepper-input/StepperInput.tsx @@ -1,21 +1,49 @@ -import { Button, Input, type InputProps, makePrefixer } from "@salt-ds/core"; +import { + Button, + StatusAdornment, + type ValidationStatus, + capitalize, + makePrefixer, + useControlled, + useForkRef, + useFormFieldProps, +} from "@salt-ds/core"; import { TriangleDownIcon, TriangleUpIcon } from "@salt-ds/icons"; import { useComponentCssInjection } from "@salt-ds/styles"; import { useWindow } from "@salt-ds/window"; import { clsx } from "clsx"; -import { type FocusEventHandler, forwardRef, useRef } from "react"; -import { useStepperInput } from "./useStepperInput"; +import { + type ChangeEvent, + type ComponentPropsWithoutRef, + type FocusEvent, + type InputHTMLAttributes, + type KeyboardEvent, + type ReactNode, + type Ref, + type SyntheticEvent, + forwardRef, + useRef, + useState, +} from "react"; +import { + isAllowedNonNumeric, + isOutOfRange, + sanitizedInput, + toFixedDecimalPlaces, + toFloat, +} from "./internal/utils"; import stepperInputCss from "./StepperInput.css"; +import { useStepperInput } from "./useStepperInput"; const withBaseName = makePrefixer("saltStepperInput"); export interface StepperInputProps - extends Omit { + extends Omit, "onChange"> { /** - * A multiplier applied to the `step` when the value is incremented or decremented using the PageDown/PageUp keys. + * A boolean. When `true`, the input will receive a full border. */ - block?: number; + bordered?: boolean; /** * The number of decimal places to display. */ @@ -23,57 +51,124 @@ export interface StepperInputProps /** * Sets the initial default value of the component. */ - defaultValue?: number; + defaultValue?: number | string; /** - * The maximum value that can be selected. + * If `true`, the stepper input will be disabled. */ - max?: number; + disabled?: boolean; /** - * The minimum value that can be selected. + * The marker to use in an empty read only Input. + * Use `''` to disable this feature. Defaults to '—'. + * @default '—' */ - min?: number; + emptyReadOnlyMarker?: string; + /** + * End adornment component + */ + endAdornment?: ReactNode; /** * Whether to hide the stepper buttons. Defaults to `false`. + * @default false */ hideButtons?: boolean; /** - * Callback when stepper input loses focus. + * [Attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#Attributes) applied to the `input` element. + */ + inputProps?: InputHTMLAttributes; + /** + * Optional ref for the input component + */ + inputRef?: Ref; + /** + * The maximum value that can be selected. Defaults to Number.MAX_SAFE_INTEGER. + * @default Number.MAX_SAFE_INTEGER + */ + max?: number; + /** + * The minimum value that can be selected. Defaults to Number.MIN_SAFE_INTEGER. + * @default Number.MIN_SAFE_INTEGER */ - onBlur?: FocusEventHandler; + min?: number; /** * Callback when stepper input value is changed. + * @param event - the event triggers value change, could be undefined during increment / decrement button long press + */ + onChange?: ( + event: SyntheticEvent | undefined, + value: number | string, + ) => void; + /** + * A string. Displayed in a dimmed color when the input value is empty. */ - onChange?: (changedValue: number | string) => void; + placeholder?: string | undefined; /** - * Callback when stepper input gains focus. + * A boolean. If `true`, the component is not editable by the user. */ - onFocus?: FocusEventHandler; + readOnly?: boolean; /** - * The amount to increment or decrement the value by when using the stepper buttons or Up Arrow and Down Arrow keys. + * Start adornment component + */ + startAdornment?: ReactNode; + /** + * The amount to increment or decrement the value by when using the stepper buttons or Up Arrow and Down Arrow keys. Default to 1. + * @default 1 */ step?: number; /** - * Determines the text alignment of the display value. + * The amount to change the value when the value is incremented or decremented by holding Shift and pressing Up arrow or Down arrow keys. + * Defaults to 10. + * @default 10 + */ + stepBlock?: number; + /** + * Alignment of text within container. Defaults to "left". + * @default "left" */ - textAlign?: "center" | "left" | "right"; + textAlign?: "left" | "center" | "right"; + /** + * Validation status. + */ + validationStatus?: Extract; + /** + * Styling variant. Defaults to "primary". + * @default "primary" + */ + variant?: "primary" | "secondary"; /** * The value of the stepper input. The component will be controlled if this prop is provided. */ - value?: number | string; + value?: number | string | undefined; } export const StepperInput = forwardRef( - function StepperInput(props, ref) { - const { - className, + function StepperInput( + { + bordered, + className: classNameProp, + decimalPlaces = 0, + defaultValue: defaultValueProp, + disabled, + emptyReadOnlyMarker = "—", + endAdornment, hideButtons, - onBlur, - onChange, - onFocus, - readOnly, - ...rest - } = props; - + inputProps: inputPropsProp = {}, + inputRef: inputRefProp, + max = Number.MAX_SAFE_INTEGER, + min = Number.MIN_SAFE_INTEGER, + onChange: onChangeProp, + placeholder, + readOnly: readOnlyProp, + startAdornment, + step = 1, + stepBlock = 10, + textAlign = "left", + validationStatus: validationStatusProp, + value: valueProp, + variant = "primary", + ...restProps + }, + ref, + ) { const targetWindow = useWindow(); useComponentCssInjection({ testId: "salt-stepper-input", @@ -81,32 +176,233 @@ export const StepperInput = forwardRef( window: targetWindow, }); + const { + a11yProps: { + "aria-describedby": formFieldDescribedBy, + "aria-labelledby": formFieldLabelledBy, + } = {}, + disabled: formFieldDisabled, + readOnly: formFieldReadOnly, + necessity: formFieldRequired, + validationStatus: formFieldValidationStatus, + } = useFormFieldProps(); + + const isDisabled = disabled || formFieldDisabled; + const isReadOnly = readOnlyProp || formFieldReadOnly; + const validationStatus = formFieldValidationStatus ?? validationStatusProp; + + const { + "aria-describedby": inputDescribedBy, + "aria-labelledby": inputLabelledBy, + className: inputClassName, + onBlur: inputOnBlur, + onChange: inputOnChange, + onFocus: inputOnFocus, + required: inputRequired, + onKeyDown: inputOnKeyDown, + ...restInputProps + } = inputPropsProp; + + const isRequired = formFieldRequired + ? ["required", "asterisk"].includes(formFieldRequired) + : inputRequired; + + const [value, setValue] = useControlled({ + controlled: valueProp, + default: + typeof defaultValueProp === "number" + ? toFixedDecimalPlaces(defaultValueProp, decimalPlaces) + : defaultValueProp, + name: "StepperInput", + state: "value", + }); + + // Won't be needed when `:has` css can be used + const [focused, setFocused] = useState(false); + const inputRef = useRef(null); + const forkedInputRef = useForkRef(inputRef, inputRefProp); + + const { + decrementButtonProps, + decrementValue, + incrementButtonProps, + incrementValue, + } = useStepperInput({ + inputRef, + setValue, + decimalPlaces, + disabled, + max, + min, + onChange: onChangeProp, + readOnly: isReadOnly, + step, + stepBlock, + value, + }); + + const handleInputFocus = (event: FocusEvent) => { + setFocused(true); - const { getButtonProps, getInputProps } = useStepperInput(props, inputRef); + inputOnFocus?.(event); + }; + + const handleInputBlur = (event: FocusEvent) => { + setFocused(false); + + if (value === undefined) return; + + const floatValue = toFloat(value); + if (Number.isNaN(floatValue)) { + // Keep original value if NaN + setValue(value); + onChangeProp?.(event, value); + } else { + const roundedValue = toFixedDecimalPlaces(floatValue, decimalPlaces); + + if (value !== "" && !isAllowedNonNumeric(value)) { + setValue(roundedValue); + } + + onChangeProp?.(event, roundedValue); + } + + inputOnBlur?.(event); + }; + + const handleInputChange = (event: ChangeEvent) => { + const changedValue = event.target.value; + + setValue(sanitizedInput(changedValue)); + + onChangeProp?.(event, sanitizedInput(changedValue)); + inputOnChange?.(event); + }; + + const handleInputKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case "ArrowUp": { + event.preventDefault(); + const block = event.shiftKey; + incrementValue(event, block); + break; + } + case "ArrowDown": { + event.preventDefault(); + const block = event.shiftKey; + decrementValue(event, block); + break; + } + case "Home": { + event.preventDefault(); + setValue(min); + onChangeProp?.(event, min); + break; + } + case "End": { + event.preventDefault(); + setValue(max); + onChangeProp?.(event, max); + break; + } + case "PageUp": { + event.preventDefault(); + incrementValue(event, true); + break; + } + case "PageDown": { + event.preventDefault(); + decrementValue(event, true); + break; + } + } + + inputOnKeyDown?.(event); + }; return ( -
- - {!hideButtons && !readOnly && ( +
+
+ {startAdornment && ( +
+ {startAdornment} +
+ )} + + {!isDisabled && validationStatus && ( + + )} + {endAdornment && ( +
+ {endAdornment} +
+ )} +
+
+ + {!hideButtons && !isReadOnly && (
diff --git a/packages/lab/src/stepper-input/internal/useActivateWhileMouseDown.ts b/packages/lab/src/stepper-input/internal/useActivateWhileMouseDown.ts new file mode 100644 index 0000000000..573ca7e958 --- /dev/null +++ b/packages/lab/src/stepper-input/internal/useActivateWhileMouseDown.ts @@ -0,0 +1,56 @@ +import { useWindow } from "@salt-ds/window"; +import { type SyntheticEvent, useCallback, useEffect, useState } from "react"; +import { useInterval } from "./useInterval"; + +const INITIAL_DELAY = 500; +const INTERVAL_DELAY = 100; + +export function useActivateWhileMouseDown( + activationFn: (event?: SyntheticEvent) => void, + isAtLimit: boolean, +) { + const [buttonDown, setButtonDown] = useState(false); + const [delay, setDelay] = useState(INITIAL_DELAY); + + const cancelInterval = useCallback(() => { + setButtonDown(false); + setDelay(INITIAL_DELAY); + }, []); + + useEffect(() => { + if (isAtLimit) cancelInterval(); + }, [isAtLimit, cancelInterval]); + + const targetWindow = useWindow(); + + useEffect(() => { + if (targetWindow) { + targetWindow.addEventListener("mouseup", cancelInterval); + } + return () => { + if (targetWindow) { + targetWindow.removeEventListener("mouseup", cancelInterval); + } + }; + }, [cancelInterval, targetWindow]); + + const activate = (event: SyntheticEvent) => { + activationFn(event); + if (event.type === "mousedown") { + setButtonDown(true); + } + }; + + useInterval( + () => { + if (!buttonDown) return; + activationFn(); + if (delay === INITIAL_DELAY) { + setDelay(INTERVAL_DELAY); + } + }, + buttonDown ? delay : null, + ); + + return { activate, buttonDown }; +} diff --git a/packages/lab/src/stepper-input/internal/useSpinner.ts b/packages/lab/src/stepper-input/internal/useSpinner.ts deleted file mode 100644 index 5e8f86c9eb..0000000000 --- a/packages/lab/src/stepper-input/internal/useSpinner.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useEffect, useState } from "react"; - -import { useInterval } from "./useInterval"; - -const INTERVAL_DELAY = 300; - -function useSpinner(activationFn: () => void, isAtLimit: boolean) { - const [buttonDown, setButtonDown] = useState(false); - - useEffect(() => { - if (isAtLimit) setButtonDown(false); - }, [isAtLimit]); - - useEffect(() => { - const cancelInterval = () => setButtonDown(false); - - window.addEventListener("keyup", cancelInterval); - window.addEventListener("mouseup", cancelInterval); - return () => { - window.removeEventListener("keyup", cancelInterval); - window.removeEventListener("mouseup", cancelInterval); - }; - }, []); - - const activate = () => { - activationFn(); - setButtonDown(true); - }; - - useInterval(activationFn, buttonDown ? INTERVAL_DELAY : null); - - return { activate, buttonDown }; -} - -export { useSpinner }; diff --git a/packages/lab/src/stepper-input/internal/utils.ts b/packages/lab/src/stepper-input/internal/utils.ts new file mode 100644 index 0000000000..99bf9a9203 --- /dev/null +++ b/packages/lab/src/stepper-input/internal/utils.ts @@ -0,0 +1,44 @@ +// The input should only accept numbers, decimal points, and plus/minus symbols +export const ACCEPT_INPUT = /^[-+]?[0-9]*\.?([0-9]+)?/g; + +export const toFixedDecimalPlaces = ( + inputNumber: number, + decimalPlaces: number, +) => inputNumber.toFixed(decimalPlaces); + +export const isAllowedNonNumeric = (inputCharacter: number | string) => { + if (typeof inputCharacter === "number") return; + return ( + ("-+".includes(inputCharacter) && inputCharacter.length === 1) || + inputCharacter === "" + ); +}; + +export const toFloat = (inputValue: number | string) => { + // Plus, minus, and empty characters are treated as 0 + if (isAllowedNonNumeric(inputValue)) return 0; + return Number.parseFloat(inputValue.toString()); +}; + +export const sanitizedInput = (numberString: string) => + (numberString.match(ACCEPT_INPUT) || []).join(""); + +export const isAtMax = (value: number | string | undefined, max: number) => { + if (value === undefined) return true; + return toFloat(value) >= max; +}; + +export const isAtMin = (value: number | string | undefined, min: number) => { + if (value === undefined) return true; + return toFloat(value) <= min; +}; + +export const isOutOfRange = ( + value: number | string | undefined, + min: number, + max: number, +) => { + if (value === undefined) return true; + const floatValue = toFloat(value); + return floatValue > max || floatValue < min; +}; diff --git a/packages/lab/src/stepper-input/useStepperInput.ts b/packages/lab/src/stepper-input/useStepperInput.ts index 13f417f629..47de405d54 100644 --- a/packages/lab/src/stepper-input/useStepperInput.ts +++ b/packages/lab/src/stepper-input/useStepperInput.ts @@ -1,249 +1,138 @@ -import { type InputProps, useControlled, useId } from "@salt-ds/core"; -import type { - ChangeEvent, - KeyboardEvent, - MouseEvent, - MutableRefObject, +import { + type Dispatch, + type MouseEvent, + type MutableRefObject, + type SetStateAction, + type SyntheticEvent, + useCallback, } from "react"; import type { StepperInputProps } from "./StepperInput"; -import { useSpinner } from "./internal/useSpinner"; - -// The input should only accept numbers, decimal points, and plus/minus symbols -const ACCEPT_INPUT = /^[-+]?[0-9]*\.?([0-9]+)?/g; - -const callAll = - (...fns: any[]) => - (...args: any[]) => - fns.forEach((fn) => fn?.(...args)); - -const toFixedDecimalPlaces = (inputNumber: number, decimalPlaces: number) => - inputNumber.toFixed(decimalPlaces); - -const isAllowedNonNumeric = (inputCharacter: number | string) => { - if (typeof inputCharacter === "number") return; - return ( - ("-+".includes(inputCharacter) && inputCharacter.length === 1) || - inputCharacter === "" +import { useActivateWhileMouseDown } from "./internal/useActivateWhileMouseDown"; +import { + isAtMax, + isAtMin, + toFixedDecimalPlaces, + toFloat, +} from "./internal/utils"; + +/** + * Manages increment / decrement logic + */ +export const useStepperInput = ({ + decimalPlaces = 0, + disabled, + inputRef, + max = Number.MAX_SAFE_INTEGER, + min = Number.MIN_SAFE_INTEGER, + onChange, + readOnly, + setValue, + step = 1, + stepBlock = 10, + value, +}: Pick< + StepperInputProps, + | "decimalPlaces" + | "disabled" + | "inputRef" + | "max" + | "min" + | "onChange" + | "readOnly" + | "step" + | "stepBlock" + | "value" +> & { + setValue: Dispatch>; + inputRef: MutableRefObject; +}) => { + const setValueInRange = useCallback( + (event: SyntheticEvent | undefined, modifiedValue: number) => { + if (readOnly) return; + let nextValue = modifiedValue; + if (nextValue < min) nextValue = min; + if (nextValue > max) nextValue = max; + + const roundedValue = toFixedDecimalPlaces(nextValue, decimalPlaces); + if (Number.isNaN(toFloat(roundedValue))) return; + + setValue(roundedValue); + + onChange?.(event, roundedValue); + }, + [decimalPlaces, min, max, onChange, readOnly, setValue], ); -}; - -const toFloat = (inputValue: number | string) => { - // Plus, minus, and empty characters are treated as 0 - if (isAllowedNonNumeric(inputValue)) return 0; - return Number.parseFloat(inputValue.toString()); -}; - -const sanitizedInput = (numberString: string) => - (numberString.match(ACCEPT_INPUT) || []).join(""); - -export const useStepperInput = ( - props: StepperInputProps, - inputRef: MutableRefObject, -) => { - const { - block = 10, - decimalPlaces = 0, - defaultValue = 0, - id: idProp, - max = Number.MAX_SAFE_INTEGER, - min = Number.MIN_SAFE_INTEGER, - onChange, - step = 1, - value, - } = props; - - const [currentValue, setCurrentValue, isControlled] = useControlled({ - controlled: value, - default: toFixedDecimalPlaces(defaultValue, decimalPlaces), - name: "stepper-input", - }); - const inputId = useId(idProp); - - const isOutOfRange = () => { - if (currentValue === undefined) return true; - return toFloat(currentValue) > max || toFloat(currentValue) < min; - }; - - const isAtMax = () => { - if (currentValue === undefined) return true; - return toFloat(currentValue) >= max || (max === 0 && currentValue === ""); - }; - - const isAtMin = () => { - if (currentValue === undefined) return true; - return toFloat(currentValue) <= min || (min === 0 && currentValue === ""); - }; - - const decrement = () => { - if (currentValue === undefined || isAtMin()) return; - let nextValue = currentValue === "" ? -step : toFloat(currentValue) - step; - - // Set value to `max` if it's currently out of range - if (max !== undefined && isOutOfRange()) nextValue = max; - - setNextValue(nextValue); - }; - - const decrementBlock = () => { - if (currentValue === undefined || isAtMin()) return; - let nextValue = - currentValue === "" - ? block * -step - : toFloat(currentValue) - step * block; - - // Set value to `max` if it's currently out of range - if (max !== undefined && isOutOfRange()) nextValue = max; - - setNextValue(nextValue); - }; - - const increment = () => { - if (currentValue === undefined || isAtMax()) return; - let nextValue = currentValue === "" ? step : toFloat(currentValue) + step; - - // Set value to `min` if it's currently out of range - if (min !== undefined && isOutOfRange()) nextValue = min; - - setNextValue(nextValue); - }; - - const incrementBlock = () => { - if (currentValue === undefined || isAtMax()) return; - let nextValue = - currentValue === "" ? block * step : toFloat(currentValue) + step * block; - // Set value to `min` if it's currently out of range - if (min !== undefined && isOutOfRange()) nextValue = min; - - setNextValue(nextValue); - }; - - const setNextValue = (modifiedValue: number) => { - if (props.readOnly) return; - let nextValue = modifiedValue; - if (nextValue < min) nextValue = min; - if (nextValue > max) nextValue = max; - - const roundedValue = toFixedDecimalPlaces(nextValue, decimalPlaces); - if (Number.isNaN(toFloat(roundedValue))) return; - - if (!isControlled) { - setCurrentValue(roundedValue); - } - - if (onChange) { - onChange(roundedValue); - } - }; - - const { activate: decrementSpinnerBlock, buttonDown: pgDnButtonDown } = - useSpinner(decrementBlock, isAtMin()); - - const { activate: decrementSpinner, buttonDown: arrowDownButtonDown } = - useSpinner(decrement, isAtMin()); - - const { activate: incrementSpinnerBlock, buttonDown: pgUpButtonDown } = - useSpinner(incrementBlock, isAtMax()); - - const { activate: incrementSpinner, buttonDown: arrowUpButtonDown } = - useSpinner(increment, isAtMax()); - - const handleInputBlur = () => { - if (currentValue === undefined) return; - - const roundedValue = toFixedDecimalPlaces( - toFloat(currentValue), - decimalPlaces, - ); - - if ( - currentValue !== "" && - !isAllowedNonNumeric(currentValue) && - !isControlled - ) { - setCurrentValue(roundedValue); - } - - if (onChange) { - onChange(roundedValue); - } - }; - - const handleInputChange = (event: ChangeEvent) => { - const changedValue = event.target.value; - - if (!isControlled) { - setCurrentValue(sanitizedInput(changedValue)); - } + const decrementValue = useCallback( + (event?: SyntheticEvent, block?: boolean) => { + if (value === undefined || isAtMin(value, min)) return; + const decrementStep = block ? stepBlock : step; + const nextValue = + value === "" ? -decrementStep : toFloat(value) - decrementStep; + setValueInRange(event, nextValue); + }, + [value, min, step, stepBlock, setValueInRange], + ); - if (onChange) { - onChange(sanitizedInput(changedValue)); - } - }; + const incrementValue = useCallback( + (event?: SyntheticEvent, block?: boolean) => { + if (value === undefined || isAtMax(value, max)) return; + const incrementStep = block ? stepBlock : step; + const nextValue = + value === "" ? incrementStep : toFloat(value) + incrementStep; + setValueInRange(event, nextValue); + }, + [value, max, step, stepBlock, setValueInRange], + ); - const handleInputKeyDown = (event: KeyboardEvent) => { - if (["ArrowUp", "ArrowDown"].includes(event.key)) { - event.preventDefault(); - event.key === "ArrowUp" ? incrementSpinner() : decrementSpinner(); - } - if (["PageUp", "PageDown"].includes(event.key)) { - event.preventDefault(); - event.key === "PageUp" - ? incrementSpinnerBlock() - : decrementSpinnerBlock(); - } - }; + const { activate: decrementSpinner } = useActivateWhileMouseDown( + (event?: SyntheticEvent) => decrementValue(event), + isAtMin(value, min), + ); - const handleButtonMouseDown = ( - event: MouseEvent, - direction: string, - ) => { - if (event.nativeEvent.button !== 0) return; - direction === "increment" ? incrementSpinner() : decrementSpinner(); - }; + const { activate: incrementSpinner } = useActivateWhileMouseDown( + (event?: SyntheticEvent) => incrementValue(event), + isAtMax(value, max), + ); const handleButtonMouseUp = () => inputRef.current?.focus(); - const getButtonProps = (direction: string) => ({ + const commonButtonProps = { "aria-hidden": true, - disabled: - props.disabled || (direction === "increment" ? isAtMax() : isAtMin()), tabIndex: -1, - onMouseDown: (event: MouseEvent) => - handleButtonMouseDown(event, direction), onMouseUp: handleButtonMouseUp, - }); + }; + + const incrementButtonProps = { + ...commonButtonProps, + "aria-label": "increment value", + disabled: disabled || isAtMax(value, max), + onMouseDown: (event: MouseEvent) => { + if (event.nativeEvent.button !== 0) { + // To match closely with + return; + } + incrementSpinner(event); + }, + }; - const getInputProps = ( - inputProps: InputProps = {}, - ): InputProps | undefined => { - if (currentValue === undefined) return undefined; - return { - ...inputProps, - inputProps: { - role: "spinbutton", - "aria-invalid": isOutOfRange(), - "aria-valuemax": toFloat(toFixedDecimalPlaces(max, decimalPlaces)), - "aria-valuemin": toFloat(toFixedDecimalPlaces(min, decimalPlaces)), - "aria-valuenow": toFloat( - toFixedDecimalPlaces(toFloat(currentValue), decimalPlaces), - ), - id: inputId, - ...inputProps.inputProps, - }, - onBlur: callAll(inputProps.onBlur, handleInputBlur), - onChange: callAll(inputProps.onChange, handleInputChange), - onFocus: inputProps.onFocus, - onKeyDown: callAll(inputProps.onKeyDown, handleInputKeyDown), - textAlign: inputProps.textAlign, - value: String(currentValue), - }; + const decrementButtonProps = { + ...commonButtonProps, + "aria-label": "decrement value", + disabled: disabled || isAtMin(value, min), + onMouseDown: (event: MouseEvent) => { + if (event.nativeEvent.button !== 0) { + // To match closely with + return; + } + decrementSpinner(event); + }, }; return { - decrementButtonDown: arrowDownButtonDown || pgDnButtonDown, - getButtonProps, - getInputProps, - incrementButtonDown: arrowUpButtonDown || pgUpButtonDown, + incrementButtonProps, + decrementButtonProps, + incrementValue, + decrementValue, }; }; diff --git a/packages/lab/stories/stepper-input/stepper-input.qa.stories.tsx b/packages/lab/stories/stepper-input/stepper-input.qa.stories.tsx index a692d3322e..1d26a46d66 100644 --- a/packages/lab/stories/stepper-input/stepper-input.qa.stories.tsx +++ b/packages/lab/stories/stepper-input/stepper-input.qa.stories.tsx @@ -3,13 +3,13 @@ import type { Meta, StoryFn } from "@storybook/react"; import { QAContainer, type QAContainerProps } from "docs/components"; export default { - title: "Lab/Stepper Input/QA", + title: "Lab/Stepper Input/Stepper Input QA", component: StepperInput, } as Meta; export const ExamplesGrid: StoryFn = (props) => { return ( - + = (props) => { /> = (props) => { /> + + + + + + + ); }; diff --git a/packages/lab/stories/stepper-input/stepper-input.stories.tsx b/packages/lab/stories/stepper-input/stepper-input.stories.tsx index b585578aa5..e37efccf1b 100644 --- a/packages/lab/stories/stepper-input/stepper-input.stories.tsx +++ b/packages/lab/stories/stepper-input/stepper-input.stories.tsx @@ -4,10 +4,9 @@ import { FormFieldHelperText, FormFieldLabel, StackLayout, - Text, } from "@salt-ds/core"; import { AddIcon, RefreshIcon, RemoveIcon } from "@salt-ds/icons"; -import { StepperInput } from "@salt-ds/lab"; +import { StepperInput, type StepperInputProps } from "@salt-ds/lab"; import type { Meta, StoryFn } from "@storybook/react"; import { useState } from "react"; export default { @@ -15,36 +14,133 @@ export default { component: StepperInput, } as Meta; -export const Default: StoryFn = (args) => { +export const Default: StoryFn = (args) => { return ( - Default Stepper Input + Stepper Input Please enter a number ); }; +Default.args = { + defaultValue: 0, +}; -export const Secondary: StoryFn = (args) => { +export const Secondary: StoryFn = (args) => { return ( - Default Stepper Input + Stepper Input Please enter a number ); }; -export const DecimalPlaces: StoryFn = (args) => { +Secondary.args = { + defaultValue: 0, +}; + +export const Bordered: StoryFn = (args) => { + return ( + + Stepper Input + + + ); +}; +Bordered.args = { + bordered: true, +}; + +export const ReadOnly: StoryFn = (args) => { return ( - Default Stepper Input + Stepper Input + + + ); +}; +ReadOnly.args = { + readOnly: true, + defaultValue: 5, +}; + +export const Disabled: StoryFn = (args) => { + return ( + + Stepper Input + + + ); +}; +Disabled.args = { + disabled: true, + defaultValue: 5, +}; + +export const Validation: StoryFn = (args) => { + return ( + + + Error Stepper Input + + + + Warning Stepper Input + + + + Success Stepper Input + + + + ); +}; + +export const DecimalPlaces: StoryFn = (args) => { + return ( + + Stepper Input Please enter a number ); }; +DecimalPlaces.args = { + defaultValue: 0, +}; + +export const Controlled: StoryFn = (args) => { + const [value, setValue] = useState(1.11); -export const MinAndMaxValue: StoryFn = (args) => { + return ( + + Stepper Input + { + setValue(value); + }} + endAdornment={ + + } + /> + + The stepper input value is: {value} + + + ); +}; + +export const MinAndMaxValue: StoryFn = (args) => { const [value, setValue] = useState(2); const max = 5; const min = 0; @@ -69,7 +165,9 @@ export const MinAndMaxValue: StoryFn = (args) => { setValue(changedValue)} + onChange={(_event, value) => { + setValue(value); + }} max={max} min={min} style={{ width: "250px" }} @@ -81,7 +179,22 @@ export const MinAndMaxValue: StoryFn = (args) => { ); }; -export const Alignment: StoryFn = (args) => ( +export const CustomStep: StoryFn = (args) => { + return ( + + Stepper Input + + Custom step 5 and step block 50 + + ); +}; +CustomStep.args = { + defaultValue: 1, + step: 5, + stepBlock: 50, +}; + +export const TextAlignment: StoryFn = (args) => ( Left aligned @@ -100,8 +213,11 @@ export const Alignment: StoryFn = (args) => ( ); +TextAlignment.args = { + defaultValue: 0, +}; -export const RefreshAdornment: StoryFn = (args) => { +export const RefreshAdornment: StoryFn = (args) => { const [value, setValue] = useState(10); return ( @@ -110,7 +226,9 @@ export const RefreshAdornment: StoryFn = (args) => { setValue(changedValue)} + onChange={(_event, value) => { + setValue(value); + }} endAdornment={