@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/dist/BottomSheet/useSheetGestures.d.ts +0 -12
  3. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  4. package/dist/BottomSheet/useSheetGestures.js +3 -30
  5. package/dist/Dialog/Dialog.d.ts.map +1 -1
  6. package/dist/Dialog/Dialog.js +8 -33
  7. package/dist/DropdownMenu/lazyMenuBottomSheet.d.ts +1 -10
  8. package/dist/DropdownMenu/lazyMenuBottomSheet.d.ts.map +1 -1
  9. package/dist/DropdownMenu/lazyMenuBottomSheet.js +2 -16
  10. package/dist/NumberInput/NumberInput.d.ts +1 -3
  11. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  12. package/dist/NumberInput/NumberInput.js +3 -3
  13. package/dist/astryx.css +0 -2
  14. package/dist/utils/focusReturn.d.ts +1 -17
  15. package/dist/utils/focusReturn.d.ts.map +1 -1
  16. package/dist/utils/focusReturn.js +4 -11
  17. package/dist/utils/interactionModality.d.ts +2 -11
  18. package/dist/utils/interactionModality.d.ts.map +1 -1
  19. package/dist/utils/interactionModality.js +5 -58
  20. package/package.json +4 -4
  21. package/src/BottomSheet/BottomSheet.test.tsx +0 -46
  22. package/src/BottomSheet/useSheetGestures.test.ts +0 -52
  23. package/src/BottomSheet/useSheetGestures.ts +6 -37
  24. package/src/DateTimeInput/DateTimeInput.spec.md +48 -71
  25. package/src/Dialog/Dialog.test.tsx +2 -216
  26. package/src/Dialog/Dialog.tsx +8 -41
  27. package/src/DropdownMenu/lazyMenuBottomSheet.ts +6 -23
  28. package/src/NumberInput/NumberInput.doc.mjs +7 -13
  29. package/src/NumberInput/NumberInput.spec.md +37 -59
  30. package/src/NumberInput/NumberInput.tsx +3 -13
  31. package/src/utils/focusReturn.ts +6 -17
  32. package/src/utils/interactionModality.test.ts +1 -45
  33. package/src/utils/interactionModality.ts +5 -64
  34. package/src/Dialog/__tests__/DialogSecondDocument.a11y.browser.spec.ts +0 -202
  35. package/src/DropdownMenu/lazyMenuBottomSheet.test.ts +0 -53
  36. 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, modality = getInteractionModality()) {
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. The
54
- // field's own window: one in an iframe or a Picture-in-Picture window is
55
- // never an instance of the page's constructors.
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
- * Track a second document while a consumer there is mounted: a component an
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":"AAwCA,MAAM,MAAM,mBAAmB,GAAG,UAAU,GAAG,SAAS,CAAC;AAgDzD,iBAAS,wBAAwB,IAAI,MAAM,IAAI,CAsB9C;AAED,wEAAwE;AACxE,wBAAgB,8BAA8B,IAAI,IAAI,CAErD;AAED;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACxC,cAAc,EAAE,QAAQ,GACvB,MAAM,IAAI,CAkCZ;AAED,4EAA4E;AAC5E,wBAAgB,sBAAsB,CACpC,cAAc,CAAC,EAAE,QAAQ,GACxB,mBAAmB,CAOrB;AAED,0DAA0D;AAC1D,wBAAgB,iCAAiC,IAAI,IAAI,CAIxD;AAED,gEAAgE;AAChE,eAAO,MAAM,yCAAyC,iCAC5B,CAAC"}
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. A second document (a same-origin iframe, a
32
- * Document Picture-in-Picture window) gets its own store when a component
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
- * Track a second document while a consumer there is mounted: a component an
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-canary.fd0aba8",
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 body = event.currentTarget as HTMLElement;
1359
- // A gesture inside a layer this sheet hosts belongs to that layer.
1360
- if (isInsideNestedLayer(event.target, body)) {
1361
- armedBodyRef.current = null;
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). A touch
1499
- // inside a layer this sheet hosts is that layer's, never tracked here.
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: current
6
+ authority: draft
7
7
  archive_reason: null
8
8
  superseded_by: null
9
- approved_by: cixzhang
10
- approved_at: 2026-10-30
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, spec:AST-085, spec:AST-086, spec:AST-102]
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 | Records shipped behavior; no API, default, or DOM change. The presentation matrix and deprecated `nativePicker` mapping follow `spec:AST-043`. Fixed segment height, locale time text and parsing, busy paint, and caller handlers while disabled are listed under Current-state impact. |
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`, `spec:AST-085`, `spec:AST-086` FR8, and objective platform accessibility standards. |
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 under `family:input-fields` (shared owner section) and
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
- | ID | Invariant | Basis |
109
- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
110
- | 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 |
111
- | 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 |
112
- | FR3 | `presentation`, deprecated `nativePicker`, precedence, pointer adaptation, forced surfaces, and per-segment native fallbacks MUST follow `spec:AST-043` FR1–FR4. | `spec:AST-043` |
113
- | 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` |
114
- | 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. Each segment is the shared element height for its size, as a minimum that grows with a set text size under `spec:AST-102` FR4. Implementation gap: the height is fixed at that size. | Public docs, current source, focused layout tests; `spec:AST-102` FR4 |
115
- | 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 |
116
- | 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. Time text and typed times MUST follow the active locale's digits, separator, and day-period words (`architecture:internationalization` INV1a, INV8). Implementation gap: time text, preset options, and the stepped-time announcement use ASCII digits, a `:` separator, and a fixed English "AM"/"PM", and the typed time parser takes no locale. | Public docs, current source, focused interaction tests; `architecture:internationalization` INV1a, INV8 |
117
- | FR8 | Base props and `ref` MUST reach the date segment's input; a caller `aria-describedby` joins the component's own descriptions, and a caller DOM handler runs after the field's own handler. `container*` reaches the Field box and `control*` the segment row; root `xstyle`, `className`, and `style` are not accepted. | `family:input-fields` FR10; `DateTimeInput.fr10.test.tsx` |
118
- | FR9 | A disabled field MUST keep both segments focusable with `aria-disabled` and no native `disabled`, block every value-changing path, keep the calendar button's tab stop, and paint the keyboard focus ring. Explicit `isLoading` makes every surface inert the same way; a pending `changeAction` does so only on the popover surface, and the bottom sheet and any field with a native date segment keep accepting edits. The busy start is announced once. Implementation gap: a caller `onKeyDown` and other DOM handlers on the date segment still run while disabled (`spec:AST-085` FR2). Implementation gap: a busy field that is not disabled takes the disabled paint (half opacity), on every surface while `isLoading` and on the popover while a `changeAction` is pending, against `spec:AST-085` FR6. | `spec:AST-085` FR1, FR2, FR6; `spec:AST-086` FR8; current source and tests |
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 | Allowed variation |
132
- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
133
- | Empty | Both segments and every active picker expose no committed selection. | Placeholder and selected surface. |
134
- | Date-time value | Date segment, time segment, and active picker reflect the same committed value. | Locale, hour format, and time precision. |
135
- | 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. |
136
- | Busy | Proposed value remains visible; both editable segments expose busy. With explicit `isLoading`, mutation paths are unavailable on every surface; with a pending `changeAction`, only on the popover surface. | Explicit loading or pending action source. |
137
- | Focusable disabled | Both segments stay focusable with `aria-disabled` while every value-changing path stays blocked. | Disabled-reason text. |
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 | Representative states | Mutation or failure expectation | Audit section |
233
- | ------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
234
- | 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` |
235
- | 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` |
236
- | FR4, FR9, AR4 | `DateTimeInput.test.tsx`, `DateTimeInputTouch.test.tsx`; `BusyOwnerAnnouncement.test.tsx` DateTimeInput case | pending, loading, disabled reason | Mutation during busy/disabled state or stale optimistic output makes a state test fail. Evidence gap: no test asserts that caller handlers still run while disabled. | `audit:DateTimeInput/state` |
237
- | 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. Evidence gap: no test asserts segment heights or text growth. | `audit:DateTimeInput/layout` |
238
- | 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. Evidence gap: no test asserts locale-specific time text or parsing. | `audit:DateTimeInput/interaction` |
239
- | FR8 | `DateTimeInput.fr10.test.tsx` | each surface; caller id, describedby, onClick, onKeyDown | A base prop off the date input, a dropped caller description, or styling on the wrong part fails. | `audit:DateTimeInput/api` |
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. Cross-component picker decisions live in
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