Repository navigation
feat(NumberInput): under a finger the steppers are one UIStepper-style control beside the field - #8218
feat(NumberInput): under a finger the steppers are one UIStepper-style control beside the field#8218vjeux wants to merge 1958 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
PR Analysis Report📚 Storybook Preview🧪 Sandbox PreviewModified ComponentsCheckboxInput (@astryxdesign/core) · View in Storybook
Item (@astryxdesign/core) · View in Storybook
NumberInput (@astryxdesign/core) · View in Storybook
TreeList (@astryxdesign/core) · View in Storybook
Component Size
Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.
|
| Component | JavaScript | CSS |
|---|---|---|
| core/NumberInput | +21.7KB |
+1.1KB |
| core/Pagination | +22.0KB |
+1.5KB |
| core/PowerSearch | +22.0KB |
+1.5KB |
Bundle Size Summary
Gzip figures measure each package's entry file, not the code an import pulls in.
| Package | Entry (ESM) | Entry gzip before | Entry gzip after | Delta |
|---|---|---|---|---|
| @astryxdesign/core | 4.9KB | 1.2KB | 1.2KB | 0B |
| Package stylesheet | Gzip before | Gzip after | Delta |
|---|---|---|---|
| @astryxdesign/core | 34.6KB | 36.3KB | +1.7KB |
Accessibility Audit
Status: 4 accessibility violation(s) found — 4 serious.
Checkbox pattern - 1 issue(s)
- 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
- Rule:
color-contrast· Affects 1/25 stories · Learn more - WCAG: 1.4.3 (Level AA)
- Rule:
CheckboxList - 1 issue(s)
- 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
- Rule:
color-contrast· Affects 3/19 stories · Learn more - WCAG: 1.4.3 (Level AA)
- Rule:
Radio group pattern - 1 issue(s)
- 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
- Rule:
color-contrast· Affects 3/11 stories · Learn more - WCAG: 1.4.3 (Level AA)
- Rule:
RadioList - 1 issue(s)
- 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
- Rule:
color-contrast· Affects 3/14 stories · Learn more - WCAG: 1.4.3 (Level AA)
- Rule:
Visual Regression
55 of 502 shot(s) changed.
A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.
| component | story | theme | mode | pixels |
|---|---|---|---|---|
| Selector | Indicator Space Bottom Sheet Narrow End RTL | neutral | light | 753,933 |
| Selector | Indicator Space Bottom Sheet Wide Start | neutral | light | 740,072 |
| RichTextEditor | Markdown parity: side by side | probe | dark | 558,840 |
| RichTextEditor | Markdown parity: side by side | probe | light | 540,756 |
| Chat | Full AI Chat | probe | light | 230,823 |
| Chat | Full AI Chat | probe | dark | 229,805 |
| AppShell | With Mobile Nav | probe | light | 217,010 |
| AppShell | With Mobile Nav | probe | dark | 216,960 |
| Collapsible | Dividers | probe | dark | 66,445 |
| Collapsible | Dividers | probe | light | 66,445 |
| AppShell | Playground | neutral | dark | 65,198 |
| AppShell | Playground | neutral | light | 63,788 |
| Chat | Default | neutral | dark | 36,304 |
| Chat | Default | neutral | light | 35,767 |
| Chat | Empty State | probe | light | 23,531 |
| Chat | Empty State | probe | dark | 22,622 |
| ChatAutoScroll | Scroll Behavior Comparison | neutral | dark | 15,378 |
| ChatAutoScroll | Scroll Behavior Comparison | neutral | light | 14,107 |
| Chat | With Attachments | probe | light | 13,406 |
| Chat | With Attachments | probe | dark | 11,232 |
and 35 more.
Generated by PR Enrichment workflow | Storybook | Sandbox | View full report
There was a problem hiding this comment.
This needs an owner's decision on spec direction before approval, and a few changes are required either way. No current spec governs NumberInput's coarse-pointer stepper control. Keeping − on the left in right-to-left pages and drawing a 32 px control on size="sm" also conflict with the current bidirectionality and component-size rules, and the draft AST-094 adds new requirements and amends current ones, including AST-025 FR14 and the user-states, buttons and theme-tokens records. An engineering owner (or a design owner, for the design records) records the decision by commenting /approve-spec 7b446108d1b58caf063ecd8e39d08366d5dce0e0 on this pull request, and the author may do so as an engineering owner.
Whatever that decision, these need fixing:
- On a touch screen, a stepper field with a narrow explicit width (the checkout-wizard template's
width={96}) shrinks its input to 0 px while the control overflows into the next column. - With the default attached status, the message box keeps the field's full width and sits under the stepper control.
- The diff carries 28,118 authored lines from six open predecessor PRs; landing those first, or retargeting onto #8205, would leave this PR with its own 997-line commit.
[Automated review]
| // width and a gap beside it. | ||
| wrapperWithTouchSteppers: { | ||
| paddingInlineEnd: {default: 0, [COARSE]: padInlineEnd}, | ||
| marginInlineEnd: { |
There was a problem hiding this comment.
On a touch screen this margin comes out of the Field's own width with no floor. At the checkout-wizard template's width={96} the field box is 18 px, the input 0 px, and the control ends 24 px past the field. The default attached status also keeps the full width and sits under the control. A minimum inline size for the box, with a fallback to the in-field column, plus reserving the control outside the status wrapper, would avoid both.
There was a problem hiding this comment.
Fixed in 4bda068. The control's room now comes out of the Field box's padding instead of the input box, so an attached status ends where the input box ends. A Field box under 170 px (the control, the gap and a box that still shows a few digits), from a numeric width at render or measured otherwise, keeps the column inside it, as an InputGroup does. Measured in Chromium in the iOS idiom: width={96} now gives a usable box with the in-field column. Tests that fail on the previous head: in a field too narrow for the control the steppers stay in its box, an attached status ends where the input box ends, clear of the control, and the jsdom Field-box and narrow-width cases. Simulator stills are in the description.
| // page and in order in a right-to-left one, − lands on the left either way. | ||
| flexDirection: { | ||
| default: 'column', | ||
| [COARSE]: {default: 'row-reverse', ':is([dir="rtl"] *)': 'row'}, |
There was a problem hiding this comment.
Keeping − on the left in right-to-left un-mirrors an ordered progression. Current design:bidirectionality DR3 asks it to follow the region's direction, and DR10 allows a physical orientation only when it carries stable user meaning, which matching UIKit alone does not establish. This needs an owner ruling, or a recorded exception.
| inside a scroll area MUST be decided once, when the drag begins. If the | ||
| area sits at its edge and the drag moves toward that edge, the surrounding | ||
| surface (a sheet) takes the drag; otherwise the area keeps it for the whole | ||
| gesture and rubber-bands at its edge. The drag MUST NOT change hands midway, |
There was a problem hiding this comment.
This contradicts current spec:AST-025 FR14: nested wheel, touch and keyboard scrolling continues to an ancestor when the area cannot move. The amendment needs that record's owner to update it before this requirement can govern.
7b44610 to
5de4cce
Compare
There was a problem hiding this comment.
Approval waits on an engineering owner's decision on the touch-screen stepper and draft AST-094 (FR1-FR23, DEC-1 / DEC-2 and the amendments it declares to AST-025 FR14, user-states DR1 / DR3, the buttons family and theme-tokens), including keeping − on the left in right-to-left pages and a 32 px control beside the 28 px small field; an engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Whatever the decision, these still need changing:
- At an explicit
width={96}, as the checkout-wizard template sets, the touch field drops to 18 px with a 0 px input, and an attached status runs under the control (input-fields FR1, FR2, FR8). lintfails onNumberInput.stepperTouch.test.tsx:278andNumberInputStepperLook.interaction.browser.spec.ts:393(promise-function-asyncwithallowAny: false).- At 28,212 authored lines the stack is easier to review landed as its own pull requests, with this one keeping the stepper commit.
[Automated review]
| const {onChange, increment} = setup({initial: 8, max: 10}); | ||
| const id = fingerDown(increment, INCREMENT); | ||
| wait(2600); | ||
| expect(onChange.mock.calls.map(([value]) => value)).toEqual([9, 10]); |
There was a problem hiding this comment.
lint fails here: @typescript-eslint/promise-function-async runs with allowAny: false (eslint.config.js:251-254), so this callback, which returns the any of an untyped vi.fn() call, counts as returning a promise. Typing the mock, for example vi.fn<(value: number | null) => void>(), clears it.
| await page.waitForTimeout(150); | ||
| await board.capture('lifted', 1950, await data()); | ||
| await board.write(testInfo); | ||
| const values = board.frames.map(f => f.data.value); |
There was a problem hiding this comment.
lint fails here for the same rule: f.data.value is unknown, which allowAny: false treats as a possible promise. A typed read such as board.frames.map(f => Number(f.data.value)) clears it.
There was a problem hiding this comment.
Fixed in 4bda068: board.frames.map(f => Number(f.data.value)).
5de4cce to
e272f77
Compare
There was a problem hiding this comment.
Approval waits on an engineering owner's decision on the iPhone stepper control, draft AST-094, and the new theme motion roles, platform adaptation condition and experimental subpaths; an engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Whatever the decision, these still need changing:
- At a 96 px field width, as the checkout-wizard template sets, the touch field shrinks to 18 px with a 0 px input, and an attached status runs under the control (input-fields FR2, FR8).
- NumberInput needs a
presentationvalue that keeps the column on iPhone (public-component-api INV16), and its iOS-only tracking code should load lazily or have its size reported and justified (component-style-authoring INV6). - AST-094 FR4 says touch physics are not themable while the new theme motion roles retune them, and
lintfails on two test lines. - At 32,340 authored lines, landing the predecessor pull requests first would leave this one with its stepper commit.
[Automated review]
| measures (0.55 for a scroll view and a sheet), and a release MUST pick its | ||
| rest point by projecting its velocity the way its UIKit counterpart does: | ||
| with a deceleration rate, or with a measured projection time. | ||
| - **FR4 — Physics are not themable in touch presentations.** A touch |
There was a problem hiding this comment.
FR4 says a touch presentation reads its motion from the table on every theme, and DEC-1 rejects tuning touch motion per theme, but this head lets defineTheme({motion}) retune the same table springs: motion/animator.ts:1301-1302 resolves each spring through the theme, .changeset/touch-motion-theme-roles.md announces "Themes can retune touch motion", and animator.interaction.browser.spec.ts proves a theme changing springs.sheetPresent. One of the two needs to change before an owner can approve this record.
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [feat] NumberInput: on iPhone and iPad the number steppers sit beside the field as one control shaped like the iOS stepper: a 94 × 32 capsule with − on the left and + on the right in either writing direction, a divider between them, and the theme's neutral fill and icon colours. The pressed half darkens at once, and a finger can slide across the divider. The field gives up the control's width and a gap. On Android and other touch screens (Material has no stepper), with a mouse, and inside an InputGroup, the steppers stay in the field as before. |
There was a problem hiding this comment.
This changes NumberInput's default stepper presentation on iPhone and iPad, but names no option that restores the column. Current architecture:public-component-api INV16 asks for a presentation value a caller can choose to keep the fine-pointer surface, and spec:AST-017 FR3 makes that value the restoring input this changeset should name.
| import { | ||
| paintPress, | ||
| readPressPaint, | ||
| usePressTracker, |
There was a problem hiding this comment.
This import brings the gesture host, press tracker and stepper tracking into every NumberInput import, though they run only in the iOS touch idiom: an esbuild bundle of the NumberInput entry grows from 51,228 to 71,289 bytes gzip. Current architecture:component-style-authoring INV6 flags a coarse-pointer-only feature over 2 KB gzip and asks for a lazy load or a reported, justified cost.
e272f77 to
4bda068
Compare
|
On the size note: the diff is the stack, not this change. Each predecessor is its own open pull request, listed in merge order at the end of the description, ending with #8205. This pull request's own change is its last commit, about 1.4k lines with tests; once its predecessors merge, the diff is that commit alone. |
|
Posted by Cindy's agent on her behalf. Parts of this PR re-implement behavior that already exists, or is being specified, as a shared part of core. Converging keeps one implementation per behavior:
This is about where the behavior lives, not about the PR's intent. |
|
Agreed: one implementation.
|
There was a problem hiding this comment.
Approval waits on an engineering owner's decision on the iPhone stepper capsule, draft AST-094, and the stack's theme motion roles, platform adaptation condition and experimental subpaths: an engineering owner needs to approve the draft or identify the governing spec, recording it by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. The narrow-field collapse, the status overlap and the lint errors are fixed on this head. Whatever the decision, these still need changing:
- The touch resolver's own 744 px compact width contradicts AST-012 FR9, which fixes compact at the theme's
mdpoint or 500 px tall and names this resolver. - NumberInput's import grows by about 20 KB gzip of touch-press code with no size report; component-style-authoring INV6 asks for the figures and a lazy load or a justification.
- AST-094 FR1, FR4 and FR13 contradict the code they would govern (theme motion roles, NumberInput's own gate and sizes), and FR7 cites an AST-090 that does not exist.
- At 32,730 authored lines, landing the predecessors first would leave this pull request with its stepper commit.
[Automated review]
| export const COMPACT_QUERY = '(width < 744px), (height < 500px)'; | ||
|
|
||
| /** Both: presentations such as a sheet in place of a popover. */ | ||
| export const COMPACT_TOUCH_QUERY = | ||
| '(width < 744px) and (pointer: coarse), (height < 500px) and (pointer: coarse)'; |
There was a problem hiding this comment.
These numbers contradict current AST-012 FR9: a screen is compact "while its viewport width is below the effective md point (FR1) or its viewport height is below 500", the theme exports that one definition, and "the web touch presentation resolver" is named among the resolvers that use it, keeping "no size numbers of its own" (also public-component-api INV17). With 744px, an iPad mini in portrait and other 744–767 px touch screens get popovers from the adaptive menus and Selector where the theme says sheet, and a theme that moves md is ignored. After rebasing, build these queries from the theme's compact definition (compactDefinition on main).
There was a problem hiding this comment.
The resolver belongs to the base (#8062, folded into #8076). At #8076 a71f4e5, which this PR now stacks on, its hook reads compact from the theme's md point, and the 744 is gone; the static media constant stays the default, which is the resolver's own question. NumberInput keys on the coarse pointer alone and reads no compact size.
Android 15, Chrome 124 (emulator golden v2, recorded at 540 x 1200), on this push's heads (meta#8205 40690a8, meta#8218 7615c44): meta#8205 beside main (a tap steps once at the lift without focusing; a 3.45 s hold steps on UIKit's cadence to 10), and meta#8218 beside meta#8205 (the capsule: - then +, a 1.3 s hold, and a tap on + while Gboard is up). Every take passes the gap gate (no frame gap over 60 ms after the recorder warm-up); every frame reviewed; the bands start below Chrome's address bar.
4bda068 to
7615c44
Compare
…s once Where nothing that claims is attached, the host's touchend listener is passive and cannot cancel the engine's own click after a retargeted tap clicks what is drawn. The shared click guard swallows that late click whatever is attached; this case pins it, as asked on meta#8293.
meta#8360 now carries the review fixes of the foundation it holds. The two conflicts are this pull request's own files: - GestureHost.ts: meta#8360 counts the attachments that ask for an action group; here the fine-pointer group listener and the per-event recognizer set are gone (every pointer is served, FR43), so the merge keeps the count and drops those two. - GestureHost.test.ts: both sides added the same assertion to the second-finger case; the merge keeps this side's comment. meta#8360's action-group lifetime case counts a group's own pointerdown under a fine pointer, which this pull request replaces with one passive pointerdown on any element that uses a gesture (its own idle-listener case), so the merge leaves it out.
… attached, in this host's terms meta#8360's action-group lifetime case pins that an element stays a group while any attachment asks and stops with the last. Here every element that uses a gesture is served as a group under a fine pointer, so the same invariant reads as the element's one passive pointerdown: with two gestures attached, detaching either keeps it, and detaching the last removes it. It passes on this head.
meta#8360 carries this pull request's predecessors and the review fixes of the foundation it holds; this pull request's own diff is now its six commits (a second finger fails a one-finger pan wherever fingers come from, and the check reruns once that finger is delivered; a press's selection lock gets what it landed on; a pinch's second finger on another pan claims neither; the wider-band edge pan's real-touch proof; text). Two conflicts: - GestureHost.ts: the selection lock's target takes meta#8360's presented target, which is what the press landed on unless an element under it is moving, so it keeps this pull request's fix and meta#8293's correction. - .changeset/gesture-press-tracking.md: meta#8360's text, which names both writers of the press attribute.
ListItem declares --_hit-border-bottom (the divider's width, 0 on the last row) for the touch hit layer, and List.doc.mjs did not list it, so derivedVarRegistry.test.ts failed: "List has undocumented CSS vars in source: --_hit-border-bottom". It is now in List.doc.mjs theming.vars[] as a private var, and in VARS_WITHOUT_DERIVED_MAPPING with its reason: it is the ::after layer's geometry, which follows --border-width, not a property a theme author sets (as with --_thumbnail-hit-inset and --_input-clear-hit-inset). Red first: "List: all source vars are in doc file" failed on the change before this one; it passes now (122 of 122).
…esture host's subscriptions The gesture host subscribes no click: the one cancelling click subscriber is the click guard's, added at a window's first arm and kept. The release note, the stream's header and the gestures README listed click with the host's touchend and contextmenu; they now name the guard's own.
… lift and the browser's menu, keeps a kept press's finger, and goes when the page hides From the review of this pull request, three cases fail on its head: - an armed press installed the page listeners without the claiming ones, so a claimed finger's touchend stayed passive and the browser's menu opened during a claimed long press (spec:AST-094 FR21); - a kept press got its blocking touchmove during an armed press, but its moves were cancelled only where the primary pointer is coarse, so a held stepper on a touchscreen laptop lost its finger (FR11, DEC-21); - a page hidden mid-press cancelled the press but kept the page's listeners until the next lift anywhere (FR24).
…lift and the browser's menu, keeps a kept press's finger, and goes when the page hides - A finger's or a pen's armed press now brings the claiming listeners (the cancelable touchend and the contextmenu canceller) with the page listeners, as wherever the host installs, so a claimed lift clicks nothing and a claimed hold opens no browser menu (spec:AST-094 FR21). A mouse's press needs neither and adds neither. - A kept press keeps its finger under any pointer: its target blocks and its moves are cancelled once its press is delivered, not only where the primary pointer is coarse (FR11, DEC-21). - A page hidden mid-press releases the armed press as a blur does, so the page's listeners go with the cancelled press (FR24).
…'s selection An action group's press is a long press with no minimum duration, so it claims at the press's pointerdown, before any movement. The drag selection hold then cleared the page's selection, so a plain click on a group's button left its Copy or Cut with nothing to act on. The case fails on this head: the selection is gone after the pointerdown.
…age's selection The drag selection hold cleared the page's selection when any mouse or pen contact was claimed, but an action group's press claims at its pointerdown, before it has moved or selected anything, so a plain click on a group's button left its Copy or Cut with nothing to act on. A claim before any movement now keeps the page's selection: the hold still refuses a new one, and puts the page's back over a selection that appears while it runs. A claim after movement clears what its drag selected, as before (spec:AST-094 FR41). The case before the new one now waits for its hold to end a task after the lift, so no hold outlives that test.
…t every pointer does - GestureHost's header, the gestures README and coarsePointer.ts no longer describe the coarse-only host: a finger or a pen takes the touch-down lock under any primary pointer, a kept press's finger is kept during a fine pointer's press too, and recognizers attach for every pointer while the page listeners last a press (spec:AST-094 FR19, FR24, FR41, FR43). - The fine-pointer selection case asserted the replaced rule and passed only because it read iOS WebKit's inline lock without iOS WebKit: it now asserts the lock a fine pointer's finger and pen take, and that a mouse takes none. - FR41's sentence and its verification row say that a press claimed at its own pointerdown, before it moves, keeps the page's selection, while a drag that moved before its claim loses what it selected.
… name their measured causes The hold proofs that need a tap's click skip in Playwright's WebKit with a reason that blamed this story. Measured against a plain button: that WebKit synthesizes no click after a scripted touch on any element, while its own tap path clicks; these proofs script the touch, so the reason now says that. The steppers cancel a touch's pointerdown, not only its mousedown. Canceling the mousedown alone was tried: iOS Safari, Chromium and WebKit's tap path then all step on the click, but Chrome on Android focuses a held stepper at its long press (focusin about 500 ms in, measured on the emulator), which would blur an editing field. So the cancel stays, and the comments name why; WebKit's Linux build, outside spec:AST-013's support, sends no click after a tap there.
…erdown name their measured causes)
…cause Its reason blamed this story. Measured: WebKit's Linux build clicks a plain button after a tap, but sends no click once the touch's pointerdown is canceled, unlike iOS Safari and Chrome. The steppers cancel it because Chrome on Android otherwise focuses a held stepper at its long press (measured on the emulator), so the proof runs in Chromium and on the iOS simulator; that engine is outside spec:AST-013's support. The comment that documents open() moves back above it.
…ion, in the present tense The sentence said a drag that moved before its claim loses the selection it started, but the hold clears the page's selection then, whatever made it; and an unclaimed mouse drag kept "today's" behavior, a phrase that ages. It now says a claim after movement clears the page's selection, a press claimed at its own pointerdown keeps it, and an unclaimed mouse drag keeps its released behavior.
This pull request now sits on meta#8665, so a stack carrying it and meta#8665's other dependants (meta#8676) needs no second GestureHost resolution. Two hunks in GestureHost.ts's pointerdown, both this pull request's: - the second-finger check where fingers come from pointer events stays, followed by meta#8665's rule that a right click, a pen's barrel button or a macOS ctrl-click starts no press; - the check that runs again once the new finger is delivered stays, followed by meta#8665's selection lock call, which no longer takes the window.
…events, as a browser sends them The gesture host now reads a finger from its pointer events once it has seen the pointerdown (meta#8336, through meta#8360). This suite's touch helper sent a move or a lift as a touch event alone, which no browser does, so the two slide tests lost the finger and the touched half's paint stayed on. The helper now sends pointermove or pointerup first, as a browser does; the browser proofs, which use real input, pass unchanged.
…as the repeat arms it Under a loaded machine one WebKit run of "a slide onto the other half carries the paint and the repeat" failed with its third step 68 ms off the schedule. The schedule was timed from touch-down, but the hold starts from the press's delivery and the repeat arms each beat from the moment its step ran (stepperTracking.ts). So a slow touch dispatch, or one late beat, moved every later step, and the proof charged it to each of them. Instrumented over repeated runs at load 83 to 105, WebKit's first step sat up to 38 ms after touch-down plus 500 ms, while every later interval stayed within 17 ms of its beat. Each step is now checked against its own beat: the first 500 ms after the press's first paint, which is written when the hold is armed, and each later one its interval after the step before. The leave-and-return proof's resumed beat is timed from the second step the same way. The slack per step is unchanged.
There was a problem hiding this comment.
Approval waits on an engineering owner's decision: this head amends the current component:NumberInput record (FR4, FR5 and its Behavior and Steppers rows) for the touch stepper capsule, the hold-to-repeat and the focus rule, and the current spec:AST-094 FR41 for claimed mouse and pen drags, while the capsule also conflicts with spec:AST-094 FR42 and DEC-20, which keep control sizes unchanged under touch and reach 44 px through invisible hit areas. An engineering owner records the decision on these amendments by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner); keeping the capsule also needs FR42 amended on main, or the capsule gives way to an invisible hit area or the hidden steppers proposed in #8015, and the new --size-touch-target theme token needs a maintainer to provide or identify its governing spec. Whatever the decision, these changes are still needed:
- Reduce the diff to this change by landing or rebasing onto its predecessors (it carries 39,369 authored lines today), and move the
astryxStylexdefault change out of it. - Name in FR4 the ramp and tracking-area departures from UIStepper's measured values, update the record's contract summary, and name the horizontal-labels FormLayout among the layouts that keep the column.
- Add real-device evidence for the iOS Safari and Android Chrome claims, fine-pointer before-and-after evidence for the mouse hold and a justification for the touch CSS over 1 KB, and say what assistive technology reads during a hold.
[Automated review]
| | 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 a stepper press step by one grid position and commit at once. A held stepper repeats once after 0.5 s, then on a ramp whose interval starts at 250 ms and shortens to 0.9 of the last, down to 50 ms; the input shows each step, and the press commits once, at its release. Moving off the stepper pauses the repeat and moving back resumes it with no immediate step; sliding onto the other stepper carries it on; a held press selects no text and opens no callout (`spec:AST-094` FR41). 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. | UIStepper's 0.5 s delay and 50 ms floor, measured on the iOS 26.2 simulator; `spec:AST-094` FR41; current tests | |
There was a problem hiding this comment.
FR4's basis cites only UIStepper's measured 0.5 s delay and 50 ms floor, but the 250 ms × 0.9 ramp, the pause off the stepper and the 70 px tracking area are marked “GUESS — proposed, pending owner review” in motion/uikit/stepper.ts and NumberInputTouch.tsx. spec:AST-094 FR2 asks a departure to name its date and the measurement it departs from; please name them here (UIStepper measured 500 ms between its first six repeats) or play the measured cadence.
| | 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 a stepper press step by one grid position and commit at once. A held stepper repeats once after 0.5 s, then on a ramp whose interval starts at 250 ms and shortens to 0.9 of the last, down to 50 ms; the input shows each step, and the press commits once, at its release. Moving off the stepper pauses the repeat and moving back resumes it with no immediate step; sliding onto the other stepper carries it on; a held press selects no text and opens no callout (`spec:AST-094` FR41). 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. | UIStepper's 0.5 s delay and 50 ms floor, measured on the iOS 26.2 simulator; `spec:AST-094` FR41; current tests | | ||
| | FR5 | With `hasNumberSteppers`, increment and decrement buttons render at the end of the control, stacked. Where the primary pointer is coarse they render beside the control instead, as one capsule of two side-by-side halves at the field's element height, − at the inline start and + at the inline end, unless the field sits in an InputGroup or its box is narrower than 170 px. They are outside the tab order, are named with the localized increment and decrement phrases and the label, never take focus themselves, and expose `aria-disabled` when stepping that way cannot move or the field is disabled or read-only. A mouse press, and a pen or pointerless press where the primary pointer is fine, moves focus to the input; a touch press, and a pen or pointerless press where the primary pointer is coarse, leaves focus where it is, so an input being edited stays focused and a tap raises no on-screen keyboard. | `spec:AST-085` FR1, FR2; UIKit's UIStepper never becomes first responder; current tests | |
There was a problem hiding this comment.
The capsule needs a Field box, and a horizontal-labels FormLayout renders none (Field/fieldParts.ts:129), so there the column stays under a finger. Please name that layout beside the InputGroup here, in NumberInput.doc.mjs and in the changeset.
| | Behavior | A text-backed `spinbutton`. Typing edits a draft that commits only on blur or Enter, parsed for the locale and clamped to the range. Arrow keys, the wheel, and a stepper press step on a grid and commit at once; a held stepper repeats and commits once, at its release. | | ||
| | End-user impact | People can type or paste numbers the way their locale and spreadsheets write them, and get told at once when an entry is invalid. Keyboard and screen-reader users can reach a disabled field. Read-only is not announced. | | ||
| | Builder impact | None. | | ||
| | Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. The read-only state is not exposed to assistive technology, and the clear button scales under reduced motion; both are listed under Current-state impact. | |
There was a problem hiding this comment.
This row (and “Public contract: None changes.”, “Builder impact: None.” and the compatibility class at lines 72–73) still says the record changes nothing, while FR4 and FR5 now add the coarse-pointer capsule, the hold with its release commit and the focus rule. Please say what callers notice.
|
Closing as requested: later decisions replaced this pull request. The branch stays. |
Depends on #8205. Review only this PR's change: vjeux/astryx@699d690...bcb5f28
User impact
On a phone, NumberInput's steppers are two stacked 15 px chevrons at the end of the field, too small for a finger (WCAG 2.5.8 asks for 24 px).
What led to it: Astryx's touch-presentation work (
spec:AST-094) matches its components to native iOS controls on phones. The native stepper, measured on the iOS simulator, is a capsule of two side-by-side halves beside its field.Options: a larger touch area for the column would overlap the field, and #8241 leaves spin buttons out of touch-target growth; hiding the steppers on phones (#8015) leaves only the keyboard. Chosen: under a finger only, the same markup becomes the iOS-shaped capsule, − and +, each half 47 × 32 px. A mouse, or a field under 170 px, keeps the column.
Visual authority
design:bidirectionalityDR3 andfamily:input-fields' field sizes;component:NumberInputFR5 and its Steppers row, amended here.Proposed for owner review (UIKit's shape,
motion/uikit/stepper.ts): iOS's 13 pt − and + strokes instead of registry icons; the capsule 8 px past the input box, which gives up 102 px; theme colours (fill--color-neutral, divider--color-track, glyphs--color-icon-primary, pressed--color-overlay-pressed); the column kept inside an InputGroup.Open owner question: open #8015, marked owner-requested, hides the steppers on phones, while this PR shows a capsule and #8205 adds a hold. Which direction should NumberInput take on phones? #8173 works with either.
Testing
On a phone, open Storybook's NumberInput "With number steppers" story:
Unit and browser tests check the sizes, mirroring, forced colours and taps. Two node checks fail through this stack's bases, not this change: a bundle check that #8671 replaces, and a List row check that #8085 fixes.
Before and after
Recorded on the iOS 26.2 simulator and the Android emulator.
State matrix
Light and dark use tokens; RTL mirrors; a half at its bound greys its glyph; forced colours keep CanvasText glyphs, divider and outline;
sm/md/lgare 28, 32 and 36 px tall; only the pressed half darkens; an attached status ends with the input box.Size (the PR size report's measure, gzip): NumberInput 76.6 → 78.1 KB; CSS +1.09 KB.
Scope
Writing rubric results
R1 narration, R2 research, R4–R6: none in the amended rows. R3 evidence: the amended rows cite current source and tests.