@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
|
@@ -41,21 +41,14 @@ export function isFocusDetached(doc = document) {
|
|
|
41
41
|
* autofocus target, the control that opened a Dialog, or a menu trigger the
|
|
42
42
|
* caller renders. Pass `options` (for example `preventScroll`) as to `focus()`.
|
|
43
43
|
*
|
|
44
|
-
* `modality` defaults to the page's last input. A caller moving focus inside a
|
|
45
|
-
* second document (a same-origin iframe, a Document Picture-in-Picture window)
|
|
46
|
-
* passes that document's, which it tracks with `trackInteractionModalityIn`.
|
|
47
|
-
*
|
|
48
44
|
* Make sure modality tracking is active (`useInteractionModalityTracking()`)
|
|
49
45
|
* in the component that calls this.
|
|
50
46
|
*/
|
|
51
|
-
export function focusForLastInput(element, options
|
|
47
|
+
export function focusForLastInput(element, options) {
|
|
52
48
|
// A text-entry field paints its own focus treatment to show where typing
|
|
53
|
-
// goes, and an extra blur there can run validation or mark it touched.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const view = element.ownerDocument.defaultView ?? window;
|
|
57
|
-
const isTextEntry = element instanceof view.HTMLInputElement || element instanceof view.HTMLTextAreaElement || element instanceof view.HTMLSelectElement || element.isContentEditable;
|
|
58
|
-
if (isTextEntry || modality !== 'pointer') {
|
|
49
|
+
// goes, and an extra blur there can run validation or mark it touched.
|
|
50
|
+
const isTextEntry = element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement || element.isContentEditable;
|
|
51
|
+
if (isTextEntry || getInteractionModality() !== 'pointer') {
|
|
59
52
|
if (options === undefined) {
|
|
60
53
|
element.focus();
|
|
61
54
|
} else {
|
|
@@ -2,17 +2,8 @@ export type InteractionModality = 'keyboard' | 'pointer';
|
|
|
2
2
|
declare function trackInteractionModality(): () => void;
|
|
3
3
|
/** Activate shared modality tracking when the first consumer mounts. */
|
|
4
4
|
export declare function useInteractionModalityTracking(): void;
|
|
5
|
-
/**
|
|
6
|
-
|
|
7
|
-
* app renders into a same-origin iframe or a Document Picture-in-Picture
|
|
8
|
-
* window gets input there that never reaches the page's listeners. The first
|
|
9
|
-
* consumer installs the document's listener pair and the last one's cleanup
|
|
10
|
-
* removes it. Read the value with `getInteractionModality(doc)`. The page's
|
|
11
|
-
* own document keeps its document-lifetime listeners.
|
|
12
|
-
*/
|
|
13
|
-
export declare function trackInteractionModalityIn(targetDocument: Document): () => void;
|
|
14
|
-
/** How the user last interacted with the page, or with `targetDocument`. */
|
|
15
|
-
export declare function getInteractionModality(targetDocument?: Document): InteractionModality;
|
|
5
|
+
/** How the user last interacted with the page. */
|
|
6
|
+
export declare function getInteractionModality(): InteractionModality;
|
|
16
7
|
/** Test-only: restore the initial state between cases. */
|
|
17
8
|
export declare function __resetInteractionModalityForTest(): void;
|
|
18
9
|
/** Test-only: activate the tracker without rendering a hook. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"interactionModality.d.ts","sourceRoot":"","sources":["../../src/utils/interactionModality.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"interactionModality.d.ts","sourceRoot":"","sources":["../../src/utils/interactionModality.ts"],"names":[],"mappings":"AAkCA,MAAM,MAAM,mBAAmB,GAAG,UAAU,GAAG,SAAS,CAAC;AA8CzD,iBAAS,wBAAwB,IAAI,MAAM,IAAI,CAsB9C;AAED,wEAAwE;AACxE,wBAAgB,8BAA8B,IAAI,IAAI,CAErD;AAED,kDAAkD;AAClD,wBAAgB,sBAAsB,IAAI,mBAAmB,CAI5D;AAED,0DAA0D;AAC1D,wBAAgB,iCAAiC,IAAI,IAAI,CAIxD;AAED,gEAAgE;AAChE,eAAO,MAAM,yCAAyC,iCAC5B,CAAC"}
|
|
@@ -6,9 +6,7 @@
|
|
|
6
6
|
* @file interactionModality.ts
|
|
7
7
|
* @input Global pointerdown/keydown events
|
|
8
8
|
* @output `useInteractionModalityTracking()` activates document-lifetime tracking;
|
|
9
|
-
* `getInteractionModality()` reads how the user last interacted
|
|
10
|
-
* `trackInteractionModalityIn(doc)` and `getInteractionModality(doc)` do the
|
|
11
|
-
* same for a second document
|
|
9
|
+
* `getInteractionModality()` reads how the user last interacted
|
|
12
10
|
* @position Internal document-wide singleton; used where `:focus-visible` alone
|
|
13
11
|
* is too broad
|
|
14
12
|
*
|
|
@@ -28,12 +26,8 @@
|
|
|
28
26
|
* The store lives on `document`, so duplicate Astryx bundles share one state
|
|
29
27
|
* value and one listener pair. The first mounted consumer activates tracking;
|
|
30
28
|
* the listeners then remain for the document lifetime so input during a gap
|
|
31
|
-
* between consumers is not lost.
|
|
32
|
-
*
|
|
33
|
-
* tracks it, since input there never reaches the page's listeners; its
|
|
34
|
-
* listeners stay only while a consumer there is mounted. Importing this module
|
|
35
|
-
* has no DOM side effects, and server rendering keeps the keyboard-safe
|
|
36
|
-
* default.
|
|
29
|
+
* between consumers is not lost. Importing this module has no DOM side effects,
|
|
30
|
+
* and server rendering keeps the keyboard-safe default.
|
|
37
31
|
*/
|
|
38
32
|
import { useEffect } from 'react';
|
|
39
33
|
const interactionModalityStoreKey = Symbol.for('@astryxdesign/core/interaction-modality/v1');
|
|
@@ -95,55 +89,8 @@ export function useInteractionModalityTracking() {
|
|
|
95
89
|
useEffect(() => trackInteractionModality(), []);
|
|
96
90
|
}
|
|
97
91
|
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
* app renders into a same-origin iframe or a Document Picture-in-Picture
|
|
101
|
-
* window gets input there that never reaches the page's listeners. The first
|
|
102
|
-
* consumer installs the document's listener pair and the last one's cleanup
|
|
103
|
-
* removes it. Read the value with `getInteractionModality(doc)`. The page's
|
|
104
|
-
* own document keeps its document-lifetime listeners.
|
|
105
|
-
*/
|
|
106
|
-
export function trackInteractionModalityIn(targetDocument) {
|
|
107
|
-
if (typeof document !== 'undefined' && targetDocument === document) {
|
|
108
|
-
return trackInteractionModality();
|
|
109
|
-
}
|
|
110
|
-
const store = getStore(targetDocument);
|
|
111
|
-
store.consumers = (store.consumers ?? 0) + 1;
|
|
112
|
-
if (store.consumers === 1) {
|
|
113
|
-
store.isListening = true;
|
|
114
|
-
targetDocument.addEventListener('pointerdown', store.onPointerDown, {
|
|
115
|
-
capture: true,
|
|
116
|
-
passive: true
|
|
117
|
-
});
|
|
118
|
-
targetDocument.addEventListener('keydown', store.onKeyDown, {
|
|
119
|
-
capture: true,
|
|
120
|
-
passive: true
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
let isReleased = false;
|
|
124
|
-
return () => {
|
|
125
|
-
if (isReleased) {
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
isReleased = true;
|
|
129
|
-
store.consumers = (store.consumers ?? 1) - 1;
|
|
130
|
-
if (store.consumers === 0) {
|
|
131
|
-
store.isListening = false;
|
|
132
|
-
targetDocument.removeEventListener('pointerdown', store.onPointerDown, {
|
|
133
|
-
capture: true
|
|
134
|
-
});
|
|
135
|
-
targetDocument.removeEventListener('keydown', store.onKeyDown, {
|
|
136
|
-
capture: true
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** How the user last interacted with the page, or with `targetDocument`. */
|
|
143
|
-
export function getInteractionModality(targetDocument) {
|
|
144
|
-
if (targetDocument != null) {
|
|
145
|
-
return getStore(targetDocument).modality;
|
|
146
|
-
}
|
|
92
|
+
/** How the user last interacted with the page. */
|
|
93
|
+
export function getInteractionModality() {
|
|
147
94
|
return typeof document === 'undefined' ? 'keyboard' : getStore(document).modality;
|
|
148
95
|
}
|
|
149
96
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/core",
|
|
3
|
-
"version": "0.7.5
|
|
3
|
+
"version": "0.7.5",
|
|
4
4
|
"displayName": "Astryx Core",
|
|
5
5
|
"description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -847,8 +847,6 @@
|
|
|
847
847
|
"react-dom": ">=19.0.0"
|
|
848
848
|
},
|
|
849
849
|
"devDependencies": {
|
|
850
|
-
"@astryxdesign/a11y-spec": "0.7.5-canary.fd0aba8",
|
|
851
|
-
"@astryxdesign/cli": "0.7.5-canary.fd0aba8",
|
|
852
850
|
"@babel/cli": "^7.29.7",
|
|
853
851
|
"@babel/core": "^7.29.7",
|
|
854
852
|
"@babel/preset-react": "^7.29.7",
|
|
@@ -859,7 +857,9 @@
|
|
|
859
857
|
"@testing-library/jest-dom": "^6.6.0",
|
|
860
858
|
"@testing-library/react": "^16.3.2",
|
|
861
859
|
"@types/babel__core": "^7.20.5",
|
|
862
|
-
"tailwindcss": "^4.3.2"
|
|
860
|
+
"tailwindcss": "^4.3.2",
|
|
861
|
+
"@astryxdesign/cli": "0.7.5",
|
|
862
|
+
"@astryxdesign/a11y-spec": "0.0.0"
|
|
863
863
|
},
|
|
864
864
|
"dependencies": {
|
|
865
865
|
"intl-messageformat": "^11.2.9"
|
|
@@ -1045,52 +1045,6 @@ describe('BottomSheet', () => {
|
|
|
1045
1045
|
});
|
|
1046
1046
|
|
|
1047
1047
|
describe('swipe to dismiss', () => {
|
|
1048
|
-
it("a sheet opened from inside another sheet's content drags to dismiss by its own handle, and the covered host neither drags nor takes the pointer (FR6, FR7)", () => {
|
|
1049
|
-
const onInnerOpenChange = vi.fn();
|
|
1050
|
-
const onOuterOpenChange = vi.fn();
|
|
1051
|
-
render(
|
|
1052
|
-
<BottomSheet isOpen onOpenChange={onOuterOpenChange} label="Outer">
|
|
1053
|
-
Outer content
|
|
1054
|
-
<BottomSheet isOpen onOpenChange={onInnerOpenChange} label="Inner">
|
|
1055
|
-
Inner content
|
|
1056
|
-
</BottomSheet>
|
|
1057
|
-
</BottomSheet>,
|
|
1058
|
-
);
|
|
1059
|
-
const dialogs = screen.getAllByRole('dialog', {hidden: true});
|
|
1060
|
-
expect(dialogs).toHaveLength(2);
|
|
1061
|
-
const [outerDialog, innerDialog] = dialogs;
|
|
1062
|
-
// The inner sheet's dialog is rendered inside the outer sheet's body,
|
|
1063
|
-
// so its events bubble through the outer body's gesture listeners.
|
|
1064
|
-
expect(outerDialog.contains(innerDialog)).toBe(true);
|
|
1065
|
-
const outerPanel = outerDialog.querySelector<HTMLElement>(
|
|
1066
|
-
':scope > .astryx-bottom-sheet',
|
|
1067
|
-
)!;
|
|
1068
|
-
const outerBody = outerPanel.querySelector<HTMLElement>(
|
|
1069
|
-
':scope > :not([aria-hidden])',
|
|
1070
|
-
)!;
|
|
1071
|
-
const innerHandle = innerDialog.querySelector<HTMLElement>(
|
|
1072
|
-
'.astryx-bottom-sheet > [aria-hidden="true"]',
|
|
1073
|
-
)!;
|
|
1074
|
-
// `setPointerCapture` is a prototype stub here, so record who captures.
|
|
1075
|
-
const captured: Element[] = [];
|
|
1076
|
-
const capture = vi
|
|
1077
|
-
.spyOn(Element.prototype, 'setPointerCapture')
|
|
1078
|
-
.mockImplementation(function (this: Element) {
|
|
1079
|
-
captured.push(this);
|
|
1080
|
-
});
|
|
1081
|
-
drag(innerHandle, [{y: 0}, {y: 40}, {y: 120}], 'touch');
|
|
1082
|
-
capture.mockRestore();
|
|
1083
|
-
// The inner sheet's own drag ran to its dismissal.
|
|
1084
|
-
expect(onInnerOpenChange).toHaveBeenCalledWith(false);
|
|
1085
|
-
// The covered host saw the same pointer bubble up and did nothing with
|
|
1086
|
-
// it: no drag of its own (its panel keeps its resting transform) and no
|
|
1087
|
-
// pointer capture, which would have cancelled the inner handle's drag.
|
|
1088
|
-
expect(onOuterOpenChange).not.toHaveBeenCalled();
|
|
1089
|
-
expect(captured).toContain(innerHandle);
|
|
1090
|
-
expect(captured).not.toContain(outerBody);
|
|
1091
|
-
expect(outerPanel.style.transform).toBe('');
|
|
1092
|
-
});
|
|
1093
|
-
|
|
1094
1048
|
it('requests close when dragged past the dismiss threshold', () => {
|
|
1095
1049
|
const onOpenChange = vi.fn();
|
|
1096
1050
|
render(
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
|
|
13
13
|
import {act, renderHook} from '@testing-library/react';
|
|
14
14
|
import {
|
|
15
|
-
isInsideNestedLayer,
|
|
16
15
|
useSheetGestures,
|
|
17
16
|
type UseSheetGesturesOptions,
|
|
18
17
|
} from './useSheetGestures';
|
|
@@ -107,27 +106,6 @@ function up(hook: Hook, y: number, t: number, target: HTMLElement) {
|
|
|
107
106
|
);
|
|
108
107
|
}
|
|
109
108
|
|
|
110
|
-
describe('isInsideNestedLayer', () => {
|
|
111
|
-
it("is true for a target inside a <dialog> rendered within the body, and false for the body's own content", () => {
|
|
112
|
-
const body = document.createElement('div');
|
|
113
|
-
const own = document.createElement('button');
|
|
114
|
-
body.append(own);
|
|
115
|
-
const nested = document.createElement('dialog');
|
|
116
|
-
const nestedHandle = document.createElement('div');
|
|
117
|
-
nested.append(nestedHandle);
|
|
118
|
-
body.append(nested);
|
|
119
|
-
expect(isInsideNestedLayer(own, body)).toBe(false);
|
|
120
|
-
expect(isInsideNestedLayer(body, body)).toBe(false);
|
|
121
|
-
expect(isInsideNestedLayer(nestedHandle, body)).toBe(true);
|
|
122
|
-
expect(isInsideNestedLayer(nested, body)).toBe(true);
|
|
123
|
-
// A dialog above the body (the sheet's own) does not count.
|
|
124
|
-
const ownDialog = document.createElement('dialog');
|
|
125
|
-
ownDialog.append(body);
|
|
126
|
-
expect(isInsideNestedLayer(own, body)).toBe(false);
|
|
127
|
-
expect(isInsideNestedLayer(null, body)).toBe(false);
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
|
|
131
109
|
describe('useSheetGestures', () => {
|
|
132
110
|
it('dismisses on a fast downward flick (swipe-to-close)', () => {
|
|
133
111
|
const {hook, onDismiss} = setup({snapHeights: () => [200]});
|
|
@@ -1235,36 +1213,6 @@ describe('useSheetGestures', () => {
|
|
|
1235
1213
|
expect(hook.result.current.isDragging).toBe(true);
|
|
1236
1214
|
});
|
|
1237
1215
|
|
|
1238
|
-
it('leaves a touch that starts inside a nested <dialog> to that layer', () => {
|
|
1239
|
-
// A sheet opened from this sheet's content renders its <dialog> inside
|
|
1240
|
-
// the body, so its touches bubble here. The same pull-down from the
|
|
1241
|
-
// body's own content promotes (see above); from the nested layer it
|
|
1242
|
-
// must not.
|
|
1243
|
-
const {hook} = setup({snapHeights: () => [200]});
|
|
1244
|
-
const {body} = makeNested({
|
|
1245
|
-
bodyScrollTop: 0,
|
|
1246
|
-
innerScrollTop: 0,
|
|
1247
|
-
innerScrollHeight: 200,
|
|
1248
|
-
});
|
|
1249
|
-
const nested = document.createElement('dialog');
|
|
1250
|
-
const nestedHandle = document.createElement('div');
|
|
1251
|
-
nested.appendChild(nestedHandle);
|
|
1252
|
-
body.appendChild(nested);
|
|
1253
|
-
act(() => hook.result.current.sheetRef(body));
|
|
1254
|
-
act(() => hook.result.current.bodyProps.ref(body));
|
|
1255
|
-
let start: Event | undefined;
|
|
1256
|
-
let pull: Event | undefined;
|
|
1257
|
-
act(() => {
|
|
1258
|
-
start = touchOn(nestedHandle, body, 'touchstart', 100);
|
|
1259
|
-
pull = touchOn(nestedHandle, body, 'touchmove', 160);
|
|
1260
|
-
});
|
|
1261
|
-
expect(start?.defaultPrevented).toBe(false);
|
|
1262
|
-
expect(pull?.defaultPrevented).toBe(false);
|
|
1263
|
-
expect(hook.result.current.isDragging).toBe(false);
|
|
1264
|
-
expect(liveOffset(hook)).toBe(0);
|
|
1265
|
-
expect(body.style.transform).toBe('');
|
|
1266
|
-
});
|
|
1267
|
-
|
|
1268
1216
|
it('leaves a pull-up to a nested scroller with content below', () => {
|
|
1269
1217
|
const {hook} = setup({snapHeights: () => [200]});
|
|
1270
1218
|
const {body, tile} = makeNested({bodyScrollTop: 0, innerScrollTop: 100});
|
|
@@ -235,33 +235,6 @@ function isBlockScroller(element: HTMLElement): boolean {
|
|
|
235
235
|
return overflowY === 'auto' || overflowY === 'scroll';
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
-
/**
|
|
239
|
-
* Whether a gesture that lands on `target` began inside another layer
|
|
240
|
-
* rendered in this sheet's content — a `<dialog>` between the target and the
|
|
241
|
-
* body. A sheet opened from inside a sheet's content, a Dialog opened there,
|
|
242
|
-
* renders its dialog as a descendant of the host's body, so its pointer and
|
|
243
|
-
* touch events bubble through the host's gesture listeners. That gesture is
|
|
244
|
-
* the layer's own: the host must neither arm a drag on it nor take pointer
|
|
245
|
-
* capture for it, which would cancel the drag the layer's own handle began
|
|
246
|
-
* (`component:BottomSheet` FR7: a swipe reaches only the top sheet of a
|
|
247
|
-
* stack; FR6: a covered sheet is blocked).
|
|
248
|
-
*/
|
|
249
|
-
export function isInsideNestedLayer(
|
|
250
|
-
target: EventTarget | null,
|
|
251
|
-
body: HTMLElement,
|
|
252
|
-
): boolean {
|
|
253
|
-
for (
|
|
254
|
-
let element = target instanceof Element ? target : null;
|
|
255
|
-
element != null && element !== body;
|
|
256
|
-
element = element.parentElement
|
|
257
|
-
) {
|
|
258
|
-
if (element.localName === 'dialog') {
|
|
259
|
-
return true;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
return false;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
238
|
/**
|
|
266
239
|
* The scrollers a gesture that lands on `target` can scroll, innermost first
|
|
267
240
|
* and the body last: every scrollable box between the target and the body,
|
|
@@ -1355,13 +1328,10 @@ export function useSheetGestures({
|
|
|
1355
1328
|
if (event.button !== 0 || !event.isPrimary) {
|
|
1356
1329
|
return;
|
|
1357
1330
|
}
|
|
1358
|
-
const
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
return;
|
|
1363
|
-
}
|
|
1364
|
-
const scrollers = scrollChainUnder(event.target, body);
|
|
1331
|
+
const scrollers = scrollChainUnder(
|
|
1332
|
+
event.target,
|
|
1333
|
+
event.currentTarget as HTMLElement,
|
|
1334
|
+
);
|
|
1365
1335
|
if (!chainAtTop(scrollers)) {
|
|
1366
1336
|
armedBodyRef.current = null;
|
|
1367
1337
|
return;
|
|
@@ -1495,9 +1465,8 @@ export function useSheetGestures({
|
|
|
1495
1465
|
// At the top, a pull DOWN hands off (collapse); at the bottom, a pull UP
|
|
1496
1466
|
// hands off (expand). A gesture that starts mid-content is an ordinary
|
|
1497
1467
|
// scroll, but it is tracked all the same: the scroller can run out of
|
|
1498
|
-
// content while the finger is still down (see onTouchMove).
|
|
1499
|
-
|
|
1500
|
-
if (!touch || isInsideNestedLayer(event.target, body)) {
|
|
1468
|
+
// content while the finger is still down (see onTouchMove).
|
|
1469
|
+
if (!touch) {
|
|
1501
1470
|
touchDragRef.current = null;
|
|
1502
1471
|
return;
|
|
1503
1472
|
}
|
|
@@ -3,11 +3,11 @@ schema_version: 3
|
|
|
3
3
|
template_version: 7
|
|
4
4
|
kind: component
|
|
5
5
|
id: component:DateTimeInput
|
|
6
|
-
authority:
|
|
6
|
+
authority: draft
|
|
7
7
|
archive_reason: null
|
|
8
8
|
superseded_by: null
|
|
9
|
-
approved_by:
|
|
10
|
-
approved_at:
|
|
9
|
+
approved_by: null
|
|
10
|
+
approved_at: null
|
|
11
11
|
owners: [cixzhang]
|
|
12
12
|
review_triggers: [public-api, behavior, layout, theming, accessibility]
|
|
13
13
|
verified_by:
|
|
@@ -16,37 +16,30 @@ verified_by:
|
|
|
16
16
|
packages/core/src/DateTimeInput/DateTimeInputTouch.test.tsx,
|
|
17
17
|
packages/core/src/DateTimeInput/NativePickerSegments.test.tsx,
|
|
18
18
|
packages/core/src/DateTimeInput/Presentation.test.tsx,
|
|
19
|
-
packages/core/src/DateTimeInput/DateTimeInput.fr10.test.tsx,
|
|
20
|
-
packages/core/src/__tests__/BusyOwnerAnnouncement.test.tsx,
|
|
21
19
|
packages/core/src/theme/themingTargets.test.ts,
|
|
22
20
|
scripts/check-knowledge.mjs,
|
|
23
21
|
]
|
|
24
22
|
modules: []
|
|
25
23
|
families: [family:input-fields, family:overlay-dismissal]
|
|
26
24
|
design_specs: []
|
|
27
|
-
architecture:
|
|
28
|
-
[
|
|
29
|
-
architecture:component-theming-surface,
|
|
30
|
-
architecture:internationalization,
|
|
31
|
-
architecture:public-component-api,
|
|
32
|
-
]
|
|
25
|
+
architecture: [architecture:component-theming-surface]
|
|
33
26
|
contributing: []
|
|
34
|
-
system_specs: [spec:AST-043
|
|
27
|
+
system_specs: [spec:AST-043]
|
|
35
28
|
---
|
|
36
29
|
|
|
37
30
|
# DateTimeInput component contract
|
|
38
31
|
|
|
39
32
|
## Contract at a glance
|
|
40
33
|
|
|
41
|
-
| Area | Contract
|
|
42
|
-
| ----------------------- |
|
|
43
|
-
| Public contract | One controlled ISO date-time value presented through coordinated date and time segments; `presentation` selects Astryx, native, or adaptive picker surfaces.
|
|
44
|
-
| Behavior | Date and time changes emit one combined value; clear emits `undefined`; controlled empty state remains empty in every rendered picker.
|
|
45
|
-
| End-user impact | People selecting a date and time see one value reflected consistently in the closed field and the active picker, including after clearing.
|
|
46
|
-
| Builder impact | None.
|
|
47
|
-
| Compatibility/readiness |
|
|
48
|
-
| Review checks | Reject competing picker selection, date/time output that is not one valid combined value, presentation precedence drift, lost per-segment fallback, or broken segment accessibility.
|
|
49
|
-
| Governing rules | `spec:AST-043`, `family:input-fields`, `family:overlay-dismissal`,
|
|
34
|
+
| Area | Contract |
|
|
35
|
+
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
36
|
+
| Public contract | One controlled ISO date-time value presented through coordinated date and time segments; `presentation` selects Astryx, native, or adaptive picker surfaces. |
|
|
37
|
+
| Behavior | Date and time changes emit one combined value; clear emits `undefined`; controlled empty state remains empty in every rendered picker. |
|
|
38
|
+
| End-user impact | People selecting a date and time see one value reflected consistently in the closed field and the active picker, including after clearing. |
|
|
39
|
+
| Builder impact | None. |
|
|
40
|
+
| Compatibility/readiness | Released defaults and deprecated `nativePicker` behavior remain unchanged. The presentation matrix is governed by current `spec:AST-043`; component-local observations remain draft pending owner review. |
|
|
41
|
+
| Review checks | Reject competing picker selection, date/time output that is not one valid combined value, presentation precedence drift, lost per-segment fallback, or broken segment accessibility. |
|
|
42
|
+
| Governing rules | `spec:AST-043`, `family:input-fields`, `family:overlay-dismissal`, and objective platform accessibility standards. |
|
|
50
43
|
|
|
51
44
|
This table is a review projection; the body below is authoritative.
|
|
52
45
|
|
|
@@ -78,16 +71,13 @@ Consumer migration instructions belong in consumer docs and release notes.
|
|
|
78
71
|
- Applying component-local date-time bounds, date constraints, time precision,
|
|
79
72
|
typed-time stepping, and optional preset-time behavior.
|
|
80
73
|
- Emitting `onChange` before an optional optimistic `changeAction`.
|
|
81
|
-
- The disabled-reason Tooltip composition, which `family:input-fields` assigns
|
|
82
|
-
to the member.
|
|
83
74
|
|
|
84
75
|
**Does not own / non-goals**
|
|
85
76
|
|
|
86
|
-
- Field label, description, status, and clear-button presentation —
|
|
87
|
-
owned by Field
|
|
88
|
-
`component:FieldStatus`.
|
|
77
|
+
- Field label, description, status, disabled-reason, and clear-button presentation —
|
|
78
|
+
owned by `component:Field` and `component:FieldStatus`.
|
|
89
79
|
- Calendar-grid rendering and date-cell interaction — owned by
|
|
90
|
-
Calendar
|
|
80
|
+
`component:Calendar`.
|
|
91
81
|
- Popover and sheet hosting or dismissal — owned by the composed layer components
|
|
92
82
|
and `family:overlay-dismissal`.
|
|
93
83
|
- Cross-component picker-presentation vocabulary and deprecated-prop mapping —
|
|
@@ -105,17 +95,18 @@ Consumer migration instructions belong in consumer docs and release notes.
|
|
|
105
95
|
|
|
106
96
|
## Behavioral and layout contract
|
|
107
97
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
|
112
|
-
|
|
|
113
|
-
|
|
|
114
|
-
|
|
|
115
|
-
|
|
|
116
|
-
|
|
|
117
|
-
|
|
|
118
|
-
|
|
|
98
|
+
Draft requirements identify their basis so observed code is not mistaken for an
|
|
99
|
+
intentional decision.
|
|
100
|
+
|
|
101
|
+
| ID | Candidate invariant | Basis | Draft review state |
|
|
102
|
+
| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------ |
|
|
103
|
+
| FR1 | DateTimeInput MUST reflect one controlled date-time value across its visible segments and active picker, and MUST NOT reveal a competing selected date after the parent accepts a clear. | Public docs, controlled component seam, current source, focused tests | Verified observation; owner approval pending |
|
|
104
|
+
| FR2 | Every committed date or time change MUST emit one valid combined ISO date-time value; clearing MUST emit `undefined`; changing time while no date is committed MUST emit nothing. | Public types, docs, current source, focused tests | Verified observation; owner approval pending |
|
|
105
|
+
| FR3 | `presentation`, deprecated `nativePicker`, precedence, pointer adaptation, forced surfaces, and per-segment native fallbacks MUST follow `spec:AST-043` FR1–FR4. | `spec:AST-043` | Inherited current authority |
|
|
106
|
+
| FR4 | When `changeAction` is present, every value-change path MUST call `onChange` first, show the proposed controlled value optimistically, and contribute to one busy state until the controlled value accepts or replaces it. | `family:input-fields/FR6` | Inherited current authority |
|
|
107
|
+
| FR5 | The two pointer segments share one row while each retains its intrinsic basis; when the container is narrower, flex wrapping moves them into separate full-width rows. | Public docs, current source, focused layout tests | Verify rendered geometry; class-level behavior covered |
|
|
108
|
+
| FR6 | Date-time bounds and date constraints MUST prevent invalid commits. Native controls MAY receive platform hints, but JavaScript validation remains the final commit boundary; calendar-only options MAY be unavailable on native surfaces. | Public docs, current source, focused presentation tests | Verified observation; owner approval pending |
|
|
109
|
+
| FR7 | A preset-time listbox MUST remain optional, preserve typed entry, expose one selected option independently from keyboard highlight, and limit offered values to the selected date's effective minimum and maximum. | Public docs, current source, focused interaction tests | Verified observation; owner approval pending |
|
|
119
110
|
|
|
120
111
|
### Allowed variation
|
|
121
112
|
|
|
@@ -128,13 +119,13 @@ Consumer migration instructions belong in consumer docs and release notes.
|
|
|
128
119
|
|
|
129
120
|
### Representative states
|
|
130
121
|
|
|
131
|
-
| State | Required invariant
|
|
132
|
-
| --------------------------- |
|
|
133
|
-
| Empty | Both segments and every active picker expose no committed selection.
|
|
134
|
-
| Date-time value | Date segment, time segment, and active picker reflect the same committed value.
|
|
135
|
-
| Cleared after picker change | Closed field is empty and reopening the picker reveals no selected date or retained committed time.
|
|
136
|
-
| Busy | Proposed value remains visible;
|
|
137
|
-
| Focusable disabled | Both segments
|
|
122
|
+
| State | Required invariant | Allowed variation |
|
|
123
|
+
| --------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
|
|
124
|
+
| Empty | Both segments and every active picker expose no committed selection. | Placeholder and selected surface. |
|
|
125
|
+
| Date-time value | Date segment, time segment, and active picker reflect the same committed value. | Locale, hour format, and time precision. |
|
|
126
|
+
| Cleared after picker change | Closed field is empty and reopening the picker reveals no selected date or retained committed time. | Picker focus date may remain navigational. |
|
|
127
|
+
| Busy | Proposed value remains visible; mutation paths are unavailable; both editable segments expose busy. | Explicit loading or pending action source. |
|
|
128
|
+
| Focusable disabled | Both segments remain focusable enough to expose the reason while every value-changing path stays blocked. | Disabled-reason text. |
|
|
138
129
|
|
|
139
130
|
### Transformation and precedence order
|
|
140
131
|
|
|
@@ -229,38 +220,24 @@ leading clock glyph uses `date-time-input-clock-icon`.
|
|
|
229
220
|
|
|
230
221
|
## Verification map
|
|
231
222
|
|
|
232
|
-
| Contract | Verification
|
|
233
|
-
| ------------------- |
|
|
234
|
-
| FR1–FR2 | `DateTimeInput.test.tsx`
|
|
235
|
-
| FR3, FR6 | `Presentation.test.tsx`, `NativePickerSegments.test.tsx`, `DateTimeInputTouch.test.tsx`
|
|
236
|
-
| FR4,
|
|
237
|
-
| FR5 | `DateTimeInput.test.tsx`
|
|
238
|
-
| FR7, AR1–AR3 | `DateTimeInput.test.tsx`, `DateTimeInputTouch.test.tsx`
|
|
239
|
-
|
|
|
240
|
-
| Theming anatomy map | `scripts/check-knowledge.mjs`, `themingTargets.test.ts` | root, segments, icons, listbox, options | Missing, undocumented, or wrongly placed targets fail knowledge or target checks. | `audit:DateTimeInput/theming` |
|
|
241
|
-
|
|
242
|
-
## Current-state impact
|
|
243
|
-
|
|
244
|
-
- **Fixed height (FR5).** Segments hold the fixed shared element height instead
|
|
245
|
-
of growing with a set text size, against `spec:AST-102` FR4.
|
|
246
|
-
- **Time text and parsing (FR7).** Time text, preset options, and the stepped
|
|
247
|
-
announcement use ASCII digits, a `:` separator, and English "AM"/"PM", and
|
|
248
|
-
typed times parse without the locale, against
|
|
249
|
-
`architecture:internationalization` INV1a and INV8.
|
|
250
|
-
- **Busy paint (FR9).** A busy field that is not disabled takes the disabled
|
|
251
|
-
half-opacity paint, against `spec:AST-085` FR6.
|
|
252
|
-
- **Caller handlers while disabled (FR9).** Caller DOM handlers on the date
|
|
253
|
-
segment, such as `onKeyDown`, still run while disabled, against
|
|
254
|
-
`spec:AST-085` FR2.
|
|
223
|
+
| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
|
|
224
|
+
| ------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------- |
|
|
225
|
+
| FR1–FR2 | `DateTimeInput.test.tsx` | empty, calendar selection, typed date/time, clear | A stale Calendar selection, partial output, or invalid mutation makes a public-state test fail. | `audit:DateTimeInput/behavior` |
|
|
226
|
+
| FR3, FR6 | `Presentation.test.tsx`, `NativePickerSegments.test.tsx`, `DateTimeInputTouch.test.tsx` | forced, adaptive, legacy, native fallback, constrained commit | A substituted surface, precedence drift, or invalid commit makes a presentation test fail. | `audit:DateTimeInput/surfaces` |
|
|
227
|
+
| FR4, AR4 | `DateTimeInput.test.tsx`, `DateTimeInputTouch.test.tsx` | pending, loading, disabled reason | Mutation during busy/disabled state or stale optimistic output makes a state test fail. | `audit:DateTimeInput/state` |
|
|
228
|
+
| FR5 | `DateTimeInput.test.tsx` | intrinsic row, narrow-container wrap classes | Removing the intrinsic basis or wrap styles makes the class-level layout assertions fail; rendered geometry remains review evidence. | `audit:DateTimeInput/layout` |
|
|
229
|
+
| FR7, AR1–AR3 | `DateTimeInput.test.tsx`, `DateTimeInputTouch.test.tsx` | popovers, time options, keyboard/focus | Wrong role, name, state, keyboard behavior, or focus behavior makes a focused interaction test fail. | `audit:DateTimeInput/interaction` |
|
|
230
|
+
| Theming anatomy map | `scripts/check-knowledge.mjs`, `themingTargets.test.ts` | root, segments, icons, listbox, options | Missing, undocumented, or wrongly placed targets fail knowledge or target checks. | `audit:DateTimeInput/theming` |
|
|
255
231
|
|
|
256
232
|
## Decision log
|
|
257
233
|
|
|
258
|
-
No component-local decision
|
|
259
|
-
`spec:AST-043`.
|
|
234
|
+
No component-local decision is asserted by this draft. Current cross-component
|
|
235
|
+
picker decisions remain in `spec:AST-043`.
|
|
260
236
|
|
|
261
237
|
## Open questions
|
|
262
238
|
|
|
263
|
-
None.
|
|
239
|
+
None. Owner review may accept, amend, or reject the observational component-local
|
|
240
|
+
rows without changing inherited current authority.
|
|
264
241
|
|
|
265
242
|
## Content boundary
|
|
266
243
|
|