diff --git a/packages/components/rollup.config.mjs b/packages/components/rollup.config.mjs index 772aa316f5..c6c6d0b1c9 100644 --- a/packages/components/rollup.config.mjs +++ b/packages/components/rollup.config.mjs @@ -15,14 +15,25 @@ const createMultiInput = typeof multiInput === "function" ? multiInput : multiInput.default; /** - * When PREBUILD_CSS is supplied, only build the main index.ts file. + * When PREBUILD_CSS is supplied, only build the main barrels. * This ensures postcss maintains consistent ordering of styles across builds. * * Using multiInput (input with globs) produces inconsistent ordering within styles.css * because files are loaded in a non-deterministic order, and postcss bundles them in * that order. + * + * The primitives barrel is included in addition to the main barrel so that styled + * primitives (under `src/primitives/`) contribute their CSS module hashes to the + * extracted styles.css. Without this, primitives' class hashes appear in the JS + * bundle but not in the stylesheet, so consumers see no styles applied. */ const PREBUILD_CSS = process.env.PREBUILD_CSS === "true"; +// Object form gives each entry a unique `[name]` for output filenames; both +// inputs share the basename `index.ts`, so the array form would collide. +const PREBUILD_CSS_INPUTS = { + index: "src/index.ts", + "primitives/index": "src/primitives/index.ts", +}; /** * PostCSS plugin to remove @charset declarations. @@ -52,7 +63,7 @@ export default { warn(warning); }, - input: PREBUILD_CSS ? "src/index.ts" : `src/**/index.{ts,tsx}`, + input: PREBUILD_CSS ? PREBUILD_CSS_INPUTS : `src/**/index.{ts,tsx}`, plugins: [ nodePolyfills(), nodeResolve(), diff --git a/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.module.css b/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.module.css new file mode 100644 index 0000000000..d4f99ba1f9 --- /dev/null +++ b/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.module.css @@ -0,0 +1,376 @@ +.container { + width: 100%; +} + +.inline { + display: inline-block; + width: auto; +} + +.wrapper { + --field--placeholder-color: var(--color-base-blue--600); + --field--value-color: var(--color-heading); + --field--border-color: var(--color-border--interactive); + + --field--placeholder-offset: 50%; + --field--placeholder-transform: -50%; + + --field--textAlign: left; + + --field--height: var(--space-largest); + + --field--padding-top: calc(var(--space-base) - var(--space-smallest)); + --field--padding-bottom: calc(var(--space-base) - var(--space-smallest)); + --field--padding-left: var(--space-base); + --field--padding-right: var(--space-base); + + --field--value-lineHeight: calc(var(--base-unit) * 1.25); + + --field--background-color: var(--color-surface); + + --field--base-elevation: var(--elevation-base); + --field--label-elevation: calc(var(--field--base-elevation) + 1); + + --stepper--width: calc(var(--space-base) + var(--space-smaller)); + + display: flex; + position: relative; + flex-direction: row; + align-items: stretch; + width: 100%; + border: var(--border-base) solid var(--field--border-color); + border-radius: var(--radius-base); + /* Read radius vars via fallback so InputGroup's parent-level overrides + * still flatten inner corners; declaring them on `.wrapper` would + * shadow the inherited value. */ + border-top-right-radius: var( + --public-field--top-right-radius, + var(--radius-base) + ); + border-bottom-right-radius: var( + --public-field--bottom-right-radius, + var(--radius-base) + ); + border-bottom-left-radius: var( + --public-field--bottom-left-radius, + var(--radius-base) + ); + border-top-left-radius: var( + --public-field--top-left-radius, + var(--radius-base) + ); + color: var(--field--value-color); + font-size: var(--typography--fontSize-base); + background-color: var(--field--background-color); +} + +.wrapper * { + box-sizing: border-box; +} + +.inline .wrapper { + width: auto; +} + +.wrapper:focus-within { + z-index: var(--field--base-elevation); + box-shadow: var(--shadow-focus); +} + +/** + * Base UI's `Field.Root` sets `data-invalid` on the outer container when the + * field's invalid state is true. We read that attribute instead of toggling + * a local `.invalid` class so Base UI is the single source of truth. + */ +.container[data-invalid] .wrapper, +.container[data-invalid] .wrapper:focus-within { + --field--border-color: var(--color-critical); +} + +.disabled { + --field--placeholder-color: var(--color-disabled); + --field--value-color: var(--color-disabled); + --field--background-color: var(--color-disabled--secondary); + --field--border-color: var(--color-border); +} + +.disabled :disabled { + -webkit-text-fill-color: var(--field--value-color); + opacity: 1; +} + +.small { + --field--padding-left: calc(var(--space-base) - var(--space-smaller)); + --field--padding-right: calc(var(--space-base) - var(--space-smaller)); + --field--padding-top: var(--space-small); + --field--padding-bottom: var(--space-small); + --field--height: calc(var(--space-larger) + var(--space-smaller)); +} + +.large { + --field--padding-left: var(--space-large); + --field--padding-right: var(--space-large); + --field--height: calc(var(--space-extravagant)); +} + +.center { + --field--textAlign: center; +} + +.right { + --field--textAlign: right; +} + +.inputWrapper { + display: flex; + position: relative; + z-index: var(--elevation-default); + height: var(--field--height); + flex: 1; +} + +.input { + position: relative; + z-index: var(--field--base-elevation); + width: 100%; + padding-top: var(--field--padding-top); + padding-bottom: var(--field--padding-bottom); + padding-left: var(--field--padding-left); + padding-right: var(--field--padding-right); + border: none; + border-radius: var(--radius-base); + color: inherit; + font-family: inherit; + font-size: inherit; + line-height: var(--field--value-lineHeight); + text-align: var(--field--textAlign); + background: transparent; + appearance: none; +} + +.input:focus { + outline: none; +} + +/** + * When the stepper is rendered as an in-flow flex sibling (default mode), it + * provides visual separation from the typed value. Tighten the input's + * own right padding so narrow consumers (~100px wrappers with a suffix) keep + * enough room for multi-digit values to display. + */ +.inputWrapper:has(> .stepper) .input { + padding-right: var(--space-smallest); +} + +.label { + position: absolute; + top: var(--field--placeholder-offset); + left: 0; + z-index: var(--field--label-elevation); + width: 100%; + padding-left: var(--field--padding-left); + padding-right: var(--field--padding-right); + overflow: hidden; + color: var(--field--placeholder-color); + line-height: var(--field--value-lineHeight); + text-align: var(--field--textAlign); + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: none; + transform: translateY(var(--field--placeholder-transform)); +} + +.inputWrapper:not(.hideLabel) .label { + transition: all var(--timing-quick); +} + +/** + * Value-present mini-label state: when the input has a value (or focus), + * shrink and pin the placeholder label to the top-left and grow the input's + * vertical padding to make room. + * + * The variables are hoisted to `.wrapper` (not `.inputWrapper`) so that + * affix labels — which are flex siblings of `.inputWrapper` inside `.wrapper` + * in the default render — also inherit the new padding values and stay + * vertically aligned with the typed value. + */ +.wrapper:has( + .inputWrapper:not(.small, .hideLabel) + .input:not(:placeholder-shown) + + .label:not(:empty) + ) { + --field--placeholder-color: var(--color-text--secondary); + --field--placeholder-offset: var(--space-smallest); + --field--placeholder-transform: 0; + --field--padding-top: calc(var(--space-base) + var(--space-smaller)); + --field--padding-bottom: var(--space-small); +} + +.inputWrapper.disabled label { + --field--placeholder-color: var(--color-text-disabled); +} + +.input:not(:placeholder-shown) + .label:not(:empty) { + font-size: var(--typography--fontSize-small); +} + +.hideLabel .input:not(:placeholder-shown) + .label:not(:empty) { + visibility: hidden; +} + +.inputWrapper.small .input:not(:placeholder-shown) + .label:not(:empty) { + display: none; +} + +/** + * Affix labels render as flex siblings of `.inputWrapper` inside `.wrapper`. + * The .prefix and .suffix modifiers carry variant-specific spacing so the + * label sits visually flush against the typed value (mirroring V1 visuals). + */ +.affixLabel { + display: flex; + align-items: center; + flex: 0 0 auto; + padding-top: var(--field--padding-top); + padding-bottom: var(--field--padding-bottom); + font-size: var(--typography--fontSize-base); + line-height: var(--field--value-lineHeight); + white-space: nowrap; +} + +/** + * Prefix label: negative right margin absorbs the input's left padding so the + * label sits flush against the typed value. + */ +.affixLabel.prefix { + margin: 0 calc((var(--field--padding-left) - var(--space-smallest)) * -1) 0 0; + padding-left: var(--field--padding-left); +} + +/** + * Suffix label: no negative left margin (the stepper sits between the input + * value and this label, so we don't want to pull the label across it). + */ +.affixLabel.suffix { + padding-right: var(--field--padding-left); +} + +/** + * Outer affix icon: rendered as a flex sibling of .inputWrapper inside the + * field group. The prefix variant uses a negative right margin to abut the + * input's left padding (V1 pattern); the suffix variant does NOT, because the + * stepper now sits between the input wrapper and the suffix icon, and a + * negative margin would pull the icon over the stepper. + */ +.affixIcon { + display: flex; + align-items: center; + justify-content: center; + margin: 0 calc(var(--field--padding-left) * -1) 0 0; + padding: 0 var(--field--padding-right) 0 var(--field--padding-left); +} + +.affixIcon.suffix { + margin: 0; + padding: 0 var(--field--padding-right) 0 var(--space-small); +} + +/** + * Compound `<.Affix>`: a single flex sibling of .inputWrapper that bundles + * icon and/or label inline. Visually slightly less integrated than the default + * (split) layout but lets users compose freely. + */ +.compoundAffix { + display: flex; + align-items: center; + gap: var(--space-smaller); + padding: 0 var(--space-base); + color: var(--color-text--secondary); + font-size: var(--typography--fontSize-base); + white-space: nowrap; +} + +.affixLabelText { + line-height: var(--field--value-lineHeight); +} + +.stepper { + display: flex; + z-index: calc(var(--field--base-elevation) + 1); + width: var(--stepper--width); + opacity: 0; + transition: opacity var(--timing-quick); + flex-direction: column; + flex: 0 0 auto; +} + +/* Default mode: stepper lives inside .inputWrapper. Reveal on local hover/focus. */ +.inputWrapper:hover .stepper, +.inputWrapper:focus-within .stepper { + opacity: 1; +} + +/** + * Compound mode: the consumer composes <.Stepper> as a direct child of + * NumberField.Group (.wrapper). Reveal on the wider group hover/focus there. + * The `>` direct-child selector ensures this only matches the compound case; + * default-mode steppers are nested deeper inside .inputWrapper. + */ +.wrapper:hover > .stepper, +.wrapper:focus-within > .stepper { + opacity: 1; +} + +.stepperButton { + display: flex; + padding: 0; + border: none; + color: var(--color-text--secondary); + font-size: 8px; + line-height: 1; + background: transparent; + cursor: pointer; + align-items: center; + justify-content: center; + flex: 1; +} + +.stepperButton:hover { + color: var(--color-heading); + background: var(--color-surface--hover); +} + +.stepperButton:focus-visible { + outline: var(--shadow-focus); + outline-offset: -2px; +} + +.stepperButton:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.belowField { + display: flex; + flex-direction: column; +} + +/** + * `Field.Description` renders a `

`, which inherits the user-agent + * `margin-block` defaults. Reset margins so spacing comes only from our token. + */ +.description { + margin: var(--space-smaller) 0 0 0; + color: var(--color-text--secondary); + font-size: var(--typography--fontSize-small); +} + +.fieldError { + align-items: center; + display: flex; + gap: var(--space-smaller); + padding: var(--space-smaller); + padding-left: 0; + color: var(--color-critical); + font-size: var(--typography--fontSize-small); +} diff --git a/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.module.css.d.ts b/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.module.css.d.ts new file mode 100644 index 0000000000..e98b4a1675 --- /dev/null +++ b/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.module.css.d.ts @@ -0,0 +1,27 @@ +declare const styles: { + readonly "affixIcon": string; + readonly "affixLabel": string; + readonly "affixLabelText": string; + readonly "belowField": string; + readonly "center": string; + readonly "compoundAffix": string; + readonly "container": string; + readonly "description": string; + readonly "disabled": string; + readonly "fieldError": string; + readonly "hideLabel": string; + readonly "inline": string; + readonly "input": string; + readonly "inputWrapper": string; + readonly "label": string; + readonly "large": string; + readonly "prefix": string; + readonly "right": string; + readonly "small": string; + readonly "stepper": string; + readonly "stepperButton": string; + readonly "suffix": string; + readonly "wrapper": string; +}; +export = styles; + diff --git a/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.stories.tsx b/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.stories.tsx new file mode 100644 index 0000000000..f0da6d40d8 --- /dev/null +++ b/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.stories.tsx @@ -0,0 +1,342 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import React, { useState } from "react"; +import { InputNumberExperimental } from "@jobber/components/primitives"; +import { InputNumber } from "@jobber/components/InputNumber"; +import { Content } from "@jobber/components/Content"; +import { Heading } from "@jobber/components/Heading"; + +const meta: Meta = { + title: "Primitives/InputNumberExperimental", + component: InputNumberExperimental, + args: { + placeholder: "Quantity", + min: 0, + max: 100, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + render: args => { + const [value, setValue] = useState(3); + + return ( + + ); + }, +}; + +export const WithSuffixLabel: Story = { + render: args => { + const [value, setValue] = useState(0); + + return ( + + ); + }, +}; + +export const WithPrefixLabelAndSuffixLabel: Story = { + render: args => { + const [value, setValue] = useState(42); + + return ( + + ); + }, +}; + +/** + * V1 parity: prefix with both `label` and `icon`, suffix with `label`. + * Mirrors `InputNumberPrefixAndSuffixExample` from the V1 story. + */ +export const InvoiceTotal_V1Parity: Story = { + args: { + placeholder: "Invoice total", + }, + render: args => { + const [value, setValue] = useState(100000); + + return ( + + ); + }, +}; + +export const PrefixIconOnly: Story = { + args: { placeholder: "Search by amount" }, + render: args => { + const [value, setValue] = useState(undefined); + + return ( + + ); + }, +}; + +export const PrefixAndSuffixIcons: Story = { + args: { placeholder: "Pick a date offset" }, + render: args => { + const [value, setValue] = useState(7); + + return ( + + ); + }, +}; + +export const ClickableSuffixIcon: Story = { + args: { placeholder: "Repetitions" }, + render: args => { + const [value, setValue] = useState(3); + + return ( + setValue(undefined), + }} + value={value} + /> + ); + }, +}; + +export const WithDescriptionAndError: Story = { + render: args => { + const [value, setValue] = useState(150); + + return ( + 100 ? "Too high" : undefined} + onChange={setValue} + value={value} + /> + ); + }, +}; + +/** + * All three sizes side-by-side. Useful for spotting padding/height regressions + * when changes are made to the `.small` or `.large` modifier rules. + */ +export const AllSizes: Story = { + parameters: { controls: { disable: true } }, + render: () => { + const [a, setA] = useState(42); + const [b, setB] = useState(42); + const [c, setC] = useState(42); + + return ( + + + + + + ); + }, +}; + +export const Compound: Story = { + render: args => { + const [value, setValue] = useState(7); + + return ( + + + + + + ); + }, +}; + +/** + * Side-by-side visual diff against V1's PrefixAndSuffix example for the + * full V1 affix surface (label-only, icon-only, label+icon, clickable suffix). + * Use this to spot any visual regressions vs the V1 baseline. + */ +export const V1ComparisonGrid: Story = { + parameters: { controls: { disable: true } }, + render: () => { + const [v1, setV1] = useState(100000); + const [v2, setV2] = useState(100000); + const [v3, setV3] = useState(undefined); + const [v4, setV4] = useState(undefined); + const [v5, setV5] = useState(7); + const [v6, setV6] = useState(7); + const [v7, setV7] = useState(3); + const [v8, setV8] = useState(3); + + const cell = { + display: "grid" as const, + gridTemplateColumns: "1fr 1fr", + gap: "1rem", + alignItems: "start", + }; + const sectionStyle = { + display: "flex" as const, + flexDirection: "column" as const, + gap: "0.25rem", + }; + + return ( + +

+
+ V1 (legacy) + InputNumberExperimental +
+ +
+ Label + icon prefix, label suffix +
+ setV1(newValue)} + /> + +
+
+ +
+ Icon-only prefix +
+ setV3(newValue)} + /> + +
+
+ +
+ Prefix and suffix icons +
+ setV5(newValue)} + /> + +
+
+ +
+ Clickable suffix icon (clears the value) +
+ setV7(undefined), + }} + value={v7} + onChange={(newValue: number) => setV7(newValue)} + /> + setV8(undefined), + }} + value={v8} + onChange={setV8} + /> +
+
+
+ + ); + }, +}; diff --git a/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.tsx b/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.tsx new file mode 100644 index 0000000000..9f02bf8a9d --- /dev/null +++ b/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.tsx @@ -0,0 +1,534 @@ +import type { + FocusEvent, + KeyboardEvent, + ReactNode, + Ref, + RefObject, +} from "react"; +import React, { + createContext, + forwardRef, + useCallback, + useContext, + useId, + useImperativeHandle, + useMemo, + useRef, +} from "react"; +import { NumberField } from "@base-ui/react/number-field"; +import { Field } from "@base-ui/react/field"; +import classnames from "classnames"; +import type { + InputNumberExperimentalAffix, + InputNumberExperimentalAffixCompoundProps, + InputNumberExperimentalAutocomplete, + InputNumberExperimentalInputProps, + InputNumberExperimentalProps, + InputNumberExperimentalRef, + InputNumberExperimentalSize, + InputNumberExperimentalStepperCompoundProps, + InputNumberExperimentalSuffixProp, +} from "./types"; +import styles from "./InputNumberExperimental.module.css"; +import { Button } from "../../Button"; +import { Icon } from "../../Icon"; +import type { IconNames } from "../../Icon"; + +type AffixVariation = "prefix" | "suffix"; + +/** + * Preserve user-typed decimals on blur. Without `maximumFractionDigits: 20`, + * `Intl.NumberFormat` rounds to 3 digits and silently truncates input. + */ +const DEFAULT_FORMAT: Intl.NumberFormatOptions = { + useGrouping: false, + maximumFractionDigits: 20, +}; + +interface InputNumberExperimentalContextValue { + readonly autocomplete?: InputNumberExperimentalAutocomplete; + /** + * `true` when the consumer passed compound children (so they own the layout + * and the `<.Input>` compound shouldn't auto-render the stepper inside its + * wrapper). `false` for the default render path. + */ + readonly compoundLayout: boolean; + readonly disabled?: boolean; + readonly id: string; + readonly innerInputRef: RefObject; + readonly keyboard?: "numeric" | "decimal"; + readonly maxLength?: number; + readonly onBlur?: (event?: FocusEvent) => void; + readonly onFocus?: (event?: FocusEvent) => void; + readonly onKeyDown: (event: KeyboardEvent) => void; + readonly onKeyUp?: (event: KeyboardEvent) => void; + readonly placeholder?: string; + readonly readonly?: boolean; + readonly showMiniLabel: boolean; + readonly showStepper: boolean; + readonly size: InputNumberExperimentalSize; +} + +const InputNumberExperimentalContext = + createContext(null); + +function useInputNumberExperimentalContext( + consumer: string, +): InputNumberExperimentalContextValue { + const context = useContext(InputNumberExperimentalContext); + + if (context === null) { + throw new Error( + ` must be used inside .`, + ); + } + + return context; +} + +function InputNumberExperimentalInternal( + props: InputNumberExperimentalProps, + ref: Ref, +) { + const { + align, + autocomplete, + children, + description, + disabled, + error, + id: idProp, + inline, + invalid, + keyboard, + max, + maxLength, + min, + name, + onBlur, + onChange, + onEnter, + onFocus, + onKeyDown, + onKeyUp, + placeholder, + prefix, + readonly, + showMiniLabel = true, + size = "default", + suffix, + value, + } = props; + + const generatedId = useId(); + const id = idProp ?? generatedId; + + const innerInputRef = useRef(null); + + useImperativeHandle( + ref, + () => ({ + focus: () => innerInputRef.current?.focus(), + blur: () => innerInputRef.current?.blur(), + }), + [], + ); + + const handleValueCommitted = useCallback( + (newValue: number | null) => { + onChange?.(newValue ?? undefined); + }, + [onChange], + ); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + onKeyDown?.(event); + + if ( + event.key === "Enter" && + !event.shiftKey && + !event.ctrlKey && + !event.metaKey + ) { + onEnter?.(event); + + // V1/V2 parity: commit on Enter. Base UI treats Enter as a + // navigation no-op, so round-trip focus to fire onValueCommitted + // via Base UI's onBlur and restore focus immediately after. + const target = event.currentTarget; + target.blur(); + target.focus(); + } + }, + [onKeyDown, onEnter], + ); + + const isUsingCompoundPattern = useMemo( + () => + React.Children.toArray(children).some( + child => + React.isValidElement(child) && + (child.type === InputNumberExperimentalInputCompound || + child.type === InputNumberExperimentalAffixCompound || + child.type === InputNumberExperimentalStepperCompound), + ), + [children], + ); + + const showStepper = !disabled && !readonly; + + // `handleKeyDown` is a `useCallback` whose own deps include `onKeyDown` and + // `onEnter`, so listing it here transitively tracks both. Don't add `onEnter` + // or `onKeyDown` separately — they'd be redundant and an exhaustive-deps lint + // would flag the duplication. + const contextValue = useMemo( + () => ({ + autocomplete, + compoundLayout: isUsingCompoundPattern, + disabled, + id, + innerInputRef, + keyboard, + maxLength, + onBlur, + onFocus, + onKeyDown: handleKeyDown, + onKeyUp, + placeholder, + readonly, + showMiniLabel, + showStepper, + size, + }), + [ + autocomplete, + isUsingCompoundPattern, + disabled, + id, + keyboard, + maxLength, + onBlur, + onFocus, + handleKeyDown, + onKeyUp, + placeholder, + readonly, + showMiniLabel, + showStepper, + size, + ], + ); + + const fieldInvalid = invalid || Boolean(error); + + return ( + + + + + {isUsingCompoundPattern + ? children + : renderDefaultComposition({ prefix, suffix, size })} + + + + {(description || error) && ( +
+ {description && ( + + {description} + + )} + {error && ( + + {error} + + )} +
+ )} +
+ ); +} + +function renderDefaultComposition({ + prefix, + suffix, + size, +}: { + readonly prefix?: InputNumberExperimentalAffix; + readonly suffix?: InputNumberExperimentalSuffixProp; + readonly size: InputNumberExperimentalSize; +}): ReactNode { + return ( + <> + {prefix?.icon && ( + + )} + {prefix?.label && ( + + )} + + {suffix?.label && ( + + )} + {suffix?.icon && ( + + )} + + ); +} + +function InputNumberExperimentalInputCompound( + props: InputNumberExperimentalInputProps = {}, +) { + const ctx = useInputNumberExperimentalContext("Input"); + const labelText = props.placeholder ?? ctx.placeholder; + + return ( +
+ + {labelText && ( + + )} + {!ctx.compoundLayout && } +
+ ); +} + +function InputNumberExperimentalAffixCompound({ + variation, + label, + icon, + onClick, + ariaLabel, +}: InputNumberExperimentalAffixCompoundProps) { + const ctx = useInputNumberExperimentalContext("Affix"); + + if (!icon && !label) { + return null; + } + + return ( +
+ {icon && ( + + )} + {label && {label}} +
+ ); +} + +function InputNumberExperimentalStepperCompound({ + incrementLabel, + decrementLabel, +}: InputNumberExperimentalStepperCompoundProps = {}) { + const ctx = useInputNumberExperimentalContext("Stepper"); + + if (!ctx.showStepper) { + return null; + } + + const labelTarget = ctx.placeholder ?? "value"; + const resolvedIncrementLabel = incrementLabel ?? `Increase ${labelTarget}`; + const resolvedDecrementLabel = decrementLabel ?? `Decrease ${labelTarget}`; + + return ( +
+ + ▲ + + + ▼ + +
+ ); +} + +interface AffixIconSlotProps { + readonly variation: AffixVariation; + readonly icon: IconNames; + readonly size: InputNumberExperimentalSize; + readonly onClick?: () => void; + readonly ariaLabel?: string; +} + +function AffixIconSlot({ + variation, + icon, + size, + onClick, + ariaLabel, +}: AffixIconSlotProps) { + return ( +
+ +
+ ); +} + +interface AffixIconContentProps { + readonly icon: IconNames; + readonly size: InputNumberExperimentalSize; + readonly onClick?: () => void; + readonly ariaLabel?: string; +} + +function AffixIconContent({ + icon, + size, + onClick, + ariaLabel, +}: AffixIconContentProps) { + const iconSize = size === "small" ? "small" : "base"; + + if (onClick) { + return ( +