diff --git a/.changeset/gold-windows-deliver.md b/.changeset/gold-windows-deliver.md new file mode 100644 index 00000000000..feab1522e5b --- /dev/null +++ b/.changeset/gold-windows-deliver.md @@ -0,0 +1,6 @@ +--- +"@salt-ds/lab": minor +--- + +Removed `liveValue`, `showRefreshButton`, `ButtonProps` and `InputProps` props from `StepperInput`. +Added `hideButtons` prop from `StepperInput` and updated to extend Input's `InputProps`. diff --git a/packages/lab/src/__tests__/__e2e__/stepper-input/StepperInput.accessibility.cy.tsx b/packages/lab/src/__tests__/__e2e__/stepper-input/StepperInput.accessibility.cy.tsx index d4c7c66db61..829b5cdb2b9 100644 --- a/packages/lab/src/__tests__/__e2e__/stepper-input/StepperInput.accessibility.cy.tsx +++ b/packages/lab/src/__tests__/__e2e__/stepper-input/StepperInput.accessibility.cy.tsx @@ -1,5 +1,5 @@ -import { StepperInput, FormField } from "@salt-ds/lab"; -import { useState } from "react"; +import { StepperInput } from "@salt-ds/lab"; +import { FormField, FormFieldHelperText, FormFieldLabel } from "@salt-ds/core"; describe("Stepper Input - Accessibility", () => { it("sets the correct default ARIA attributes on input", () => { @@ -20,95 +20,17 @@ describe("Stepper Input - Accessibility", () => { it("has the correct labelling when wrapped in a `FormField`", () => { cy.mount( - + + Stepper Input + Please enter a value ); - cy.findByRole("spinbutton").should("have.accessibleName", "stepper input"); + cy.findByRole("spinbutton").should("have.accessibleName", "Stepper Input"); cy.findByRole("spinbutton").should( "have.accessibleDescription", - "please enter a value" - ); - }); - - it("appends a message to `aria-label` when the controlled `liveValue` prop changes", () => { - const ControlledLiveValue = () => { - const [liveValue, setLiveValue] = useState(10); - - return ( - <> - - - - - - ); - }; - - cy.mount(); - - cy.findByRole("spinbutton").should("have.accessibleName", "stepper input"); - cy.findByRole("spinbutton").should( - "have.accessibleDescription", - "please enter a value" - ); - - cy.findByRole("button", { name: "Increment" }).realClick(); - - cy.findByRole("spinbutton").should( - "have.accessibleName", - "stepper input , value out of date" - ); - cy.findByRole("spinbutton").should( - "have.accessibleDescription", - "please enter a value" - ); - }); - - it("removes the appended message from `aria-label` when the the component is refreshed", () => { - const ControlledLiveValue = () => { - const [liveValue, setLiveValue] = useState(11); - - return ( - <> - - - - - - ); - }; - - cy.mount(); - - cy.findByRole("button", { name: "Increment" }).realClick(); - - cy.findByRole("spinbutton").should( - "have.accessibleName", - "stepper input , value out of date" - ); - cy.findByRole("spinbutton").should( - "have.accessibleDescription", - "please enter a value" - ); - - cy.findByRole("button", { name: "Refresh default value" }).realClick(); - - cy.findByRole("spinbutton").should("have.accessibleName", "stepper input"); - cy.findByRole("spinbutton").should( - "have.accessibleDescription", - "please enter a value" + "Please enter a value" ); }); @@ -119,11 +41,11 @@ describe("Stepper Input - Accessibility", () => { it("sets the correct default ARIA attributes on the increment/decrement buttons", () => { cy.mount(); - cy.findByTestId("increment-button") + cy.findByLabelText("increment value") .should("have.attr", "tabindex", "-1") .and("have.attr", "aria-hidden", "true"); - cy.findByTestId("decrement-button") + cy.findByLabelText("decrement 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 c0c3f80310d..e73270680e8 100644 --- a/packages/lab/src/__tests__/__e2e__/stepper-input/StepperInput.cy.tsx +++ b/packages/lab/src/__tests__/__e2e__/stepper-input/StepperInput.cy.tsx @@ -3,37 +3,17 @@ import { StepperInput } from "@salt-ds/lab"; describe("Stepper Input", () => { it("renders with default props", () => { cy.mount(); - // Component should render with three buttons - refresh, increment, and decrement - cy.findAllByRole("button", { hidden: true }).should("have.length", 3); + // Component should render with two buttons - increment, and decrement + cy.findAllByRole("button", { hidden: true }).should("have.length", 2); cy.findByRole("spinbutton").should("exist"); cy.findByRole("spinbutton").should("have.value", "0"); }); - it("accepts `Input` props", () => { - const blurSpy = cy.stub().as("blurSpy"); - const changeSpy = cy.stub().as("changeSpy"); - - cy.mount( - - ); - - cy.findByRole("spinbutton").focus(); - cy.realType("1"); - cy.get("@changeSpy").should("have.been.called"); - cy.realPress("Tab"); - cy.get("@blurSpy").should("have.been.called"); - }); - it("increments the default value on button click", () => { cy.mount(); - cy.findByTestId("increment-button").realClick({ clickCount: 2 }); + cy.findByLabelText("increment value").realClick({ clickCount: 2 }); cy.findByRole("spinbutton").should("have.value", "2"); }); @@ -41,7 +21,7 @@ describe("Stepper Input", () => { it("decrements the default value on button click", () => { cy.mount(); - cy.findByTestId("decrement-button").realClick({ clickCount: 2 }); + cy.findByLabelText("decrement value").realClick({ clickCount: 2 }); cy.findByRole("spinbutton").should("have.value", "-2"); }); @@ -52,7 +32,7 @@ describe("Stepper Input", () => { cy.findByRole("spinbutton").clear(); cy.findByRole("spinbutton").should("have.value", ""); - cy.findByTestId("increment-button").realClick(); + cy.findByLabelText("increment value").realClick(); cy.findByRole("spinbutton").should("have.value", "1"); }); @@ -63,7 +43,7 @@ describe("Stepper Input", () => { cy.findByRole("spinbutton").clear(); cy.findByRole("spinbutton").should("have.value", ""); - cy.findByTestId("decrement-button").realClick(); + cy.findByLabelText("decrement value").realClick(); cy.findByRole("spinbutton").should("have.value", "-1"); }); @@ -76,7 +56,7 @@ describe("Stepper Input", () => { cy.realType("-"); cy.findByRole("spinbutton").should("have.value", "-"); - cy.findByTestId("increment-button").realClick(); + cy.findByLabelText("increment value").realClick(); cy.findByRole("spinbutton").should("have.value", "1"); }); @@ -88,7 +68,7 @@ describe("Stepper Input", () => { cy.realType("-"); cy.findByRole("spinbutton").should("have.value", "-"); - cy.findByTestId("decrement-button").realClick(); + cy.findByLabelText("decrement value").realClick(); cy.findByRole("spinbutton").should("have.value", "-1"); }); @@ -100,7 +80,7 @@ describe("Stepper Input", () => { it("increments by specified `step` value", () => { cy.mount(); - cy.findByTestId("increment-button").realClick(); + cy.findByLabelText("increment value").realClick(); cy.findByRole("spinbutton").should("have.value", "20"); }); @@ -109,66 +89,36 @@ describe("Stepper Input", () => { ); - cy.findByTestId("increment-button").realClick(); + cy.findByLabelText("increment value").realClick(); cy.findByRole("spinbutton").should("have.value", "3.15"); }); it("decrements by specified `step` value", () => { cy.mount(); - cy.findByTestId("decrement-button").realClick(); + cy.findByLabelText("decrement value").realClick(); cy.findByRole("spinbutton").should("have.value", "-10"); }); it("decrements by specified floating point `step` value", () => { cy.mount(); - cy.findByTestId("decrement-button").realClick(); + cy.findByLabelText("decrement value").realClick(); cy.findByRole("spinbutton").should("have.value", "-0.01"); }); it("disables the increment button at `max`", () => { cy.mount(); - cy.findByTestId("increment-button").realClick(); - cy.findByTestId("increment-button").should("be.disabled"); + cy.findByLabelText("increment value").realClick(); + cy.findByLabelText("increment value").should("be.disabled"); }); it("disables the decrement button at `min`", () => { cy.mount(); - cy.findByTestId("decrement-button").realClick(); - cy.findByTestId("decrement-button").should("be.disabled"); - }); - - it("displays the refresh button when `showRefreshButton` prop is `true`", () => { - cy.mount(); - cy.findByRole("button", { name: "Refresh default value" }).should( - "be.visible" - ); - }); - - it("resets the to `defaultValue` after refresh button is clicked", () => { - cy.mount(); - - cy.findByTestId("increment-button").realClick(); - cy.findByRole("spinbutton").should("have.value", "1"); - - cy.findByRole("button", { name: "Refresh default value" }).realClick(); - cy.findByRole("spinbutton").should("have.value", "0"); - }); - - it("calls the `onChange` callback on refresh", () => { - const changeSpy = cy.stub().as("changeSpy"); - - cy.mount(); - - cy.findByTestId("increment-button").realClick(); - - cy.findByRole("button", { name: "Refresh default value" }).realClick(); - - cy.findByRole("spinbutton").should("have.value", "0"); - cy.get("@changeSpy").should("have.been.called"); + cy.findByLabelText("decrement value").realClick(); + cy.findByLabelText("decrement value").should("be.disabled"); }); it("displays value with correct number of decimal places on blur", () => { @@ -186,7 +136,7 @@ describe("Stepper Input", () => { cy.mount(); - cy.findByTestId("decrement-button").realClick(); + cy.findByLabelText("decrement value").realClick(); cy.get("@changeSpy").should("have.been.calledWith", "15"); }); @@ -202,45 +152,10 @@ describe("Stepper Input", () => { /> ); - cy.findByTestId("increment-button").realClick(); + cy.findByLabelText("increment value").realClick(); cy.get("@changeSpy").should("have.been.calledWith", "-109.44"); }); - it("calls the input's change handlers", () => { - const changeSpy = cy.stub().as("changeSpy"); - const inputChangeSpy = cy.stub().as("inputChangeSpy"); - - cy.mount( - - ); - - cy.findByRole("spinbutton").focus(); - cy.findByRole("spinbutton").clear(); - cy.realType("1"); - - cy.findByRole("spinbutton").should("have.value", "1"); - - cy.get("@changeSpy").should("have.been.called"); - cy.get("@inputChangeSpy").should("have.been.called"); - }); - - it("calls the input's blur handlers", () => { - const blurSpy = cy.stub().as("blurSpy"); - - cy.mount(); - - cy.findByRole("spinbutton").focus(); - cy.findByRole("spinbutton").clear(); - cy.realType("1"); - cy.realPress("Tab"); - - cy.findByRole("spinbutton").should("have.value", "1"); - cy.get("@blurSpy").should("have.been.called"); - }); - it("allows maximum safe integer", () => { cy.mount(); @@ -269,9 +184,9 @@ describe("Stepper Input", () => { /> ); - cy.findByTestId("increment-button").realClick(); + cy.findByLabelText("increment value").realClick(); - cy.findByTestId("increment-button").should("be.disabled"); + cy.findByLabelText("increment value").should("be.disabled"); cy.get("@changeSpy").should("not.have.been.called"); cy.findByRole("spinbutton").should( "have.value", @@ -289,9 +204,9 @@ describe("Stepper Input", () => { /> ); - cy.findByTestId("decrement-button").realClick(); + cy.findByLabelText("decrement value").realClick(); - cy.findByTestId("decrement-button").should("be.disabled"); + cy.findByLabelText("decrement value").should("be.disabled"); cy.get("@changeSpy").should("not.have.been.called"); cy.findByRole("spinbutton").should( "have.value", @@ -322,4 +237,52 @@ describe("Stepper Input", () => { cy.realPress("Tab"); cy.findByRole("spinbutton").should("have.value", "-5.800"); }); + + it("increments the value on arrow up key press", () => { + cy.mount(); + + cy.findByRole("spinbutton").focus(); + cy.realPress("ArrowUp"); + + cy.findByRole("spinbutton").should("have.value", "1"); + }); + + it("decrements the value on arrow down key press", () => { + cy.mount(); + + cy.findByRole("spinbutton").focus(); + cy.realPress("ArrowDown"); + + cy.findByRole("spinbutton").should("have.value", "-1"); + }); + + it("is disabled when the `disabled` prop is true", () => { + cy.mount(); + + cy.findByRole("spinbutton").should("be.disabled"); + cy.findByLabelText("increment value").should("be.disabled"); + cy.findByLabelText("decrement value").should("be.disabled"); + }); + + it("is controlled when the `value` prop is provided", () => { + cy.mount(); + + cy.findByRole("spinbutton").should("have.value", "5"); + + cy.findByLabelText("increment value").realClick(); + cy.findByRole("spinbutton").should("have.value", "5"); + + cy.findByLabelText("decrement value").realClick(); + cy.findByRole("spinbutton").should("have.value", "5"); + }); + + it("sanitizes input to only allow numbers, decimal points, and plus/minus symbols", () => { + cy.mount(); + + cy.findByRole("spinbutton").focus(); + cy.findByRole("spinbutton").clear(); + cy.realType("abc-12.3.+-def"); + + cy.findByRole("spinbutton").should("have.value", "-12.3"); + }); }); diff --git a/packages/lab/src/stepper-input/StepperInput.css b/packages/lab/src/stepper-input/StepperInput.css index c3c0ab59793..b45994e2a4b 100644 --- a/packages/lab/src/stepper-input/StepperInput.css +++ b/packages/lab/src/stepper-input/StepperInput.css @@ -1,91 +1,20 @@ -.salt-density-medium { - --stepperInput-adornment-container-top: -2px; - --stepperInput-button-height: 12px; - --stepperInput-button-margin: 2px; - --stepperInput-button-width: 24px; - --stepperInput-input-maxHeight: 28px; - --stepperInput-secondary-button-height: 26px; - --stepperInput-secondary-button-width: 26px; - --stepperInput-secondary-button-marginRight: 2px; -} - -.salt-density-touch { - --stepperInput-adornment-container-top: 0; - --stepperInput-button-height: 16px; - --stepperInput-button-margin: 4px; - --stepperInput-button-width: 36px; - --stepperInput-secondary-button-height: 36px; - --stepperInput-secondary-button-width: 36px; - --stepperInput-secondary-button-marginRight: 4px; -} - -.salt-density-low { - --stepperInput-adornment-container-top: 0; - --stepperInput-button-height: 12px; - --stepperInput-button-margin: 4px; - --stepperInput-button-width: 28px; - --stepperInput-secondary-button-height: 28px; - --stepperInput-secondary-button-width: 28px; - --stepperInput-secondary-button-marginRight: 4px; -} - -.salt-density-high { - --stepperInput-adornment-container-top: -2px; - --stepperInput-button-height: 8px; - --stepperInput-button-margin: 2px; - --stepperInput-button-width: 16px; - --stepperInput-input-maxHeight: 20px; - --stepperInput-secondary-button-height: 18px; - --stepperInput-secondary-button-width: 18px; - --stepperInput-secondary-button-marginRight: 2px; -} - -/* Styles applied to adornment container */ -.saltStepperInput-adornmentContainer { +/* Styles applied to stepper container */ +.saltStepperInput { + align-items: center; display: flex; - position: relative; - top: var(--saltStepperInput-adornment-container-top, var(--stepperInput-adornment-container-top)); -} - -/* Styles applied to refresh button */ -.saltStepperInput-secondaryButton { - --saltButton-height: var(--stepperInput-secondary-button-height); - --saltButton-margin: var(--stepperInput-button-margin) var(--stepperInput-secondary-button-marginRight) var(--stepperInput-button-margin) var(--stepperInput-button-margin); - --saltButton-width: var(--stepperInput-secondary-button-width); -} - -.saltStepperInput-hideSecondaryButton { - visibility: hidden; + flex-direction: row; + gap: var(--salt-spacing-50); } /* Styles applied to stepper buttons container */ .saltStepperInput-buttonContainer { display: flex; flex-direction: column; - justify-content: center; + gap: var(--salt-spacing-50); } /* Styles applied to stepper buttons */ .saltStepperInput-stepperButton { - --saltButton-height: var(--stepperInput-button-height); - --saltButton-margin: var(--stepperInput-button-margin); - --saltButton-width: var(--stepperInput-button-width); -} - -/* Styles applied to increment button */ -.saltStepperInput-increment { -} - -/* Styles applied to decrement button */ -.saltStepperInput-decrement { - --saltButton-margin: 0 var(--stepperInput-button-margin) var(--stepperInput-button-margin) var(--stepperInput-button-margin); -} - -/* Styles applied to stepper button icon */ -.saltStepperInput-stepperButtonIcon { -} - -/* Styles applied to input component */ -.saltStepperInput-input { - max-height: var(--saltStepperInput-maxHeight, unset); + --saltButton-height: calc((var(--salt-size-base) - var(--salt-spacing-50)) * 0.5); + --saltButton-width: var(--salt-size-base); } diff --git a/packages/lab/src/stepper-input/StepperInput.tsx b/packages/lab/src/stepper-input/StepperInput.tsx index 25d863cf18f..0c2c745cc29 100644 --- a/packages/lab/src/stepper-input/StepperInput.tsx +++ b/packages/lab/src/stepper-input/StepperInput.tsx @@ -1,47 +1,77 @@ import { clsx } from "clsx"; -import React, { forwardRef, ReactNode, useRef } from "react"; -import { Button, ButtonProps, makePrefixer } from "@salt-ds/core"; +import { FocusEventHandler, forwardRef, useRef } from "react"; +import { Button, makePrefixer, Input, InputProps } from "@salt-ds/core"; import { useComponentCssInjection } from "@salt-ds/styles"; import { useWindow } from "@salt-ds/window"; -import { RefreshIcon, TriangleDownIcon, TriangleUpIcon } from "@salt-ds/icons"; -import { - InputLegacy as Input, - InputLegacyProps as InputProps, -} from "../input-legacy"; -import { useActivationIndicatorPosition } from "./internal/useActivationIndicatorPosition"; +import { TriangleDownIcon, TriangleUpIcon } from "@salt-ds/icons"; import { useStepperInput } from "./useStepperInput"; import stepperInputCss from "./StepperInput.css"; const withBaseName = makePrefixer("saltStepperInput"); -export interface StepperInputProps { - ButtonProps?: Partial; - InputProps?: Partial; +export interface StepperInputProps + extends Omit { + /** + * A multiplier applied to the `step` when the value is incremented or decremented using the PageDown/PageUp keys. + */ block?: number; - className?: string; + /** + * The number of decimal places to display. + */ decimalPlaces?: number; + /** + * Sets the initial default value of the component. + */ defaultValue?: number; - liveValue?: number; + /** + * The maximum value that can be selected. + */ max?: number; + /** + * The minimum value that can be selected. + */ min?: number; - onBlur?: (event: React.FocusEvent) => void; - onFocus?: (event: React.FocusEvent) => void; + /** + * Whether to hide the stepper buttons. Defaults to `false`. + */ + hideButtons?: boolean; + /** + * Callback when stepper input loses focus. + */ + onBlur?: FocusEventHandler; + /** + * Callback when stepper input value is changed. + */ onChange?: (changedValue: number | string) => void; - showRefreshButton?: boolean; + /** + * Callback when stepper input gains focus. + */ + onFocus?: FocusEventHandler; + /** + * The amount to increment or decrement the value by when using the stepper buttons or Up Arrow and Down Arrow keys. + */ step?: number; + /** + * Determines the text alignment of the display value. + */ textAlign?: "center" | "left" | "right"; + /** + * The value of the stepper input. The component will be controlled if this prop is provided. + */ value?: number | string; } export const StepperInput = forwardRef( function StepperInput(props, ref) { const { - ButtonProps: ButtonPropsProp, - InputProps: InputPropsProp, - textAlign = "left", className, - showRefreshButton = false, + hideButtons, + onBlur, + onChange, + onFocus, + readOnly, + ...rest } = props; const targetWindow = useWindow(); @@ -51,95 +81,37 @@ export const StepperInput = forwardRef( window: targetWindow, }); - const adornmentRef = useRef(null); const inputRef = useRef(null); - const { - decrementButtonDown, - getButtonIcon, - getButtonProps, - getInputProps, - incrementButtonDown, - isAtMax, - isAtMin, - refreshCurrentValue, - stepperDirection, - valuesHaveDiverged, - } = useStepperInput(props, inputRef); - - useActivationIndicatorPosition( - adornmentRef, - valuesHaveDiverged() || showRefreshButton - ); - - const endAdornment: ReactNode = ( -
- -
- - -
-
- ); + const { getButtonProps, getInputProps } = useStepperInput(props, inputRef); return ( -
+
+ {!hideButtons && !readOnly && ( +
+ + +
+ )}
); } diff --git a/packages/lab/src/stepper-input/internal/useActivationIndicatorPosition.ts b/packages/lab/src/stepper-input/internal/useActivationIndicatorPosition.ts deleted file mode 100644 index 8731d45fe19..00000000000 --- a/packages/lab/src/stepper-input/internal/useActivationIndicatorPosition.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useDensity, useIsomorphicLayoutEffect } from "@salt-ds/core"; -import { useFormFieldLegacyProps } from "../../form-field-context-legacy"; -import { MutableRefObject } from "react"; - -const refreshButtonWidth = { - touch: 36, - low: 28, - medium: 24, - high: 12, -}; - -// The activation indicator icon is absolutely positioned by FormField, -// and must be offset to accommodate the end adornment added by Stepper Input. -// Ideally, we should be able to provide an 'activationIndicator' class to FormField to -// override its default positioning instead of directly repositioning it via its ref. -export function useActivationIndicatorPosition( - adornmentRef: MutableRefObject, - refreshButtonVisible: boolean -) { - const formFieldProps = useFormFieldLegacyProps(); - const { ref: formFieldRef } = formFieldProps; - const density = useDensity(); - - useIsomorphicLayoutEffect(() => { - let offset; - if (adornmentRef && adornmentRef.current !== null) { - const marginAdjustment = - density === "high" || density === "medium" ? 2 : 4; - - const secondaryButtonAdjustment = refreshButtonVisible - ? 0 - : refreshButtonWidth[density]; - - offset = - adornmentRef.current.getBoundingClientRect().width - - marginAdjustment - - secondaryButtonAdjustment; - } - if (formFieldRef && formFieldRef.current && offset) { - const activationIndicator = formFieldRef.current.getElementsByClassName( - "saltFormActivationIndicator-icon" - ) as HTMLCollectionOf; - if (activationIndicator.length > 0) { - activationIndicator[0].style.transform = `translateX(-${offset}px)`; - } - } - }); -} diff --git a/packages/lab/src/stepper-input/internal/useDynamicAriaLabel.ts b/packages/lab/src/stepper-input/internal/useDynamicAriaLabel.ts deleted file mode 100644 index 55f380e2ec2..00000000000 --- a/packages/lab/src/stepper-input/internal/useDynamicAriaLabel.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { useEffect, useState } from "react"; -import { useAriaAnnouncer } from "@salt-ds/core"; - -// Dynamically append a string to aria-label if the component -// is controlled and the display value can be refreshed -export const useDynamicAriaLabel = ( - appendLabel: string, - hasLiveValue: boolean, - inputRef: React.MutableRefObject, - value: number | string | undefined, - valuesHaveDiverged: () => boolean -) => { - const [hasAnnounced, setHasAnnounced] = useState(false); - const { announce } = useAriaAnnouncer(); - - useEffect(() => { - function applyAriaMessage(ariaLabel: string) { - // Don't append the message again if it's already part of the string - if (!ariaLabel.includes(appendLabel)) { - inputRef.current?.setAttribute( - "aria-label", - `${ariaLabel}${appendLabel}` - ); - } - - const currentId = inputRef.current?.getAttribute("id") || ""; - const labelledBy = - inputRef.current?.getAttribute("aria-labelledby") || ""; - - if (!labelledBy.includes(currentId)) { - inputRef.current?.setAttribute( - "aria-labelledby", - `${labelledBy} ${currentId}` - ); - } - } - - function removeAriaMessage(ariaLabel: string) { - const replacementAria = ariaLabel?.replace(appendLabel, ""); - if (replacementAria !== undefined) { - inputRef.current?.setAttribute("aria-label", replacementAria); - } - - const replacementLabelledBy = inputRef.current - ?.getAttribute("aria-labelledby") - ?.replace(` ${inputRef.current?.getAttribute("id")}`, ""); - - if (replacementLabelledBy !== undefined) { - inputRef.current?.setAttribute( - "aria-labelledby", - replacementLabelledBy - ); - } - } - - if (hasLiveValue) { - const ariaLabel = inputRef.current?.getAttribute("aria-label") || ""; - if (valuesHaveDiverged()) { - applyAriaMessage(ariaLabel); - } else { - removeAriaMessage(ariaLabel); - } - } - }, [appendLabel, inputRef, hasLiveValue, valuesHaveDiverged, value]); - - useEffect(() => { - if (hasLiveValue && valuesHaveDiverged()) { - // Screen readers will automatically announce updates when the dynamic `aria-label` - // changes and the component has focus. When the component *does not* have - // focus we should announce only once for the first value update - if (inputRef.current !== document.activeElement && !hasAnnounced) { - // Empty announcement required until a fix is in place for announcer - announce(""); - announce(`${inputRef.current?.getAttribute("aria-label")}`); - - // We only want to announce on the first value change, but this flag - // is reset when the input receives focus - setHasAnnounced(true); - } - } - }, [ - announce, - appendLabel, - hasAnnounced, - inputRef, - hasLiveValue, - setHasAnnounced, - valuesHaveDiverged, - value, - ]); - - return { setHasAnnounced }; -}; diff --git a/packages/lab/src/stepper-input/useStepperInput.ts b/packages/lab/src/stepper-input/useStepperInput.ts index 15277d7c227..a7aa8c53bc6 100644 --- a/packages/lab/src/stepper-input/useStepperInput.ts +++ b/packages/lab/src/stepper-input/useStepperInput.ts @@ -1,17 +1,13 @@ -import { KeyboardEvent, MouseEvent, MutableRefObject } from "react"; -import { ButtonProps, useControlled, useId } from "@salt-ds/core"; -import { InputLegacyProps as InputProps } from "../input-legacy"; -import { useDynamicAriaLabel } from "./internal/useDynamicAriaLabel"; +import { + ChangeEvent, + KeyboardEvent, + MouseEvent, + MutableRefObject, +} from "react"; +import { useControlled, useId, InputProps } from "@salt-ds/core"; import { useSpinner } from "./internal/useSpinner"; import { StepperInputProps } from "./StepperInput"; -type Direction = "decrement" | "increment"; - -const stepperDirection = { - INCREMENT: "increment" as Direction, - DECREMENT: "decrement" as Direction, -}; - // The input should only accept numbers, decimal points, and plus/minus symbols const ACCEPT_INPUT = /^[-+]?[0-9]*\.?([0-9]+)?/g; @@ -37,12 +33,9 @@ const toFloat = (inputValue: number | string) => { return parseFloat(inputValue.toString()); }; -const santizedInput = (numberString: string) => +const sanitizedInput = (numberString: string) => (numberString.match(ACCEPT_INPUT) || []).join(""); -const getButtonIcon = (type: Direction) => - type === stepperDirection.INCREMENT ? "triangle-up" : "triangle-down"; - export const useStepperInput = ( props: StepperInputProps, inputRef: MutableRefObject @@ -51,13 +44,12 @@ export const useStepperInput = ( block = 10, decimalPlaces = 0, defaultValue = 0, - liveValue, + id: idProp, max = Number.MAX_SAFE_INTEGER, min = Number.MIN_SAFE_INTEGER, onChange, step = 1, value, - InputProps: inputPropsProp = {}, } = props; const [currentValue, setCurrentValue, isControlled] = useControlled({ @@ -65,7 +57,7 @@ export const useStepperInput = ( default: toFixedDecimalPlaces(defaultValue, decimalPlaces), name: "stepper-input", }); - const inputId = useId(inputPropsProp.id); + const inputId = useId(idProp); const isOutOfRange = () => { if (currentValue === undefined) return true; @@ -82,22 +74,6 @@ export const useStepperInput = ( return toFloat(currentValue) <= min || (min === 0 && currentValue === ""); }; - const valuesHaveDiverged = () => { - if (liveValue === undefined || currentValue === undefined) return false; - return ( - toFloat(toFixedDecimalPlaces(liveValue, decimalPlaces)) !== - toFloat(currentValue) - ); - }; - - const { setHasAnnounced } = useDynamicAriaLabel( - ", value out of date", - liveValue !== undefined, - inputRef, - currentValue, - valuesHaveDiverged - ); - const decrement = () => { if (currentValue === undefined || isAtMin()) return; let nextValue = currentValue === "" ? -step : toFloat(currentValue) - step; @@ -143,6 +119,7 @@ export const useStepperInput = ( }; const setNextValue = (modifiedValue: number) => { + if (props.readOnly) return; let nextValue = modifiedValue; if (nextValue < min) nextValue = min; if (nextValue > max) nextValue = max; @@ -192,19 +169,15 @@ export const useStepperInput = ( } }; - const handleInputFocus = () => { - setHasAnnounced(false); - }; - - const handleInputChange = (event: KeyboardEvent) => { - const changedValue = (event.currentTarget as HTMLInputElement).value; + const handleInputChange = (event: ChangeEvent) => { + const changedValue = event.target.value; if (!isControlled) { - setCurrentValue(santizedInput(changedValue)); + setCurrentValue(sanitizedInput(changedValue)); } if (onChange) { - onChange(santizedInput(changedValue)); + onChange(sanitizedInput(changedValue)); } }; @@ -223,48 +196,22 @@ export const useStepperInput = ( const handleButtonMouseDown = ( event: MouseEvent, - type: Direction = stepperDirection.INCREMENT + direction: string ) => { if (event.nativeEvent.button !== 0) return; - type === stepperDirection.INCREMENT - ? incrementSpinner() - : decrementSpinner(); + direction === "increment" ? incrementSpinner() : decrementSpinner(); }; const handleButtonMouseUp = () => inputRef.current?.focus(); - const refreshCurrentValue = () => { - const refreshedcurrentValue = - liveValue !== undefined ? liveValue : defaultValue; - if (refreshedcurrentValue === undefined) return; - - setCurrentValue( - toFixedDecimalPlaces(toFloat(refreshedcurrentValue), decimalPlaces) - ); - - inputRef.current?.focus(); - - if (onChange) { - onChange( - toFixedDecimalPlaces(toFloat(refreshedcurrentValue), decimalPlaces) - ); - } - }; - - const getButtonProps = ( - type: Direction = stepperDirection.INCREMENT, - buttonPropsProp: ButtonProps = {} - ) => ({ + const getButtonProps = (direction: string) => ({ "aria-hidden": true, - "data-testid": `${type}-button`, + disabled: + props.disabled || (direction === "increment" ? isAtMax() : isAtMin()), tabIndex: -1, - ...buttonPropsProp, - onMouseDown: callAll( - (event: MouseEvent) => - handleButtonMouseDown(event, type), - buttonPropsProp.onMouseDown - ), - onMouseUp: callAll(() => handleButtonMouseUp(), buttonPropsProp.onMouseUp), + onMouseDown: (event: MouseEvent) => + handleButtonMouseDown(event, direction), + onMouseUp: handleButtonMouseUp, }); const getInputProps = ( @@ -286,22 +233,17 @@ export const useStepperInput = ( }, onBlur: callAll(inputProps.onBlur, handleInputBlur), onChange: callAll(inputProps.onChange, handleInputChange), - onFocus: callAll(inputProps.onFocus, handleInputFocus), - onKeyDown: callAll(inputProps.onKeyPress, handleInputKeyDown), + onFocus: inputProps.onFocus, + onKeyDown: callAll(inputProps.onKeyDown, handleInputKeyDown), + textAlign: inputProps.textAlign, value: String(currentValue), }; }; return { decrementButtonDown: arrowDownButtonDown || pgDnButtonDown, - getButtonIcon, getButtonProps, getInputProps, incrementButtonDown: arrowUpButtonDown || pgUpButtonDown, - isAtMax, - isAtMin, - refreshCurrentValue, - stepperDirection, - valuesHaveDiverged, }; }; 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 7ad0c4d80dd..58bb03b3528 100644 --- a/packages/lab/stories/stepper-input/stepper-input.qa.stories.tsx +++ b/packages/lab/stories/stepper-input/stepper-input.qa.stories.tsx @@ -15,27 +15,40 @@ export const ExamplesGrid: StoryFn = (props) => { defaultValue={0.5} max={10} min={-5} - showRefreshButton step={0.5} /> - + + ); }; diff --git a/packages/lab/stories/stepper-input/stepper-input.stories.tsx b/packages/lab/stories/stepper-input/stepper-input.stories.tsx index 3366d28b678..239dbd9c7e5 100644 --- a/packages/lab/stories/stepper-input/stepper-input.stories.tsx +++ b/packages/lab/stories/stepper-input/stepper-input.stories.tsx @@ -1,337 +1,178 @@ -import { ReactNode, useEffect, useState } from "react"; -import { SaltProvider, Panel } from "@salt-ds/core"; -import { StepperInput, FormField } from "@salt-ds/lab"; +import { + Button, + FormField, + FormFieldHelperText, + FormFieldLabel, + StackLayout, + Text, +} from "@salt-ds/core"; +import { StepperInput } from "@salt-ds/lab"; import { Meta, StoryFn } from "@storybook/react"; -import { ColumnLayoutContainer, ColumnLayoutItem } from "docs/story-layout"; - +import { AddIcon, RefreshIcon, RemoveIcon } from "@salt-ds/icons"; +import { useState } from "react"; export default { title: "Lab/Stepper Input", component: StepperInput, } as Meta; -interface ExampleRowProps { - children: ReactNode; - name: string; -} - -const ExampleRow = ({ name, children }: ExampleRowProps) => ( - -

{name} - ( Touch, Low, Medium, High )

- - - Touch - {children} - - - Low - {children} - - - Medium - {children} - - - High - {children} - - -
-); - -const Examples = () => ( - <> - - - - - - -); - -export const All: StoryFn = () => ( -
- - - - - - -
-); - -export const Default: StoryFn = () => { - const max = 10; - const min = -5; - - const [isOutOfRange, setIsOutOfRange] = useState(false); - - const handleChange = (value: number | string) => - value > max || value < min ? setIsOutOfRange(true) : setIsOutOfRange(false); - +export const Default: StoryFn = (args) => { return ( - - - - - + + Default Stepper Input + + Please enter a number + ); }; -export const Alignment: StoryFn = () => ( - - - - - - +export const Secondary: StoryFn = (args) => { + return ( + + Default Stepper Input + + Please enter a number - - + ); +}; +export const DecimalPlaces: StoryFn = (args) => { + return ( + + Default Stepper Input + + Please enter a number - -); - -export const Controlled: StoryFn = () => { - const max = 100; - const min = -100; - const step = 0.01; - - const [value, setValue] = useState(20.01); + ); +}; - const handleChange = (nextValue: number | string) => { - setValue(nextValue as number); +export const MinAndMaxValue: StoryFn = (args) => { + const [value, setValue] = useState(2); + const max = 5; + const min = 0; + + const getValidationStatus = () => { + if (typeof value === "number") { + if (value > max || value < min) { + return "error"; + } + } else { + const numericValue = parseFloat(value); + if (numericValue > max || numericValue < min) { + return "error"; + } + } + return undefined; }; return ( - - - - - - {/* TODO uncomment when has been migrated */} - {/*
*/} - {/* setValue(nextValue)}*/} - {/* step={step}*/} - {/* tooltipPlacement="bottom"*/} - {/* value={value}*/} - {/* />*/} - {/*
*/} -
+ + Stepper Input + setValue(changedValue)} + max={max} + min={min} + style={{ width: "250px" }} + /> + + Please enter a value between {min} and {max} + + ); }; -export const CustomValues: StoryFn = () => ( - - - +export const Alignment: StoryFn = (args) => ( + + + Left aligned + + Please enter a number - -); - -export const Decimals: StoryFn = () => ( - - - + + Center aligned + + Please enter a number + + + Right aligned + + Please enter a number - + ); -export const Error: StoryFn = () => { - const defaultValue = 15.775; - const max = 10; - const min = -10; - - const [liveValue, setLiveValue] = useState(defaultValue); - const [isOutOfRange, setIsOutOfRange] = useState(false); - - useEffect(() => { - defaultValue > max || defaultValue < min - ? setIsOutOfRange(true) - : setIsOutOfRange(false); - }, [min, max]); - - const handleChange = (value: number | string) => { - value > max || value < min ? setIsOutOfRange(true) : setIsOutOfRange(false); - }; - - useEffect(() => { - // Update `liveValue` to valid number after initial render - setLiveValue(6.051); - }, []); +export const RefreshAdornment: StoryFn = (args) => { + const [value, setValue] = useState(10); return ( - - - - - + + Stepper Input + setValue(changedValue)} + endAdornment={ + + } + /> + Please enter a value + ); }; -export const LiveDefaultValue: StoryFn = () => { - const decimalPlaces = 2; - const defaultValue = 0.0; - const max = 100.0; - const min = 0.0; - - const formatValue = (value: number) => value.toFixed(decimalPlaces); - - const [value, setValue] = useState(defaultValue); - const [isOutOfRange, setIsOutOfRange] = useState(false); - - const randomLiveValue = (minValue: number, maxValue: number) => - Math.random() * (maxValue - minValue) + minValue; - - const handleChange = (nextValue: number | string) => - nextValue > max || nextValue < min - ? setIsOutOfRange(true) - : setIsOutOfRange(false); - - useEffect(() => { - const interval = setInterval(() => { - setValue(randomLiveValue(min, max)); - }, 5000); - return () => clearInterval(interval); - }, []); +export const HideButtons: StoryFn = (args) => { + const [value, setValue] = useState(10); return ( - - - - - - ); -}; - -export const NumericLimits: StoryFn = () => ( - - - - - - - - - - - - - -); - -export const RefreshButton: StoryFn = () => ( - - + + Stepper Input setValue(changedValue)} + startAdornment={ + + } + endAdornment={ + + } /> + Please enter a value - -); + ); +}; + +export const ReadOnly: StoryFn = (args) => { + return ( + + Stepper Input + + + ); +};