diff --git a/.changeset/number-input-stepper-hold.md b/.changeset/number-input-stepper-hold.md new file mode 100644 index 0000000000000..c8853532ce2eb --- /dev/null +++ b/.changeset/number-input-stepper-hold.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feature] Holding a NumberInput stepper with a mouse or a pen repeats the step: the first repeat comes after 0.5 s, and later ones come faster. The field shows each repeated value as its draft and calls `onChange` once, when the hold ends by release or pointer cancel. A click still steps and commits at once. + +@vjeux diff --git a/docs/specs/components/NumberInput/NumberInput.spec.md b/docs/specs/components/NumberInput/NumberInput.spec.md index 65126a1a7cb80..a6349fa58fb8a 100644 --- a/docs/specs/components/NumberInput/NumberInput.spec.md +++ b/docs/specs/components/NumberInput/NumberInput.spec.md @@ -117,21 +117,21 @@ Consumer migration instructions belong in consumer docs and release notes. ## Behavioral and layout contract -| ID | Invariant | Basis | -| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -| FR1 | The input is a native text input with `role="spinbutton"`, `inputMode` `numeric` when integer-only and `decimal` otherwise, `aria-valuemin` and `aria-valuemax` from the range, `aria-valuenow` from the committed `value`, and `aria-valuetext` set to the formatted value when `formatValue` is supplied. | Current tests | -| FR2 | Typing changes only a draft; `onChange` is not called while typing. A draft is valid when it parses for the active locale (grouping, decimal separator, digit script, and sign) and, for validity display, lies within the range and meets integer-only. An invalid draft colors the text secondary, sets `aria-invalid`, and fills a persistent assertive region with the localized invalid-number message. | `spec:AST-086` FR3; `architecture:internationalization`; current tests | -| FR3 | On blur the draft resolves once and ends: an empty draft clears (`onChange(null)` when clearable and a value exists) or reverts; an unparsable or wrong-kind draft reverts; otherwise the number clamps to the range and commits with `onChange(number)` when it differs from `value`. Enter resolves a valid draft the same way and clears an empty draft when clearable. Enter commits nothing for an empty draft without `hasClear`, which stays in the field and is not marked invalid, or for an unparsable or wrong-kind draft, which stays in the field and stays invalid. Enter ends the draft only when its commit clamped. In a field that is not disabled, every Enter outside composition then calls `onEnter`, whatever it committed. | Current tests | -| FR4 | ArrowUp and ArrowDown without modifiers, the wheel over the focused input (when `isWheelEnabled`), and the steppers step by one grid position and commit at once. The grid starts at `min` (when it suits integer-only) or 0. From an empty field or an emptied draft, up goes to `min` or 0 and down to `max` or 0. Results round to the finer of the step's and the grid start's precision (at most 12 decimal places), clamp to the range, and never leave integer-only; a stepper that cannot move does nothing, while an arrow or wheel step that cannot move still ends the draft, committing a valid draft's number and dropping an unparsable one. A caller `onKeyDown` that prevents an arrow cancels that step. A wheel step stops the page from scrolling; otherwise the page scrolls. | Current tests | -| FR5 | With `hasNumberSteppers`, increment and decrement buttons render at the end of the control unless the primary pointer is coarse. On coarse primary pointers the buttons are hidden from layout and the accessibility tree at every viewport width, with no opt-out; the control restores its normal themed end padding. Direct numeric entry and keyboard stepping remain available. When shown, the buttons are outside the tab order, are named with the localized increment and decrement phrases and the label, keep focus on the input when pressed, and expose `aria-disabled` when stepping that way cannot move or the field is disabled or read-only. | `spec:AST-085` FR1, FR2; current tests | -| FR6 | With `formatValue`, the input shows the formatted committed value at rest and the plain locale-formatted number while focused. The form value is then submitted from a hidden input carrying the raw number, and the visible input has no `name`; without `formatValue` the visible input carries `name`. | Current tests | -| FR7 | `units` renders as secondary text after the input and is part of the input's description. | Current tests | -| FR8 | With `hasClear`, a clear button shows while `value` is not `null` and the field is editable. Activating it calls `onChange(null)`, drops the draft, and focuses the input as TextInput's clear does. The clear button is a ghost small `component:Button`, so its press scale MUST NOT apply under reduced motion (`design:reduced-motion` DR1). Implementation gap: it still scales under reduced motion. | `family:input-fields` FR11; current tests | -| FR9 | NumberInput is disabled when its own `isDisabled` or an enclosing InputGroup's `isDisabled` is true; an enabled group never re-enables a field that disables itself (`spec:AST-085` FR7). Disabled: `aria-disabled="true"` and `readOnly` on the input, a kept tab stop, no submitted value, no draft commit (a draft in progress is dropped), no stepping by any route, no caller handlers other than focus and blur, Enter cancelled, the clear button hidden, the control dimmed, and a keyboard focus ring. With `disabledMessage`, a reason tooltip opens on hover and keyboard focus and describes the input. | `spec:AST-085` FR1–FR3, FR6; `family:input-fields` FR4; current tests | -| FR10 | Read-only: `readOnly`, full opacity, still submitted, no edits, and no stepping by keyboard, wheel, or steppers; the wheel then scrolls the page. Disabled wins when both are set. | Current tests | -| FR11 | Arrow and Enter keys that belong to an IME composition neither step nor commit. | Current tests | -| FR12 | Field composition, InputGroup adaptation, status presentation, start icon, click-to-focus, size and iOS text floor, and the prop targets match `component:TextInput` FR1, FR9, FR11–FR13, including its implementation gaps (the fixed height against `spec:AST-102` FR4, an error status without text, the color-only `detached` status, and the grouped description), plus a `labelIcon` passed to Field. | `family:input-fields` FR3, FR8–FR10; current tests | -| FR13 | The control box carries `astryx-number-input` with `data-size`, `data-status`, `data-disabled`, and `data-readonly`. A theme `padding` in any spelling on `number-input` pads the control box and the stepper column together. | `architecture:component-theming-surface`; current tests | +| ID | Invariant | Basis | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| FR1 | The input is a native text input with `role="spinbutton"`, `inputMode` `numeric` when integer-only and `decimal` otherwise, `aria-valuemin` and `aria-valuemax` from the range, `aria-valuenow` from the committed `value`, and `aria-valuetext` set to the formatted value when `formatValue` is supplied. | Current tests | +| FR2 | Typing changes only a draft; `onChange` is not called while typing. A draft is valid when it parses for the active locale (grouping, decimal separator, digit script, and sign) and, for validity display, lies within the range and meets integer-only. An invalid draft colors the text secondary, sets `aria-invalid`, and fills a persistent assertive region with the localized invalid-number message. | `spec:AST-086` FR3; `architecture:internationalization`; current tests | +| FR3 | On blur the draft resolves once and ends: an empty draft clears (`onChange(null)` when clearable and a value exists) or reverts; an unparsable or wrong-kind draft reverts; otherwise the number clamps to the range and commits with `onChange(number)` when it differs from `value`. Enter resolves a valid draft the same way and clears an empty draft when clearable. Enter commits nothing for an empty draft without `hasClear`, which stays in the field and is not marked invalid, or for an unparsable or wrong-kind draft, which stays in the field and stays invalid. Enter ends the draft only when its commit clamped. In a field that is not disabled, every Enter outside composition then calls `onEnter`, whatever it committed. | Current tests | +| FR4 | ArrowUp and ArrowDown without modifiers, the wheel over the focused input (when `isWheelEnabled`), and the steppers step by one grid position and commit at once. The grid starts at `min` (when it suits integer-only) or 0. From an empty field or an emptied draft, up goes to `min` or 0 and down to `max` or 0. Results round to the finer of the step's and the grid start's precision (at most 12 decimal places), clamp to the range, and never leave integer-only; a stepper that cannot move does nothing, while an arrow or wheel step that cannot move still ends the draft, committing a valid draft's number and dropping an unparsable one. A caller `onKeyDown` that prevents an arrow cancels that step. A wheel step stops the page from scrolling; otherwise the page scrolls. | Current tests | +| FR5 | With `hasNumberSteppers`, increment and decrement buttons render at the end of the control unless the primary pointer is coarse. On coarse primary pointers the buttons are hidden from layout and the accessibility tree at every viewport width, with no opt-out; the control restores its normal themed end padding. Direct numeric entry and keyboard stepping remain available. When shown, the buttons are outside the tab order, are named with the localized increment and decrement phrases and the label, keep focus on the input when pressed, and expose `aria-disabled` when stepping that way cannot move or the field is disabled or read-only. Holding a stepper with a mouse or a pen repeats the step, the first repeat after 0.5 s and then faster; each repeated value shows as the field's draft, and the field commits once, through `onChange`, when the hold ends by release or pointer cancel. A single click steps and commits at once. | `spec:AST-085` FR1, FR2; current tests | +| FR6 | With `formatValue`, the input shows the formatted committed value at rest and the plain locale-formatted number while focused. The form value is then submitted from a hidden input carrying the raw number, and the visible input has no `name`; without `formatValue` the visible input carries `name`. | Current tests | +| FR7 | `units` renders as secondary text after the input and is part of the input's description. | Current tests | +| FR8 | With `hasClear`, a clear button shows while `value` is not `null` and the field is editable. Activating it calls `onChange(null)`, drops the draft, and focuses the input as TextInput's clear does. The clear button is a ghost small `component:Button`, so its press scale MUST NOT apply under reduced motion (`design:reduced-motion` DR1). Implementation gap: it still scales under reduced motion. | `family:input-fields` FR11; current tests | +| FR9 | NumberInput is disabled when its own `isDisabled` or an enclosing InputGroup's `isDisabled` is true; an enabled group never re-enables a field that disables itself (`spec:AST-085` FR7). Disabled: `aria-disabled="true"` and `readOnly` on the input, a kept tab stop, no submitted value, no draft commit (a draft in progress is dropped), no stepping by any route, no caller handlers other than focus and blur, Enter cancelled, the clear button hidden, the control dimmed, and a keyboard focus ring. With `disabledMessage`, a reason tooltip opens on hover and keyboard focus and describes the input. | `spec:AST-085` FR1–FR3, FR6; `family:input-fields` FR4; current tests | +| FR10 | Read-only: `readOnly`, full opacity, still submitted, no edits, and no stepping by keyboard, wheel, or steppers; the wheel then scrolls the page. Disabled wins when both are set. | Current tests | +| FR11 | Arrow and Enter keys that belong to an IME composition neither step nor commit. | Current tests | +| FR12 | Field composition, InputGroup adaptation, status presentation, start icon, click-to-focus, size and iOS text floor, and the prop targets match `component:TextInput` FR1, FR9, FR11–FR13, including its implementation gaps (the fixed height against `spec:AST-102` FR4, an error status without text, the color-only `detached` status, and the grouped description), plus a `labelIcon` passed to Field. | `family:input-fields` FR3, FR8–FR10; current tests | +| FR13 | The control box carries `astryx-number-input` with `data-size`, `data-status`, `data-disabled`, and `data-readonly`. A theme `padding` in any spelling on `number-input` pads the control box and the stepper column together. | `architecture:component-theming-surface`; current tests | ### Allowed variation @@ -221,6 +221,7 @@ anatomy-theming map. | AR5 | `NumberInput.a11y.chromium.spec.ts` with its recorded known failure | Read-only | The known failure stops matching when the gap closes. | `audit:NumberInput/accessibility` | | FR11 | `NumberInput.test.tsx` "does not step or commit on a composing keydown (IME)" and "waits for IME composition to finish before committing" | Composing arrows and Enter | A step or commit during composition fails. | `audit:NumberInput/behavior` | | FR5, FR13, AR4 | `NumberInput.coarse-pointer.a11y.chromium.spec.ts` | Coarse/non-coarse pointers at 390 and 1280 px, LTR/RTL, non-default themed end padding | Visible coarse-pointer steppers, hidden fine-pointer steppers, wrong end padding, or lost direct/keyboard entry fail. | `audit:NumberInput/behavior` | +| FR5 | `NumberInput.test.tsx` "a held stepper" suite | Hold and release; pointer cancel; a bound; clicks; a finger press | A commit per repeat, a repeat after the release, or a step after the field turns disabled or read-only fails. | `audit:NumberInput/behavior` | ## Current-state impact diff --git a/packages/core/src/NumberInput/NumberInput.doc.mjs b/packages/core/src/NumberInput/NumberInput.doc.mjs index 45f325779e417..984ee9aea4ddb 100644 --- a/packages/core/src/NumberInput/NumberInput.doc.mjs +++ b/packages/core/src/NumberInput/NumberInput.doc.mjs @@ -160,7 +160,7 @@ export const docs = { name: 'hasNumberSteppers', type: 'boolean', description: - 'Shows increment and decrement buttons at the end of the input on fine pointers. Hidden on coarse pointers (such as touch screens); text entry and keyboard stepping remain available.', + 'Shows increment and decrement buttons at the end of the input on fine pointers. Hidden on coarse pointers (such as touch screens); text entry and keyboard stepping remain available. Holding a button with a mouse or a pen repeats the step and commits once, when the hold ends.', default: 'false', }, { diff --git a/packages/core/src/NumberInput/NumberInput.test.tsx b/packages/core/src/NumberInput/NumberInput.test.tsx index dc87c7dcc6962..19e88bc3453c5 100644 --- a/packages/core/src/NumberInput/NumberInput.test.tsx +++ b/packages/core/src/NumberInput/NumberInput.test.tsx @@ -2527,6 +2527,481 @@ describe('NumberInput stepping', () => { expect(onChange).not.toHaveBeenCalled(); }); + describe('a held stepper', () => { + const mousePress = {button: 0, pointerId: 1, pointerType: 'mouse'}; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function Controlled({ + onChange, + initial = 5, + isReadOnly, + isDisabled, + hasNumberSteppers = true, + }: { + onChange: (value: number | null) => void; + initial?: number; + isReadOnly?: boolean; + isDisabled?: boolean; + hasNumberSteppers?: boolean; + }) { + const [value, setValue] = useState(initial); + return ( + { + onChange(next); + setValue(next); + }} + /> + ); + } + + it('repeats into the draft and commits once, on release', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole('spinbutton'); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + + fireEvent.pointerDown(increment, mousePress); + act(() => { + vi.advanceTimersByTime(499); + }); + expect(input).toHaveValue('5'); + // The first repeat comes at 0.5 s, the next two 250 ms and 225 ms + // later. + act(() => { + vi.advanceTimersByTime(1 + 250 + 225); + }); + expect(input).toHaveValue('8'); + expect(onChange).not.toHaveBeenCalled(); + + fireEvent.pointerUp(increment, mousePress); + fireEvent.click(increment, {detail: 1}); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(8); + expect(input).toHaveValue('8'); + expect(input).toHaveFocus(); + }); + + it('commits once when the pointer is canceled', () => { + const onChange = vi.fn(); + render(); + const decrement = screen.getByRole('button', { + name: 'Decrement Quantity', + }); + + fireEvent.pointerDown(decrement, mousePress); + act(() => { + vi.advanceTimersByTime(500 + 250); + }); + fireEvent.pointerCancel(decrement, mousePress); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(3); + expect(screen.getByRole('spinbutton')).toHaveValue('3'); + }); + + it('stops repeating at a bound and commits the bound once', () => { + const onChange = vi.fn(); + render(); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + + fireEvent.pointerDown(increment, mousePress); + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(screen.getByRole('spinbutton')).toHaveValue('10'); + fireEvent.pointerUp(increment, mousePress); + fireEvent.click(increment, {detail: 1}); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(10); + }); + + it('steps and commits at once for each click', () => { + const onChange = vi.fn(); + render(); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + + for (const expected of [6, 7]) { + fireEvent.pointerDown(increment, mousePress); + act(() => { + vi.advanceTimersByTime(100); + }); + fireEvent.pointerUp(increment, mousePress); + fireEvent.click(increment, {detail: 1}); + expect(onChange).toHaveBeenLastCalledWith(expected); + } + expect(onChange).toHaveBeenCalledTimes(2); + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(onChange).toHaveBeenCalledTimes(2); + expect(screen.getByRole('spinbutton')).toHaveValue('7'); + }); + + it('steps for a click with no pointer after a hold, such as a screen reader activation', () => { + const onChange = vi.fn(); + render(); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + + fireEvent.pointerDown(increment, mousePress); + act(() => { + vi.advanceTimersByTime(500); + }); + // The release's click lands elsewhere, so the button gets none. + fireEvent.pointerUp(increment, mousePress); + expect(onChange).toHaveBeenLastCalledWith(6); + fireEvent.click(increment, {detail: 0}); + expect(onChange).toHaveBeenLastCalledWith(7); + expect(onChange).toHaveBeenCalledTimes(2); + }); + + it.each([ + ['read-only', {isReadOnly: true}], + ['disabled', {isDisabled: true}], + ])( + 'commits nothing when the field becomes %s during the hold', + (_state, props) => { + const onChange = vi.fn(); + const {rerender} = render(); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + + fireEvent.pointerDown(increment, mousePress); + act(() => { + vi.advanceTimersByTime(500 + 250); + }); + expect(screen.getByRole('spinbutton')).toHaveValue('7'); + rerender(); + // The hold ends at once: the draft drops and stops moving. + expect(screen.getByRole('spinbutton')).toHaveValue('5'); + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(screen.getByRole('spinbutton')).toHaveValue('5'); + fireEvent.pointerUp(increment, mousePress); + fireEvent.click(increment, {detail: 1}); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByRole('spinbutton')).toHaveValue('5'); + }, + ); + + it('ends the hold with no commit when the steppers go away', () => { + const onChange = vi.fn(); + const {rerender} = render(); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + const input = screen.getByRole('spinbutton'); + + fireEvent.pointerDown(increment, mousePress); + act(() => { + vi.advanceTimersByTime(500 + 250); + }); + expect(input).toHaveValue('7'); + rerender(); + expect(input).toHaveValue('5'); + act(() => { + vi.advanceTimersByTime(10_000); + }); + expect(input).toHaveValue('5'); + fireEvent.blur(input); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('holds a pen press with no text selection or callout, until it ends', () => { + const onChange = vi.fn(); + render(); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + const penPress = {...mousePress, pointerType: 'pen'}; + const refused = (type: string) => + !increment.dispatchEvent( + new Event(type, {bubbles: true, cancelable: true}), + ); + + fireEvent.pointerDown(increment, penPress); + act(() => { + vi.advanceTimersByTime(500 + 250); + }); + expect(screen.getByRole('spinbutton')).toHaveValue('7'); + expect(refused('selectstart')).toBe(true); + expect(refused('contextmenu')).toBe(true); + fireEvent.pointerUp(increment, penPress); + fireEvent.click(increment, {detail: 1}); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(7); + expect(refused('selectstart')).toBe(false); + expect(refused('contextmenu')).toBe(false); + }); + + it("lets a second pointer's press replace a running hold", () => { + const onChange = vi.fn(); + render(); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + const decrement = screen.getByRole('button', { + name: 'Decrement Quantity', + }); + const penPress = {...mousePress, pointerId: 2, pointerType: 'pen'}; + + fireEvent.pointerDown(increment, mousePress); + act(() => { + vi.advanceTimersByTime(500 + 250); + }); + fireEvent.pointerDown(decrement, penPress); + fireEvent.pointerUp(decrement, penPress); + fireEvent.click(decrement, {detail: 1}); + fireEvent.pointerUp(increment, mousePress); + fireEvent.click(increment, {detail: 1}); + const shown = screen.getByRole('spinbutton').value; + act(() => { + vi.advanceTimersByTime(5000); + }); + // Nothing keeps stepping once both pointers are up. + expect(screen.getByRole('spinbutton')).toHaveValue(shown); + }); + + it('lets only the holding pointer end a replaced hold', () => { + const onChange = vi.fn(); + render(); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + const decrement = screen.getByRole('button', { + name: 'Decrement Quantity', + }); + const input = screen.getByRole('spinbutton'); + const penPress = {...mousePress, pointerId: 2, pointerType: 'pen'}; + + fireEvent.pointerDown(increment, mousePress); + act(() => { + vi.advanceTimersByTime(500 + 250); + }); + fireEvent.pointerDown(decrement, penPress); + // The mouse lifts first; the pen's hold goes on. + fireEvent.pointerUp(increment, mousePress); + fireEvent.click(increment, {detail: 1}); + act(() => { + vi.advanceTimersByTime(500 + 250); + }); + expect(input).toHaveValue('3'); + fireEvent.pointerUp(decrement, penPress); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenLastCalledWith(3); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(input).toHaveValue('3'); + }); + + it('ends a mouse hold with no commit when a finger taps a stepper', () => { + const onChange = vi.fn(); + render(); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + const decrement = screen.getByRole('button', { + name: 'Decrement Quantity', + }); + const fingerPress = {...mousePress, pointerId: 3, pointerType: 'touch'}; + + fireEvent.pointerDown(increment, mousePress); + act(() => { + vi.advanceTimersByTime(500 + 250); + }); + expect(screen.getByRole('spinbutton')).toHaveValue('7'); + fireEvent.pointerDown(decrement, fingerPress); + fireEvent.pointerUp(decrement, fingerPress); + fireEvent.click(decrement, {detail: 1}); + // The tap steps from the committed value; the hold's line is gone. + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenLastCalledWith(4); + fireEvent.pointerUp(increment, mousePress); + fireEvent.click(increment, {detail: 1}); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(onChange).toHaveBeenCalledTimes(1); + expect(screen.getByRole('spinbutton')).toHaveValue('4'); + }); + + it('shows no held value again when the field turns editable mid-hold', () => { + const onChange = vi.fn(); + const {rerender} = render(); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + const input = screen.getByRole('spinbutton'); + + fireEvent.pointerDown(increment, mousePress); + act(() => { + vi.advanceTimersByTime(500 + 250); + }); + expect(input).toHaveValue('7'); + rerender(); + rerender(); + expect(input).toHaveValue('5'); + fireEvent.pointerUp(increment, mousePress); + fireEvent.click(increment, {detail: 1}); + expect(onChange).not.toHaveBeenCalled(); + expect(input).toHaveValue('5'); + }); + + it('keeps a typed draft when the field turns read-only mid-hold', () => { + const onChange = vi.fn(); + const {rerender} = render(); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + const input = screen.getByRole('spinbutton'); + + fireEvent.change(input, {target: {value: '6'}}); + fireEvent.pointerDown(increment, mousePress); + act(() => { + vi.advanceTimersByTime(500 + 250); + }); + expect(input).toHaveValue('8'); + rerender(); + fireEvent.pointerUp(increment, mousePress); + fireEvent.click(increment, {detail: 1}); + expect(onChange).not.toHaveBeenCalled(); + expect(input).toHaveValue('6'); + }); + + it('ends a mouse hold with no commit at a context request, and opens the menu', () => { + const onChange = vi.fn(); + render(); + const decrement = screen.getByRole('button', { + name: 'Decrement Quantity', + }); + const input = screen.getByRole('spinbutton'); + + // A macOS Control-click is a primary-button press. + fireEvent.pointerDown(decrement, mousePress); + act(() => { + vi.advanceTimersByTime(500 + 250); + }); + expect(input).toHaveValue('3'); + // fireEvent returns false when a handler refuses the event. + expect(fireEvent.contextMenu(decrement)).toBe(true); + expect(input).toHaveValue('5'); + act(() => { + vi.advanceTimersByTime(5000); + }); + fireEvent.pointerUp(decrement, mousePress); + fireEvent.click(decrement, {detail: 1}); + expect(onChange).not.toHaveBeenCalled(); + expect(input).toHaveValue('5'); + }); + + it('steps nothing on a click after a context request ends a hold', () => { + const onChange = vi.fn(); + render(); + const decrement = screen.getByRole('button', { + name: 'Decrement Quantity', + }); + + fireEvent.pointerDown(decrement, mousePress); + expect(fireEvent.contextMenu(decrement)).toBe(true); + fireEvent.click(decrement, {detail: 1}); + fireEvent.pointerUp(decrement, mousePress); + fireEvent.click(decrement, {detail: 1}); + // The menu may take the pointer's capture before its click. + fireEvent.pointerDown(decrement, mousePress); + expect(fireEvent.contextMenu(decrement)).toBe(true); + fireEvent.lostPointerCapture(decrement, mousePress); + fireEvent.click(decrement, {detail: 1}); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByRole('spinbutton')).toHaveValue('5'); + }); + + it('shows a held value as valid after an invalid typed draft', () => { + const onChange = vi.fn(); + render(); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + const input = screen.getByRole('spinbutton'); + + fireEvent.change(input, {target: {value: 'abc'}}); + expect(input).toHaveAttribute('aria-invalid', 'true'); + fireEvent.pointerDown(increment, mousePress); + act(() => { + vi.advanceTimersByTime(500); + }); + expect(input).toHaveValue('6'); + expect(input).not.toHaveAttribute('aria-invalid'); + fireEvent.pointerUp(increment, mousePress); + expect(onChange).toHaveBeenCalledWith(6); + }); + + it('enables the other stepper as a hold leaves a bound', () => { + const onChange = vi.fn(); + render(); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + const decrement = screen.getByRole('button', { + name: 'Decrement Quantity', + }); + + expect(increment).toHaveAttribute('aria-disabled', 'true'); + fireEvent.pointerDown(decrement, mousePress); + act(() => { + vi.advanceTimersByTime(500 + 250); + }); + expect(screen.getByRole('spinbutton')).toHaveValue('8'); + expect(increment).not.toHaveAttribute('aria-disabled'); + fireEvent.pointerUp(decrement, mousePress); + expect(onChange).toHaveBeenCalledWith(8); + }); + + it('keeps the single step of a finger press', () => { + const onChange = vi.fn(); + render(); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + const fingerPress = {...mousePress, pointerType: 'touch'}; + + fireEvent.pointerDown(increment, fingerPress); + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(screen.getByRole('spinbutton')).toHaveValue('5'); + fireEvent.pointerUp(increment, fingerPress); + fireEvent.click(increment, {detail: 1}); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(6); + }); + }); + it('disables both steppers with the input', () => { render(