@astryxdesign/core 0.7.5-canary.fd0aba8 → 0.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +201 -0
- package/dist/BottomSheet/useSheetGestures.d.ts +0 -12
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
- package/dist/BottomSheet/useSheetGestures.js +3 -30
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +8 -33
- package/dist/DropdownMenu/lazyMenuBottomSheet.d.ts +1 -10
- package/dist/DropdownMenu/lazyMenuBottomSheet.d.ts.map +1 -1
- package/dist/DropdownMenu/lazyMenuBottomSheet.js +2 -16
- package/dist/NumberInput/NumberInput.d.ts +1 -3
- package/dist/NumberInput/NumberInput.d.ts.map +1 -1
- package/dist/NumberInput/NumberInput.js +3 -3
- package/dist/astryx.css +0 -2
- package/dist/utils/focusReturn.d.ts +1 -17
- package/dist/utils/focusReturn.d.ts.map +1 -1
- package/dist/utils/focusReturn.js +4 -11
- package/dist/utils/interactionModality.d.ts +2 -11
- package/dist/utils/interactionModality.d.ts.map +1 -1
- package/dist/utils/interactionModality.js +5 -58
- package/package.json +4 -4
- package/src/BottomSheet/BottomSheet.test.tsx +0 -46
- package/src/BottomSheet/useSheetGestures.test.ts +0 -52
- package/src/BottomSheet/useSheetGestures.ts +6 -37
- package/src/DateTimeInput/DateTimeInput.spec.md +48 -71
- package/src/Dialog/Dialog.test.tsx +2 -216
- package/src/Dialog/Dialog.tsx +8 -41
- package/src/DropdownMenu/lazyMenuBottomSheet.ts +6 -23
- package/src/NumberInput/NumberInput.doc.mjs +7 -13
- package/src/NumberInput/NumberInput.spec.md +37 -59
- package/src/NumberInput/NumberInput.tsx +3 -13
- package/src/utils/focusReturn.ts +6 -17
- package/src/utils/interactionModality.test.ts +1 -45
- package/src/utils/interactionModality.ts +5 -64
- package/src/Dialog/__tests__/DialogSecondDocument.a11y.browser.spec.ts +0 -202
- package/src/DropdownMenu/lazyMenuBottomSheet.test.ts +0 -53
- package/src/NumberInput/__tests__/NumberInput.coarse-pointer.a11y.chromium.spec.ts +0 -102
|
@@ -13,14 +13,7 @@ import {useState, type SyntheticEvent} from 'react';
|
|
|
13
13
|
import {readFileSync} from 'node:fs';
|
|
14
14
|
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
|
|
15
15
|
import type {MockInstance} from 'vitest';
|
|
16
|
-
import {
|
|
17
|
-
render,
|
|
18
|
-
screen,
|
|
19
|
-
fireEvent,
|
|
20
|
-
within,
|
|
21
|
-
cleanup,
|
|
22
|
-
} from '@testing-library/react';
|
|
23
|
-
import {createPortal} from 'react-dom';
|
|
16
|
+
import {render, screen, fireEvent} from '@testing-library/react';
|
|
24
17
|
import {
|
|
25
18
|
Dialog,
|
|
26
19
|
dialogFullscreenSafeAreaPaddingContract,
|
|
@@ -28,10 +21,7 @@ import {
|
|
|
28
21
|
} from './Dialog';
|
|
29
22
|
import {DialogHeader} from './DialogHeader';
|
|
30
23
|
import {defineTheme, generateThemeCSS} from '../theme';
|
|
31
|
-
import {
|
|
32
|
-
__resetInteractionModalityForTest,
|
|
33
|
-
getInteractionModality,
|
|
34
|
-
} from '../utils/interactionModality';
|
|
24
|
+
import {__resetInteractionModalityForTest} from '../utils/interactionModality';
|
|
35
25
|
|
|
36
26
|
function generateThemeTestCSS(
|
|
37
27
|
theme: Parameters<typeof generateThemeCSS>[0],
|
|
@@ -565,210 +555,6 @@ describe('Dialog', () => {
|
|
|
565
555
|
}).not.toThrow();
|
|
566
556
|
expect(dialog).not.toHaveAttribute('open');
|
|
567
557
|
});
|
|
568
|
-
|
|
569
|
-
describe('in a second document', () => {
|
|
570
|
-
// An app can render a Dialog into a same-origin iframe or a Document
|
|
571
|
-
// Picture-in-Picture window through a portal. The control that opens it
|
|
572
|
-
// is focused in that document; the page's document reports only the
|
|
573
|
-
// iframe element.
|
|
574
|
-
let frame: HTMLIFrameElement;
|
|
575
|
-
let secondDocument: Document;
|
|
576
|
-
|
|
577
|
-
beforeEach(() => {
|
|
578
|
-
frame = document.createElement('iframe');
|
|
579
|
-
document.body.appendChild(frame);
|
|
580
|
-
secondDocument = frame.contentDocument!;
|
|
581
|
-
// The showModal/close stand-ins above, on the second window's own
|
|
582
|
-
// prototype.
|
|
583
|
-
const {prototype} = secondDocument.defaultView!.HTMLDialogElement;
|
|
584
|
-
prototype.showModal = HTMLDialogElement.prototype.showModal;
|
|
585
|
-
prototype.close = HTMLDialogElement.prototype.close;
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
afterEach(() => {
|
|
589
|
-
// Unmount before the frame goes, or React removes nodes from a
|
|
590
|
-
// document that no longer exists.
|
|
591
|
-
cleanup();
|
|
592
|
-
frame.remove();
|
|
593
|
-
});
|
|
594
|
-
|
|
595
|
-
function SecondDocumentDialog({isOpen}: {isOpen: boolean}) {
|
|
596
|
-
return createPortal(
|
|
597
|
-
<>
|
|
598
|
-
<button type="button">Open dialog</button>
|
|
599
|
-
<Dialog isOpen={isOpen} onOpenChange={() => {}} aria-label="Review">
|
|
600
|
-
<button type="button">Inside</button>
|
|
601
|
-
</Dialog>
|
|
602
|
-
</>,
|
|
603
|
-
secondDocument.body,
|
|
604
|
-
);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
function PageOpensSecondDocumentDialog({isOpen}: {isOpen: boolean}) {
|
|
608
|
-
return (
|
|
609
|
-
<>
|
|
610
|
-
<button type="button">Open from the page</button>
|
|
611
|
-
{createPortal(
|
|
612
|
-
<Dialog
|
|
613
|
-
isOpen={isOpen}
|
|
614
|
-
onOpenChange={() => {}}
|
|
615
|
-
aria-label="Review">
|
|
616
|
-
<button type="button">Inside</button>
|
|
617
|
-
</Dialog>,
|
|
618
|
-
secondDocument.body,
|
|
619
|
-
)}
|
|
620
|
-
</>
|
|
621
|
-
);
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
function SecondDocumentFieldDialog({isOpen}: {isOpen: boolean}) {
|
|
625
|
-
return createPortal(
|
|
626
|
-
<>
|
|
627
|
-
<input aria-label="Search" />
|
|
628
|
-
<Dialog isOpen={isOpen} onOpenChange={() => {}} aria-label="Review">
|
|
629
|
-
<button type="button">Inside</button>
|
|
630
|
-
</Dialog>
|
|
631
|
-
</>,
|
|
632
|
-
secondDocument.body,
|
|
633
|
-
);
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
function SecondDocumentDialogs({count}: {count: number}) {
|
|
637
|
-
return createPortal(
|
|
638
|
-
<>
|
|
639
|
-
{count > 0 && (
|
|
640
|
-
<Dialog isOpen={false} onOpenChange={() => {}} aria-label="First">
|
|
641
|
-
First
|
|
642
|
-
</Dialog>
|
|
643
|
-
)}
|
|
644
|
-
{count > 1 && (
|
|
645
|
-
<Dialog
|
|
646
|
-
isOpen={false}
|
|
647
|
-
onOpenChange={() => {}}
|
|
648
|
-
aria-label="Second">
|
|
649
|
-
Second
|
|
650
|
-
</Dialog>
|
|
651
|
-
)}
|
|
652
|
-
</>,
|
|
653
|
-
secondDocument.body,
|
|
654
|
-
);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
it('returns focus to the control that opened it, in its own document', () => {
|
|
658
|
-
const {rerender} = render(<SecondDocumentDialog isOpen={false} />);
|
|
659
|
-
const view = within(secondDocument.body);
|
|
660
|
-
const opener = view.getByRole('button', {name: 'Open dialog'});
|
|
661
|
-
opener.focus();
|
|
662
|
-
expect(document.activeElement).toBe(frame);
|
|
663
|
-
|
|
664
|
-
rerender(<SecondDocumentDialog isOpen={true} />);
|
|
665
|
-
view.getByRole('button', {name: 'Inside', hidden: true}).focus();
|
|
666
|
-
|
|
667
|
-
rerender(<SecondDocumentDialog isOpen={false} />);
|
|
668
|
-
expect(opener).toHaveFocus();
|
|
669
|
-
});
|
|
670
|
-
|
|
671
|
-
it('returns focus to a control in the page that opened it', () => {
|
|
672
|
-
const {rerender} = render(
|
|
673
|
-
<PageOpensSecondDocumentDialog isOpen={false} />,
|
|
674
|
-
);
|
|
675
|
-
const opener = screen.getByRole('button', {name: 'Open from the page'});
|
|
676
|
-
opener.focus();
|
|
677
|
-
|
|
678
|
-
rerender(<PageOpensSecondDocumentDialog isOpen={true} />);
|
|
679
|
-
within(secondDocument.body)
|
|
680
|
-
.getByRole('button', {name: 'Inside', hidden: true})
|
|
681
|
-
.focus();
|
|
682
|
-
|
|
683
|
-
rerender(<PageOpensSecondDocumentDialog isOpen={false} />);
|
|
684
|
-
expect(opener).toHaveFocus();
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
it('takes the ring from the last input in its own document', () => {
|
|
688
|
-
__resetInteractionModalityForTest();
|
|
689
|
-
const {rerender} = render(<SecondDocumentDialog isOpen={false} />);
|
|
690
|
-
const opener = within(secondDocument.body).getByRole('button', {
|
|
691
|
-
name: 'Open dialog',
|
|
692
|
-
});
|
|
693
|
-
// The page's last input was a pointer press (say, on the control that
|
|
694
|
-
// popped the window out); the dialog then closes from the keyboard in
|
|
695
|
-
// its own document.
|
|
696
|
-
fireEvent.pointerDown(document.body);
|
|
697
|
-
opener.focus();
|
|
698
|
-
rerender(<SecondDocumentDialog isOpen={true} />);
|
|
699
|
-
fireEvent.keyDown(secondDocument.body, {key: 'Escape'});
|
|
700
|
-
const focus = vi.spyOn(
|
|
701
|
-
secondDocument.defaultView!.HTMLElement.prototype,
|
|
702
|
-
'focus',
|
|
703
|
-
);
|
|
704
|
-
|
|
705
|
-
rerender(<SecondDocumentDialog isOpen={false} />);
|
|
706
|
-
expect(opener).toHaveFocus();
|
|
707
|
-
expect(
|
|
708
|
-
focus.mock.calls.filter(
|
|
709
|
-
(_call, index) => focus.mock.contexts[index] === opener,
|
|
710
|
-
),
|
|
711
|
-
).toEqual([[]]);
|
|
712
|
-
focus.mockRestore();
|
|
713
|
-
});
|
|
714
|
-
|
|
715
|
-
it('keeps plain focus on a text field that opened it after a pointer close', () => {
|
|
716
|
-
const {rerender} = render(<SecondDocumentFieldDialog isOpen={false} />);
|
|
717
|
-
const field = within(secondDocument.body).getByRole('textbox', {
|
|
718
|
-
name: 'Search',
|
|
719
|
-
});
|
|
720
|
-
field.focus();
|
|
721
|
-
rerender(<SecondDocumentFieldDialog isOpen={true} />);
|
|
722
|
-
const inside = within(secondDocument.body).getByRole('button', {
|
|
723
|
-
name: 'Inside',
|
|
724
|
-
hidden: true,
|
|
725
|
-
});
|
|
726
|
-
inside.focus();
|
|
727
|
-
fireEvent.pointerDown(inside);
|
|
728
|
-
// The field's own window's prototype: a field there is never an
|
|
729
|
-
// instance of the page's constructors.
|
|
730
|
-
const {prototype} = secondDocument.defaultView!.HTMLElement;
|
|
731
|
-
const focus = vi.spyOn(prototype, 'focus');
|
|
732
|
-
const blur = vi.spyOn(prototype, 'blur');
|
|
733
|
-
|
|
734
|
-
rerender(<SecondDocumentFieldDialog isOpen={false} />);
|
|
735
|
-
expect(field).toHaveFocus();
|
|
736
|
-
expect(
|
|
737
|
-
focus.mock.calls.filter(
|
|
738
|
-
(_call, index) => focus.mock.contexts[index] === field,
|
|
739
|
-
),
|
|
740
|
-
).toEqual([[]]);
|
|
741
|
-
expect(blur.mock.contexts).not.toContain(field);
|
|
742
|
-
focus.mockRestore();
|
|
743
|
-
blur.mockRestore();
|
|
744
|
-
});
|
|
745
|
-
|
|
746
|
-
it('tracks its document while any Dialog there is mounted, and releases it after the last', () => {
|
|
747
|
-
const removeEventListener = vi.spyOn(
|
|
748
|
-
secondDocument,
|
|
749
|
-
'removeEventListener',
|
|
750
|
-
);
|
|
751
|
-
const removedModalityListeners = () =>
|
|
752
|
-
removeEventListener.mock.calls
|
|
753
|
-
.map(([type]) => type)
|
|
754
|
-
.filter(type => type === 'pointerdown' || type === 'keydown')
|
|
755
|
-
.sort();
|
|
756
|
-
const {rerender} = render(<SecondDocumentDialogs count={2} />);
|
|
757
|
-
fireEvent.pointerDown(secondDocument.body);
|
|
758
|
-
expect(getInteractionModality(secondDocument)).toBe('pointer');
|
|
759
|
-
|
|
760
|
-
rerender(<SecondDocumentDialogs count={1} />);
|
|
761
|
-
fireEvent.keyDown(secondDocument.body, {key: 'Tab'});
|
|
762
|
-
expect(getInteractionModality(secondDocument)).toBe('keyboard');
|
|
763
|
-
expect(removedModalityListeners()).toEqual([]);
|
|
764
|
-
|
|
765
|
-
rerender(<SecondDocumentDialogs count={0} />);
|
|
766
|
-
expect(removedModalityListeners()).toEqual(['keydown', 'pointerdown']);
|
|
767
|
-
fireEvent.pointerDown(secondDocument.body);
|
|
768
|
-
expect(getInteractionModality(secondDocument)).toBe('keyboard');
|
|
769
|
-
removeEventListener.mockRestore();
|
|
770
|
-
});
|
|
771
|
-
});
|
|
772
558
|
});
|
|
773
559
|
|
|
774
560
|
// jsdom draws no focus ring, so these prove only which focus call Dialog
|
package/src/Dialog/Dialog.tsx
CHANGED
|
@@ -76,11 +76,7 @@ import {DialogContext} from './DialogContext';
|
|
|
76
76
|
import {themeProps} from '../utils/themeProps';
|
|
77
77
|
import type {DialogVariantMap} from './index';
|
|
78
78
|
import {focusOutlineProps} from '../utils/focusOutline.stylex';
|
|
79
|
-
import {
|
|
80
|
-
getInteractionModality,
|
|
81
|
-
trackInteractionModalityIn,
|
|
82
|
-
useInteractionModalityTracking,
|
|
83
|
-
} from '../utils/interactionModality';
|
|
79
|
+
import {useInteractionModalityTracking} from '../utils/interactionModality';
|
|
84
80
|
import {focusForLastInput} from '../utils/focusReturn';
|
|
85
81
|
import {ModalAnnounceProvider} from '../hooks/ModalAnnounceProvider';
|
|
86
82
|
|
|
@@ -95,11 +91,8 @@ function getDialogDirection(
|
|
|
95
91
|
distance = 16,
|
|
96
92
|
): {x: number; y: number} {
|
|
97
93
|
const rect = triggerEl.getBoundingClientRect();
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const view = triggerEl.ownerDocument.defaultView ?? window;
|
|
101
|
-
const dx = rect.left + rect.width / 2 - view.innerWidth / 2;
|
|
102
|
-
const dy = rect.top + rect.height / 2 - view.innerHeight / 2;
|
|
94
|
+
const dx = rect.left + rect.width / 2 - window.innerWidth / 2;
|
|
95
|
+
const dy = rect.top + rect.height / 2 - window.innerHeight / 2;
|
|
103
96
|
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
104
97
|
return {
|
|
105
98
|
x: Math.round((dx / dist) * distance),
|
|
@@ -541,18 +534,6 @@ export function Dialog({
|
|
|
541
534
|
|
|
542
535
|
useInteractionModalityTracking();
|
|
543
536
|
|
|
544
|
-
// Input in a second document (a same-origin iframe, a Document
|
|
545
|
-
// Picture-in-Picture window) never reaches the page's listeners, so the
|
|
546
|
-
// dialog's own document keeps its own last input for the focus return while
|
|
547
|
-
// this Dialog is mounted, from before it opens until it unmounts.
|
|
548
|
-
useEffect(() => {
|
|
549
|
-
const ownerDocument = dialogRef.current?.ownerDocument;
|
|
550
|
-
if (ownerDocument == null || ownerDocument === document) {
|
|
551
|
-
return undefined;
|
|
552
|
-
}
|
|
553
|
-
return trackInteractionModalityIn(ownerDocument);
|
|
554
|
-
}, [isInline]);
|
|
555
|
-
|
|
556
537
|
// This runs in a layout effect so the capture lands before any descendant's
|
|
557
538
|
// passive mount effect: all layout effects run before any passive effect,
|
|
558
539
|
// while a child's passive effect runs before this parent's passive effect.
|
|
@@ -564,15 +545,7 @@ export function Dialog({
|
|
|
564
545
|
if (isInline || !isOpen) {
|
|
565
546
|
return;
|
|
566
547
|
}
|
|
567
|
-
|
|
568
|
-
// dialog into a second document (a same-origin iframe, a Document
|
|
569
|
-
// Picture-in-Picture window) through a portal, and a control there that
|
|
570
|
-
// opens it is focused in that document, which the page's document reports
|
|
571
|
-
// only as the iframe element, or not at all. A control in the page that
|
|
572
|
-
// opens such a dialog leaves focus in the page.
|
|
573
|
-
const dialogDocument = dialogRef.current?.ownerDocument ?? document;
|
|
574
|
-
const doc = dialogDocument.hasFocus() ? dialogDocument : document;
|
|
575
|
-
triggerElementRef.current = doc.activeElement as HTMLElement | null;
|
|
548
|
+
triggerElementRef.current = document.activeElement as HTMLElement | null;
|
|
576
549
|
}, [isOpen, isInline]);
|
|
577
550
|
|
|
578
551
|
// Derive dismissal behavior from purpose
|
|
@@ -594,7 +567,7 @@ export function Dialog({
|
|
|
594
567
|
// captured by the layout effect above, before any descendant effect
|
|
595
568
|
// could move focus.
|
|
596
569
|
const trigger = triggerElementRef.current;
|
|
597
|
-
if (trigger && trigger !==
|
|
570
|
+
if (trigger && trigger !== document.body) {
|
|
598
571
|
const dir = getDialogDirection(trigger);
|
|
599
572
|
dialog.style.setProperty('--dialog-dir-x', `${dir.x}px`);
|
|
600
573
|
dialog.style.setProperty('--dialog-dir-y', `${dir.y}px`);
|
|
@@ -618,15 +591,9 @@ export function Dialog({
|
|
|
618
591
|
if (dialog.open) {
|
|
619
592
|
dialog.close();
|
|
620
593
|
}
|
|
621
|
-
// Return focus to the element that opened the dialog
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
if (trigger) {
|
|
625
|
-
focusForLastInput(
|
|
626
|
-
trigger,
|
|
627
|
-
undefined,
|
|
628
|
-
getInteractionModality(trigger.ownerDocument),
|
|
629
|
-
);
|
|
594
|
+
// Return focus to the element that opened the dialog
|
|
595
|
+
if (triggerElementRef.current) {
|
|
596
|
+
focusForLastInput(triggerElementRef.current);
|
|
630
597
|
}
|
|
631
598
|
triggerElementRef.current = null;
|
|
632
599
|
}
|
|
@@ -15,11 +15,6 @@
|
|
|
15
15
|
* of pages whose dropdowns only open as anchored popovers. Each wrapper keeps
|
|
16
16
|
* the wrapped component's props and renders nothing until its module loads.
|
|
17
17
|
*
|
|
18
|
-
* The action list preloads at rest; a failed load is not kept, so the next
|
|
19
|
-
* preload or open asks the module loader again (a loader that retries then
|
|
20
|
-
* loads it; a browser does not fetch a native module again once its import
|
|
21
|
-
* failed).
|
|
22
|
-
*
|
|
23
18
|
* Neither wrapper renders on the server or in the render that hydrates server
|
|
24
19
|
* HTML (spec:AST-013 FR9): a lazy component that has not loaded yet would
|
|
25
20
|
* leave server markup incomplete and abort hydration. The closed sheet has no
|
|
@@ -52,28 +47,16 @@ const LazyMenuBottomSheet = lazy(async () =>
|
|
|
52
47
|
let actionListModule: typeof ActionListModule | null = null;
|
|
53
48
|
let actionListLoad: Promise<typeof ActionListModule> | null = null;
|
|
54
49
|
async function loadActionList(): Promise<typeof ActionListModule> {
|
|
55
|
-
actionListLoad ??= import('./MenuBottomSheetActionList').then(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
},
|
|
60
|
-
(error: unknown) => {
|
|
61
|
-
// A failed load (a dropped connection) is not kept: the next caller
|
|
62
|
-
// asks the module loader again.
|
|
63
|
-
actionListLoad = null;
|
|
64
|
-
throw error;
|
|
65
|
-
},
|
|
66
|
-
);
|
|
50
|
+
actionListLoad ??= import('./MenuBottomSheetActionList').then(module => {
|
|
51
|
+
actionListModule = module;
|
|
52
|
+
return module;
|
|
53
|
+
});
|
|
67
54
|
return actionListLoad;
|
|
68
55
|
}
|
|
69
56
|
|
|
70
|
-
/**
|
|
71
|
-
* Starts loading the action list; a sheet menu calls it at rest. A preload
|
|
72
|
-
* that fails is dropped, never an unhandled rejection: the first open asks the
|
|
73
|
-
* module loader again.
|
|
74
|
-
*/
|
|
57
|
+
/** Starts loading the action list; a sheet menu calls it at rest. */
|
|
75
58
|
export function preloadMenuBottomSheetActionList(): void {
|
|
76
|
-
loadActionList()
|
|
59
|
+
void loadActionList();
|
|
77
60
|
}
|
|
78
61
|
|
|
79
62
|
const LazyMenuBottomSheetActionList = lazy(async () =>
|
|
@@ -160,7 +160,7 @@ export const docs = {
|
|
|
160
160
|
name: 'hasNumberSteppers',
|
|
161
161
|
type: 'boolean',
|
|
162
162
|
description:
|
|
163
|
-
'Shows increment and decrement buttons at the end of the input
|
|
163
|
+
'Shows increment and decrement buttons at the end of the input.',
|
|
164
164
|
default: 'false',
|
|
165
165
|
},
|
|
166
166
|
{
|
|
@@ -226,14 +226,12 @@ export const docs = {
|
|
|
226
226
|
{
|
|
227
227
|
name: 'containerClassName',
|
|
228
228
|
type: 'string',
|
|
229
|
-
description:
|
|
230
|
-
'Class name for the field container. Same targeting as `containerXstyle`.',
|
|
229
|
+
description: 'Class name for the field container. Same targeting as `containerXstyle`.',
|
|
231
230
|
},
|
|
232
231
|
{
|
|
233
232
|
name: 'containerStyle',
|
|
234
233
|
type: 'CSSProperties',
|
|
235
|
-
description:
|
|
236
|
-
'Inline styles for the field container. Same targeting as `containerXstyle`.',
|
|
234
|
+
description: 'Inline styles for the field container. Same targeting as `containerXstyle`.',
|
|
237
235
|
},
|
|
238
236
|
{
|
|
239
237
|
name: 'controlXstyle',
|
|
@@ -476,8 +474,7 @@ export const docsZh = {
|
|
|
476
474
|
{
|
|
477
475
|
name: 'hasNumberSteppers',
|
|
478
476
|
type: 'boolean',
|
|
479
|
-
description:
|
|
480
|
-
'在精细指针设备上于输入框末尾显示递增和递减按钮。粗指针设备(例如触摸屏)上隐藏按钮;仍可直接输入数值或使用键盘步进。',
|
|
477
|
+
description: '是否在输入框末尾显示递增和递减按钮。',
|
|
481
478
|
default: 'false',
|
|
482
479
|
},
|
|
483
480
|
{
|
|
@@ -723,12 +720,10 @@ export const docsDense = {
|
|
|
723
720
|
],
|
|
724
721
|
},
|
|
725
722
|
propDescriptions: {
|
|
726
|
-
containerXstyle:
|
|
727
|
-
'StyleX styles for the field container; no effect in InputGroup or horizontal-labels FormLayout. No root xstyle/className/style.',
|
|
723
|
+
containerXstyle: 'StyleX styles for the field container; no effect in InputGroup or horizontal-labels FormLayout. No root xstyle/className/style.',
|
|
728
724
|
containerClassName: 'Class name for the field container.',
|
|
729
725
|
containerStyle: 'Inline styles for the field container.',
|
|
730
|
-
controlXstyle:
|
|
731
|
-
'StyleX styles for the visual control box (border, background, icons); not the native input.',
|
|
726
|
+
controlXstyle: 'StyleX styles for the visual control box (border, background, icons); not the native input.',
|
|
732
727
|
controlClassName: 'Class name for the visual control box.',
|
|
733
728
|
controlStyle: 'Inline styles for the visual control box.',
|
|
734
729
|
label: 'Label text (always rendered for accessibility).',
|
|
@@ -759,8 +754,7 @@ export const docsDense = {
|
|
|
759
754
|
'Formats committed value at rest; raw number is shown while editing.',
|
|
760
755
|
isWheelEnabled:
|
|
761
756
|
'Allow focused wheel gestures to step value. Defaults to true.',
|
|
762
|
-
hasNumberSteppers:
|
|
763
|
-
'Show increment + decrement buttons on fine pointers; hidden on coarse pointers. Defaults to false.',
|
|
757
|
+
hasNumberSteppers: 'Show increment + decrement buttons. Defaults to false.',
|
|
764
758
|
units: 'Units suffix (e.g. "%" or "GB").',
|
|
765
759
|
isIntegerOnly: 'Only allow integer values.',
|
|
766
760
|
hasClear:
|
|
@@ -6,8 +6,8 @@ id: component:NumberInput
|
|
|
6
6
|
authority: current
|
|
7
7
|
archive_reason: null
|
|
8
8
|
superseded_by: null
|
|
9
|
-
approved_by:
|
|
10
|
-
approved_at: 2026-10-
|
|
9
|
+
approved_by: cixzhang
|
|
10
|
+
approved_at: 2026-10-26
|
|
11
11
|
owners: [cixzhang]
|
|
12
12
|
review_triggers: [public-api, behavior, layout, theming, accessibility]
|
|
13
13
|
verified_by:
|
|
@@ -19,7 +19,6 @@ verified_by:
|
|
|
19
19
|
packages/core/src/NumberInput/numberParser.docblock.test.ts,
|
|
20
20
|
packages/core/src/NumberInput/__tests__/NumberInput.a11y.test.tsx,
|
|
21
21
|
packages/core/src/NumberInput/__tests__/NumberInput.a11y.chromium.spec.ts,
|
|
22
|
-
packages/core/src/NumberInput/__tests__/NumberInput.coarse-pointer.a11y.chromium.spec.ts,
|
|
23
22
|
packages/core/src/InputGroup/InputGroup.test.tsx,
|
|
24
23
|
]
|
|
25
24
|
modules: []
|
|
@@ -41,15 +40,15 @@ system_specs: [spec:AST-085, spec:AST-086, spec:AST-102]
|
|
|
41
40
|
|
|
42
41
|
## Contract at a glance
|
|
43
42
|
|
|
44
|
-
| Area | Contract
|
|
45
|
-
| ----------------------- |
|
|
46
|
-
| Public contract | NumberInput is a controlled numeric field with Field props, size, status, `min`, `max`, `step`, `isIntegerOnly`, `formatValue`, `units`, steppers, wheel stepping, clear, disabled and read-only states, and input-family part props.
|
|
47
|
-
| Behavior | A text-backed `spinbutton
|
|
48
|
-
| End-user impact |
|
|
49
|
-
| Builder impact |
|
|
50
|
-
| Compatibility/readiness |
|
|
51
|
-
| Review checks | Reject
|
|
52
|
-
| Governing rules |
|
|
43
|
+
| Area | Contract |
|
|
44
|
+
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
45
|
+
| Public contract | None changes. NumberInput is a controlled numeric field with Field props, size, status, `min`, `max`, `step`, `isIntegerOnly`, `formatValue`, `units`, steppers, wheel stepping, clear, disabled and read-only states, and the input-family part props. |
|
|
46
|
+
| 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 the steppers step on a grid and commit at once. |
|
|
47
|
+
| 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. |
|
|
48
|
+
| Builder impact | None. |
|
|
49
|
+
| 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. |
|
|
50
|
+
| Review checks | Reject an `onChange` for every keystroke, a commit of an unparsable draft, a step off the grid or past a bound, a disabled field that leaves the tab order or submits, or arrows that step during IME composition. |
|
|
51
|
+
| Governing rules | `family:input-fields` FR3, FR4, FR8–FR11; `spec:AST-085` FR1–FR3, FR6; `spec:AST-086` FR3, FR4; `architecture:internationalization`; `design:reduced-motion` DR1. |
|
|
53
52
|
|
|
54
53
|
This table is a review projection; the body below is authoritative.
|
|
55
54
|
|
|
@@ -64,16 +63,12 @@ Consumer usage lives in `NumberInput.doc.mjs`.
|
|
|
64
63
|
|
|
65
64
|
- Released default preserved: `yes`; `step={1}`, wheel stepping on, no
|
|
66
65
|
steppers, no clear, `size="md"`, `statusVariant="attached"`
|
|
67
|
-
- Compatibility class:
|
|
68
|
-
|
|
69
|
-
and keyboard stepping remain unchanged under `spec:AST-017` FR3's numeric-stepper
|
|
70
|
-
presentation rule
|
|
66
|
+
- Compatibility class: records shipped behavior; no public prop, default,
|
|
67
|
+
export, or DOM change
|
|
71
68
|
- Controlled/uncontrolled behavior: controlled only; `value` (`number`, `null`,
|
|
72
69
|
or `undefined`) is the committed value, and NumberInput keeps only an
|
|
73
70
|
uncommitted text draft of its own
|
|
74
|
-
- Migration decision:
|
|
75
|
-
input functionality remains available through direct entry and keyboard
|
|
76
|
-
stepping while compact visual stepper aids adapt to pointer precision
|
|
71
|
+
- Migration decision: none
|
|
77
72
|
|
|
78
73
|
Consumer migration instructions belong in consumer docs and release notes.
|
|
79
74
|
|
|
@@ -123,7 +118,7 @@ Consumer migration instructions belong in consumer docs and release notes.
|
|
|
123
118
|
| 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 |
|
|
124
119
|
| 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 |
|
|
125
120
|
| 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 |
|
|
126
|
-
| FR5 | With `hasNumberSteppers`, increment and decrement buttons render at the end of the control
|
|
121
|
+
| FR5 | With `hasNumberSteppers`, increment and decrement buttons render at the end of the control. They 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 |
|
|
127
122
|
| 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 |
|
|
128
123
|
| FR7 | `units` renders as secondary text after the input and is part of the input's description. | Current tests |
|
|
129
124
|
| 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 |
|
|
@@ -173,20 +168,19 @@ Consumer migration instructions belong in consumer docs and release notes.
|
|
|
173
168
|
- **AR2 — Invalid entry.** FR2's assertive region announces an invalid draft,
|
|
174
169
|
since an invalid draft reverts silently on blur (WCAG 3.3.1).
|
|
175
170
|
- **AR3 — Disabled.** FR9 satisfies `spec:AST-085` FR1, FR5, and FR6.
|
|
176
|
-
- **AR4 — Steppers.**
|
|
177
|
-
|
|
178
|
-
arrow keys provide keyboard stepping on every pointer type (FR4, FR5).
|
|
171
|
+
- **AR4 — Steppers.** Pointer-only aids; the arrow keys give keyboard users the
|
|
172
|
+
same stepping (FR4, FR5).
|
|
179
173
|
- **AR5 — Read-only exposure.** A read-only NumberInput MUST expose its
|
|
180
174
|
read-only state to assistive technology (WCAG 2.2 4.1.2). Implementation gap:
|
|
181
175
|
the spinbutton exposes no read-only state.
|
|
182
176
|
|
|
183
177
|
## Design relationships
|
|
184
178
|
|
|
185
|
-
| Anatomy or state | Design requirement
|
|
186
|
-
| ---------------- |
|
|
187
|
-
| Input | Numeric text; secondary color while the draft is invalid.
|
|
188
|
-
| Units | Secondary text after the number.
|
|
189
|
-
| Steppers | Stacked at the end
|
|
179
|
+
| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
|
|
180
|
+
| ---------------- | --------------------------------------------------------- | ------------------------ | -------------- | ------------------ |
|
|
181
|
+
| Input | Numeric text; secondary color while the draft is invalid. | Current source and tests | Prominent | FR2 |
|
|
182
|
+
| Units | Secondary text after the number. | Current source and tests | Supporting | FR7 |
|
|
183
|
+
| Steppers | Stacked increment and decrement at the end. | Current source and tests | Supporting | FR5, FR13 |
|
|
190
184
|
|
|
191
185
|
`design:reduced-motion` governs the clear button's press scale (FR8); no other
|
|
192
186
|
design record governs NumberInput. The control box carries `number-input`;
|
|
@@ -207,20 +201,19 @@ anatomy-theming map.
|
|
|
207
201
|
|
|
208
202
|
## Verification map
|
|
209
203
|
|
|
210
|
-
| Contract | Verification | Representative states
|
|
211
|
-
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
212
|
-
| FR1, AR1 | `NumberInput.test.tsx` spinbutton-attribute suite; `NumberInput.a11y.chromium.spec.ts` | Bounded, unbounded, formatted
|
|
213
|
-
| FR2, AR2 | `numberParser.test.ts`; `numberParser.docblock.test.ts`; `NumberInput.test.tsx` invalid-feedback suite | Locales, grouping, invalid text
|
|
214
|
-
| FR3, ORD1 | `numberInputCommit.test.ts`; `NumberInput.test.tsx` validation, commit-policy, clamping, keyboard-clearing suites | Blur, Enter, empty, clamp, inverted range
|
|
215
|
-
| FR4, FR5, ORD2, AR4, PR1 | `NumberInput.test.tsx` stepping and steppers suites; wheel-listener cases | Keys, wheel, steppers; bounds; empty; precision
|
|
216
|
-
| FR6, FR7 | `NumberInput.test.tsx` formatted-display, form, and units suites | Rest and focus; form submission
|
|
217
|
-
| FR8 | `NumberInput.test.tsx` hasClear suite | Clear; disabled; read-only
|
|
218
|
-
| FR9, FR10, ORD3, AR3 | `NumberInput.test.tsx` disabled, disabledMessage, read-only, and form suites; `InputGroup.test.tsx` disabled-propagation cases | Disabled, reason, read-only
|
|
219
|
-
| FR12 | `NumberInput.fr10.test.tsx`; `NumberInput.test.tsx` status and click-to-focus suites | Standalone and grouped
|
|
220
|
-
| FR13 | `NumberInput.test.tsx` theme-state and stepper-padding suites | Each padding spelling
|
|
221
|
-
| AR5 | `NumberInput.a11y.chromium.spec.ts` with its recorded known failure | Read-only
|
|
222
|
-
| 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
|
|
223
|
-
| 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` |
|
|
204
|
+
| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
|
|
205
|
+
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
|
|
206
|
+
| FR1, AR1 | `NumberInput.test.tsx` spinbutton-attribute suite; `NumberInput.a11y.chromium.spec.ts` | Bounded, unbounded, formatted | A missing role or ARIA value fails. | `audit:NumberInput/accessibility` |
|
|
207
|
+
| FR2, AR2 | `numberParser.test.ts`; `numberParser.docblock.test.ts`; `NumberInput.test.tsx` invalid-feedback suite | Locales, grouping, invalid text | A misparse, a commit of a guess, or a missing announcement fails. | `audit:NumberInput/behavior` |
|
|
208
|
+
| FR3, ORD1 | `numberInputCommit.test.ts`; `NumberInput.test.tsx` validation, commit-policy, clamping, keyboard-clearing suites | Blur, Enter, empty, clamp, inverted range | A per-keystroke `onChange`, an unclamped commit, or a wrong clear fails. | `audit:NumberInput/behavior` |
|
|
209
|
+
| FR4, FR5, ORD2, AR4, PR1 | `NumberInput.test.tsx` stepping and steppers suites; wheel-listener cases | Keys, wheel, steppers; bounds; empty; precision | A step off the grid, past a bound, or with modifiers fails. Evidence gap: no test steps from an emptied draft over a value. | `audit:NumberInput/behavior` |
|
|
210
|
+
| FR6, FR7 | `NumberInput.test.tsx` formatted-display, form, and units suites | Rest and focus; form submission | A formatted value submitted, or units not described, fails. | `audit:NumberInput/behavior` |
|
|
211
|
+
| FR8 | `NumberInput.test.tsx` hasClear suite | Clear; disabled; read-only | A clear on a disabled field, or no `null`, fails. Evidence gap: no test presses the clear button under reduced motion. | `audit:NumberInput/behavior` |
|
|
212
|
+
| FR9, FR10, ORD3, AR3 | `NumberInput.test.tsx` disabled, disabledMessage, read-only, and form suites; `InputGroup.test.tsx` disabled-propagation cases | Disabled, reason, read-only | A lost tab stop, a submitted disabled value, or stepping while read-only fails. | `audit:NumberInput/accessibility` |
|
|
213
|
+
| FR12 | `NumberInput.fr10.test.tsx`; `NumberInput.test.tsx` status and click-to-focus suites | Standalone and grouped | A base prop off the input, or styling on the wrong part, fails. | `audit:NumberInput/api` |
|
|
214
|
+
| FR13 | `NumberInput.test.tsx` theme-state and stepper-padding suites | Each padding spelling | Steppers that ignore the theme padding fail. | `audit:NumberInput/theming` |
|
|
215
|
+
| 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` |
|
|
216
|
+
| 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` |
|
|
224
217
|
|
|
225
218
|
## Current-state impact
|
|
226
219
|
|
|
@@ -242,23 +235,8 @@ anatomy-theming map.
|
|
|
242
235
|
|
|
243
236
|
## Decision log
|
|
244
237
|
|
|
245
|
-
NumberInput applies `family:input-fields` and
|
|
246
|
-
|
|
247
|
-
### DEC-1 — Compact steppers follow primary-pointer precision
|
|
248
|
-
|
|
249
|
-
**Reference:** `component:NumberInput/DEC-1`
|
|
250
|
-
**Decider:** `imdreamrunner`, `2026-10-29`
|
|
251
|
-
|
|
252
|
-
Coarse-primary-pointer presentation uses direct numeric entry rather than
|
|
253
|
-
compact steppers, independently of viewport width and without an opt-out.
|
|
254
|
-
Non-coarse-pointer steppers and keyboard stepping retain their contracts.
|
|
255
|
-
The compact buttons are presentation aids: the numeric value, validation,
|
|
256
|
-
callback, direct-entry, and keyboard-stepping contracts do not depend on their
|
|
257
|
-
visibility. Adapting those aids requires no caller source or configuration
|
|
258
|
-
migration.
|
|
259
|
-
|
|
260
|
-
Rejected: width-based activation, because pointer precision governs this
|
|
261
|
-
control affordance rather than the available layout width.
|
|
238
|
+
No component-local decision. NumberInput applies `family:input-fields` and
|
|
239
|
+
`spec:AST-085`.
|
|
262
240
|
|
|
263
241
|
## Open questions
|
|
264
242
|
|