@astryxdesign/core 0.7.3-canary.b74cf21 → 0.7.3-canary.cbe384c

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 (46) hide show
  1. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  2. package/dist/DropdownMenu/DropdownMenu.js +52 -10
  3. package/dist/DropdownMenu/DropdownMenuContext.d.ts +34 -1
  4. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  5. package/dist/DropdownMenu/DropdownMenuContext.js +15 -0
  6. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +21 -2
  7. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  8. package/dist/DropdownMenu/DropdownMenuSubMenu.js +375 -42
  9. package/dist/DropdownMenu/menuRegionStops.d.ts +2 -2
  10. package/dist/DropdownMenu/menuRegionStops.js +2 -2
  11. package/dist/TimeInput/TimeInput.d.ts +2 -1
  12. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  13. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  14. package/dist/Tooltip/Tooltip.js +36 -14
  15. package/dist/astryx.css +1 -0
  16. package/dist/hooks/useMenuPress.d.ts +11 -1
  17. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  18. package/dist/hooks/useMenuPress.js +43 -5
  19. package/dist/utils/timeParser.d.ts +4 -0
  20. package/dist/utils/timeParser.d.ts.map +1 -1
  21. package/dist/utils/timeParser.js +9 -2
  22. package/package.json +3 -3
  23. package/src/ContextMenu/ContextMenu.test.tsx +41 -0
  24. package/src/DropdownMenu/DropdownMenu.spec.md +33 -2
  25. package/src/DropdownMenu/DropdownMenu.tsx +79 -8
  26. package/src/DropdownMenu/DropdownMenuContext.public.test.ts +22 -0
  27. package/src/DropdownMenu/DropdownMenuContext.tsx +39 -1
  28. package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +16 -1
  29. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +623 -1
  30. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +556 -99
  31. package/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts +267 -0
  32. package/src/DropdownMenu/menuRegionStops.ts +2 -2
  33. package/src/DropdownMenu/modules/useMenuPress.spec.md +9 -0
  34. package/src/SideNav/SideNav.test.tsx +35 -0
  35. package/src/TimeInput/TimeInput.doc.mjs +5 -3
  36. package/src/TimeInput/TimeInput.test.tsx +20 -0
  37. package/src/TimeInput/TimeInput.tsx +2 -1
  38. package/src/Tooltip/Tooltip.test.tsx +51 -6
  39. package/src/Tooltip/Tooltip.tsx +41 -18
  40. package/src/Tooltip/__tests__/Tooltip.a11y.chromium.spec.ts +129 -0
  41. package/src/Tooltip/__tests__/Tooltip.a11y.renders.tsx +86 -0
  42. package/src/Tooltip/__tests__/Tooltip.a11y.states.ts +132 -0
  43. package/src/Tooltip/__tests__/Tooltip.a11y.test.tsx +126 -0
  44. package/src/hooks/useMenuPress.ts +45 -7
  45. package/src/utils/timeParser.test.ts +11 -0
  46. package/src/utils/timeParser.ts +9 -2
@@ -97,24 +97,46 @@ export function Tooltip({
97
97
  if (!anchorRef) {
98
98
  return;
99
99
  }
100
+ const attach = el => {
101
+ // Use combined ref for position + interaction
102
+ tooltip.ref(el);
103
+
104
+ // Set aria-describedby, merging with existing
105
+ const existingDescribedBy = el.getAttribute('aria-describedby');
106
+ el.setAttribute('aria-describedby', mergeIds(existingDescribedBy, tooltip.describedBy) ?? '');
107
+ return () => {
108
+ tooltip.ref(null);
109
+ if (existingDescribedBy) {
110
+ el.setAttribute('aria-describedby', existingDescribedBy);
111
+ } else {
112
+ el.removeAttribute('aria-describedby');
113
+ }
114
+ };
115
+ };
100
116
  const el = anchorRef.current;
101
- if (!el) {
102
- return;
117
+ if (el) {
118
+ return attach(el);
103
119
  }
104
120
 
105
- // Use combined ref for position + interaction
106
- tooltip.ref(el);
107
-
108
- // Set aria-describedby, merging with existing
109
- const existingDescribedBy = el.getAttribute('aria-describedby');
110
- el.setAttribute('aria-describedby', mergeIds(existingDescribedBy, tooltip.describedBy) ?? '');
111
- return () => {
112
- tooltip.ref(null);
113
- if (existingDescribedBy) {
114
- el.setAttribute('aria-describedby', existingDescribedBy);
115
- } else {
116
- el.removeAttribute('aria-describedby');
121
+ // React attaches an ancestor's ref after its descendants' layout effects,
122
+ // so a tooltip rendered inside its own anchor (SideNavItem's collapsed
123
+ // rail tooltip) still sees null here on mount, and this effect's deps
124
+ // never change to re-run it. Re-check once the commit has attached all
125
+ // refs; if the anchor is still gone, this tooltip has no anchor, as before.
126
+ let detach;
127
+ let cancelled = false;
128
+ queueMicrotask(() => {
129
+ if (cancelled) {
130
+ return;
117
131
  }
132
+ const el = anchorRef.current;
133
+ if (el) {
134
+ detach = attach(el);
135
+ }
136
+ });
137
+ return () => {
138
+ cancelled = true;
139
+ detach?.();
118
140
  };
119
141
  }, [anchorRef, tooltip.ref, tooltip.describedBy]);
120
142
 
package/dist/astryx.css CHANGED
@@ -1537,6 +1537,7 @@
1537
1537
  .x1sad4of.x1sad4of:where(:not(:disabled,[aria-disabled="true"]))[data-astryx-press="on"]:not(#\#):not(#\#):not(#\#){--_press-overlay:var(--_press-paint)}
1538
1538
  .x1btxeh5:has(input:focus-visible):not(#\#):not(#\#):not(#\#){box-shadow:inset 0 0 0 2px var(--color-accent)}
1539
1539
  .xsw3flo:has(> div > .astryx-layout-content):not(#\#):not(#\#):not(#\#){container-type:inline-size}
1540
+ .x1ab7gxt:has(~ * > [data-astryx-submenu-regions="view"]:not([hidden])):not(#\#):not(#\#):not(#\#){display:none}
1540
1541
  .x92e2el:has(> [data-astryx-edge-comp]:last-child):not(#\#):not(#\#):not(#\#){margin-inline-end:var(--x-4drmnv)}
1541
1542
  .x1kftj4p:has(> [data-astryx-edge-comp]:first-child):not(#\#):not(#\#):not(#\#){margin-inline-start:var(--x-126w13d)}
1542
1543
  .x1uy843r:has(:focus-visible):not(#\#):not(#\#):not(#\#){outline-color:var(--focus-outline-color)}
@@ -32,7 +32,9 @@
32
32
  *
33
33
  * The menu root is marked `data-astryx-menu-press` so a nested flyout (a DOM
34
34
  * descendant of its parent menu) can tell which level owns a press, and so a
35
- * surface can be recognised as carrying this model.
35
+ * surface can be recognised as carrying this model. A menu the root renders
36
+ * beside itself names the root with `data-astryx-menu-press-surface` and counts
37
+ * as nested in it.
36
38
  *
37
39
  * SYNC: When modified, update:
38
40
  * - /packages/core/src/hooks/index.ts
@@ -44,6 +46,14 @@ import { type RefObject } from 'react';
44
46
  import { type MenuPressPointerType } from './menuPressGesture';
45
47
  /** Marks a menu root that carries this press model. */
46
48
  export declare const MENU_PRESS_MARKER = "data-astryx-menu-press";
49
+ /**
50
+ * Carried by a menu root that another root renders beside itself rather than
51
+ * inside it, naming that root's `id`: the menu of a sub-menu with a header or
52
+ * footer, in a dialog beside the root's menu, is nested in that root for the
53
+ * press model, as a flyout inside the root's DOM is. Nothing else beside the
54
+ * root carries it, so a picker placed in a header or footer stays its own root.
55
+ */
56
+ export declare const MENU_PRESS_SURFACE_ATTR = "data-astryx-menu-press-surface";
47
57
  /** How long after a tracked release the browser's click is still swallowed. */
48
58
  export declare const MENU_PRESS_STRAY_CLICK_MS = 400;
49
59
  /** How long a finger must rest on the trigger before the menu opens under it. */
@@ -1 +1 @@
1
- {"version":3,"file":"useMenuPress.d.ts","sourceRoot":"","sources":["../../src/hooks/useMenuPress.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAEH,OAAO,EAA0C,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAE9E,OAAO,EAML,KAAK,oBAAoB,EAC1B,MAAM,oBAAoB,CAAC;AAG5B,uDAAuD;AACvD,eAAO,MAAM,iBAAiB,2BAA2B,CAAC;AAG1D,+EAA+E;AAC/E,eAAO,MAAM,yBAAyB,MAAM,CAAC;AAC7C,iFAAiF;AACjF,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAC5C,oFAAoF;AACpF,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAchD;;;;;GAKG;AACH,wBAAgB,qBAAqB,IAAI,OAAO,CAE/C;AA6DD,0DAA0D;AAC1D,wBAAgB,uBAAuB,IAAI,IAAI,CAE9C;AAMD,MAAM,WAAW,mBAAmB;IAClC,+EAA+E;IAC/E,OAAO,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IACvC,uEAAuE;IACvE,UAAU,CAAC,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAC3C;;;OAGG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,CAAC,WAAW,EAAE,oBAAoB,KAAK,OAAO,CAAC;IAChE;;;;;OAKG;IACH,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,WAAW,GAAG,IAAI,KAAK,IAAI,CAAC;IAChD;;;;OAIG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,WAAW,EAAE,OAAO,EAAE,YAAY,KAAK,IAAI,CAAC;IAC/D,2EAA2E;IAC3E,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,WAAW,GAAG,IAAI,CAAC;IACvC;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,OAAO,GAAG,IAAI,CAAC;IACnD,mBAAmB;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,+CAA+C;IAC/C,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,kBAAkB;IACjC,sEAAsE;IACtE,SAAS,EAAE;QACT,aAAa,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,YAAY,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;QAChE,CAAC,iBAAiB,CAAC,EAAE,EAAE,CAAC;KACzB,CAAC;IACF,yEAAyE;IACzE,YAAY,EAAE;QACZ,aAAa,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,YAAY,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;QAChE,aAAa,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,UAAU,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;KAC/D,CAAC;IACF;;;;OAIG;IACH,uBAAuB,EAAE,MAAM,OAAO,CAAC;IACvC,uEAAuE;IACvE,MAAM,EAAE,MAAM,IAAI,CAAC;CACpB;AAsCD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,mBAAmB,GAAG,kBAAkB,CA2iB7E"}
1
+ {"version":3,"file":"useMenuPress.d.ts","sourceRoot":"","sources":["../../src/hooks/useMenuPress.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,OAAO,EAA0C,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAE9E,OAAO,EAML,KAAK,oBAAoB,EAC1B,MAAM,oBAAoB,CAAC;AAG5B,uDAAuD;AACvD,eAAO,MAAM,iBAAiB,2BAA2B,CAAC;AAE1D;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,mCAAmC,CAAC;AAExE,+EAA+E;AAC/E,eAAO,MAAM,yBAAyB,MAAM,CAAC;AAC7C,iFAAiF;AACjF,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAC5C,oFAAoF;AACpF,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAchD;;;;;GAKG;AACH,wBAAgB,qBAAqB,IAAI,OAAO,CAE/C;AA6DD,0DAA0D;AAC1D,wBAAgB,uBAAuB,IAAI,IAAI,CAE9C;AAMD,MAAM,WAAW,mBAAmB;IAClC,+EAA+E;IAC/E,OAAO,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IACvC,uEAAuE;IACvE,UAAU,CAAC,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAC3C;;;OAGG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,CAAC,WAAW,EAAE,oBAAoB,KAAK,OAAO,CAAC;IAChE;;;;;OAKG;IACH,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,WAAW,GAAG,IAAI,KAAK,IAAI,CAAC;IAChD;;;;OAIG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,WAAW,EAAE,OAAO,EAAE,YAAY,KAAK,IAAI,CAAC;IAC/D,2EAA2E;IAC3E,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,WAAW,GAAG,IAAI,CAAC;IACvC;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,OAAO,GAAG,IAAI,CAAC;IACnD,mBAAmB;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,+CAA+C;IAC/C,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,kBAAkB;IACjC,sEAAsE;IACtE,SAAS,EAAE;QACT,aAAa,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,YAAY,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;QAChE,CAAC,iBAAiB,CAAC,EAAE,EAAE,CAAC;KACzB,CAAC;IACF,yEAAyE;IACzE,YAAY,EAAE;QACZ,aAAa,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,YAAY,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;QAChE,aAAa,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,UAAU,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;KAC/D,CAAC;IACF;;;;OAIG;IACH,uBAAuB,EAAE,MAAM,OAAO,CAAC;IACvC,uEAAuE;IACvE,MAAM,EAAE,MAAM,IAAI,CAAC;CACpB;AAkED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,mBAAmB,GAAG,kBAAkB,CA2iB7E"}
@@ -36,7 +36,9 @@
36
36
  *
37
37
  * The menu root is marked `data-astryx-menu-press` so a nested flyout (a DOM
38
38
  * descendant of its parent menu) can tell which level owns a press, and so a
39
- * surface can be recognised as carrying this model.
39
+ * surface can be recognised as carrying this model. A menu the root renders
40
+ * beside itself names the root with `data-astryx-menu-press-surface` and counts
41
+ * as nested in it.
40
42
  *
41
43
  * SYNC: When modified, update:
42
44
  * - /packages/core/src/hooks/index.ts
@@ -52,6 +54,14 @@ import { useLongPress } from "./useLongPress.js";
52
54
  /** Marks a menu root that carries this press model. */
53
55
  export const MENU_PRESS_MARKER = 'data-astryx-menu-press';
54
56
  const MENU_PRESS_ROOT_SELECTOR = `[${MENU_PRESS_MARKER}]`;
57
+ /**
58
+ * Carried by a menu root that another root renders beside itself rather than
59
+ * inside it, naming that root's `id`: the menu of a sub-menu with a header or
60
+ * footer, in a dialog beside the root's menu, is nested in that root for the
61
+ * press model, as a flyout inside the root's DOM is. Nothing else beside the
62
+ * root carries it, so a picker placed in a header or footer stays its own root.
63
+ */
64
+ export const MENU_PRESS_SURFACE_ATTR = 'data-astryx-menu-press-surface';
55
65
 
56
66
  /** How long after a tracked release the browser's click is still swallowed. */
57
67
  export const MENU_PRESS_STRAY_CLICK_MS = 400;
@@ -153,13 +163,41 @@ function isRowFocusable(row) {
153
163
  function getOutermostRoot(menu) {
154
164
  let root = menu;
155
165
  for (;;) {
156
- const parent = root.parentElement?.closest(MENU_PRESS_ROOT_SELECTOR);
157
- if (parent == null) {
166
+ const parent = root.parentElement?.closest(MENU_PRESS_ROOT_SELECTOR) ?? getSurfaceOwner(root);
167
+ if (parent == null || parent === root) {
158
168
  return root;
159
169
  }
160
170
  root = parent;
161
171
  }
162
172
  }
173
+
174
+ /**
175
+ * The menu root that renders `menu` beside itself, if any (see
176
+ * `MENU_PRESS_SURFACE_ATTR`). Read off the menu itself, never an ancestor, so
177
+ * a root placed in a region of that surface is not adopted.
178
+ */
179
+ function getSurfaceOwner(menu) {
180
+ const ownerId = menu.getAttribute(MENU_PRESS_SURFACE_ATTR);
181
+ if (!ownerId) {
182
+ return null;
183
+ }
184
+ const owner = menu.ownerDocument.getElementById(ownerId);
185
+ return owner?.matches(MENU_PRESS_ROOT_SELECTOR) === true ? owner : null;
186
+ }
187
+
188
+ /**
189
+ * Whether `element` is inside the menu tree whose outermost root is `root`:
190
+ * inside the root itself, or inside a menu nested in it through a surface
191
+ * the root renders beside itself. Anything else in such a surface (a header
192
+ * field, say) is outside the menu, as the root's own regions are.
193
+ */
194
+ function isInMenuTree(element, root) {
195
+ if (root.contains(element)) {
196
+ return true;
197
+ }
198
+ const owner = element.closest(MENU_PRESS_ROOT_SELECTOR);
199
+ return owner != null && getOutermostRoot(owner) === root;
200
+ }
163
201
  // =============================================================================
164
202
  // Hook
165
203
  // =============================================================================
@@ -223,13 +261,13 @@ export function useMenuPress(options) {
223
261
  element = event.target instanceof Element ? event.target : null;
224
262
  }
225
263
  const root = menu == null ? null : getOutermostRoot(menu);
226
- const isInMenu = root != null && element != null && root.contains(element);
264
+ const isInMenu = root != null && element != null && isInMenuTree(element, root);
227
265
  const trigger = triggerRef?.current ?? null;
228
266
  const isOnTrigger = trigger != null && element != null && trigger.contains(element);
229
267
  let row = null;
230
268
  if (isInMenu && element != null) {
231
269
  row = element.closest(itemSelector);
232
- if (row != null && !root.contains(row)) {
270
+ if (row != null && !isInMenuTree(row, root)) {
233
271
  row = null;
234
272
  }
235
273
  }
@@ -70,6 +70,10 @@ export declare function isTimeInRange(time: ISOTimeString, min?: ISOTimeString,
70
70
  export declare function clampTime(time: ISOTimeString, min?: ISOTimeString, max?: ISOTimeString, includeSeconds?: boolean): ISOTimeString;
71
71
  /**
72
72
  * Adjusts a time by adding/subtracting minutes.
73
+ *
74
+ * `deltaMinutes` is a whole number of minutes. A fractional delta leaves the
75
+ * time unchanged rather than returning a value that is no longer a time —
76
+ * `HH:MM` has no place for a fraction of a minute.
73
77
  */
74
78
  export declare function adjustTime(time: ISOTimeString, deltaMinutes: number, includeSeconds?: boolean): ISOTimeString;
75
79
  //# sourceMappingURL=timeParser.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"timeParser.d.ts","sourceRoot":"","sources":["../../src/utils/timeParser.ts"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AAEH;;;GAGG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG;IAAC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAA;CAAC,CAAC;AAEzE;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAMvE;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CA6B5D;AAED;;GAEG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,UAAU,EAChB,cAAc,GAAE,OAAe,GAC9B,aAAa,CAUf;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,aAAa,GAAG,SAAS,EAC/B,cAAc,GAAE,OAAe,GAC9B,MAAM,CAqBR;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,aAAa,GAAG,SAAS,EAC/B,cAAc,GAAE,OAAe,GAC9B,MAAM,CAmBR;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,EACb,cAAc,GAAE,OAAe,GAC9B,aAAa,GAAG,IAAI,CA6HtB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CACzB,CAAC,EAAE,aAAa,GAAG,SAAS,EAC5B,CAAC,EAAE,aAAa,GAAG,SAAS,GAC3B,MAAM,CA4BR;AAED;;GAEG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,aAAa,EACnB,GAAG,CAAC,EAAE,aAAa,EACnB,GAAG,CAAC,EAAE,aAAa,GAClB,OAAO,CAQT;AAED;;GAEG;AACH,wBAAgB,SAAS,CACvB,IAAI,EAAE,aAAa,EACnB,GAAG,CAAC,EAAE,aAAa,EACnB,GAAG,CAAC,EAAE,aAAa,EACnB,cAAc,GAAE,OAAe,GAC9B,aAAa,CAoBf;AAED;;GAEG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,aAAa,EACnB,YAAY,EAAE,MAAM,EACpB,cAAc,GAAE,OAAe,GAC9B,aAAa,CAwBf"}
1
+ {"version":3,"file":"timeParser.d.ts","sourceRoot":"","sources":["../../src/utils/timeParser.ts"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AAEH;;;GAGG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG;IAAC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAA;CAAC,CAAC;AAEzE;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAMvE;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CA6B5D;AAED;;GAEG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,UAAU,EAChB,cAAc,GAAE,OAAe,GAC9B,aAAa,CAUf;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,aAAa,GAAG,SAAS,EAC/B,cAAc,GAAE,OAAe,GAC9B,MAAM,CAqBR;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,aAAa,GAAG,SAAS,EAC/B,cAAc,GAAE,OAAe,GAC9B,MAAM,CAmBR;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,EACb,cAAc,GAAE,OAAe,GAC9B,aAAa,GAAG,IAAI,CA6HtB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CACzB,CAAC,EAAE,aAAa,GAAG,SAAS,EAC5B,CAAC,EAAE,aAAa,GAAG,SAAS,GAC3B,MAAM,CA4BR;AAED;;GAEG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,aAAa,EACnB,GAAG,CAAC,EAAE,aAAa,EACnB,GAAG,CAAC,EAAE,aAAa,GAClB,OAAO,CAQT;AAED;;GAEG;AACH,wBAAgB,SAAS,CACvB,IAAI,EAAE,aAAa,EACnB,GAAG,CAAC,EAAE,aAAa,EACnB,GAAG,CAAC,EAAE,aAAa,EACnB,cAAc,GAAE,OAAe,GAC9B,aAAa,CAoBf;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,aAAa,EACnB,YAAY,EAAE,MAAM,EACpB,cAAc,GAAE,OAAe,GAC9B,aAAa,CA2Bf"}
@@ -313,6 +313,10 @@ export function clampTime(time, min, max, includeSeconds = false) {
313
313
 
314
314
  /**
315
315
  * Adjusts a time by adding/subtracting minutes.
316
+ *
317
+ * `deltaMinutes` is a whole number of minutes. A fractional delta leaves the
318
+ * time unchanged rather than returning a value that is no longer a time —
319
+ * `HH:MM` has no place for a fraction of a minute.
316
320
  */
317
321
  export function adjustTime(time, deltaMinutes, includeSeconds = false) {
318
322
  const parsed = parseISOTime(time);
@@ -321,8 +325,11 @@ export function adjustTime(time, deltaMinutes, includeSeconds = false) {
321
325
  }
322
326
 
323
327
  // A non-finite delta would spin the wrap-around forever (-Infinity) or
324
- // produce "NaN:NaN" (NaN) — return the input unchanged instead.
325
- if (!Number.isFinite(deltaMinutes)) {
328
+ // produce "NaN:NaN" (NaN), and a fractional one is never rounded on the way
329
+ // out — formatISOTime pads "30.5" to two characters and leaves it, so the
330
+ // caller gets back a value that is no longer a time. Steps are whole
331
+ // minutes: return the input unchanged instead.
332
+ if (!Number.isInteger(deltaMinutes)) {
326
333
  return time;
327
334
  }
328
335
  let totalMinutes = parsed.hour * 60 + parsed.minute + deltaMinutes;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.7.3-canary.b74cf21",
3
+ "version": "0.7.3-canary.cbe384c",
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",
@@ -792,8 +792,8 @@
792
792
  "react-dom": ">=19.0.0"
793
793
  },
794
794
  "devDependencies": {
795
- "@astryxdesign/a11y-spec": "0.7.3-canary.b74cf21",
796
- "@astryxdesign/cli": "0.7.3-canary.b74cf21",
795
+ "@astryxdesign/a11y-spec": "0.7.3-canary.cbe384c",
796
+ "@astryxdesign/cli": "0.7.3-canary.cbe384c",
797
797
  "@babel/cli": "^7.29.7",
798
798
  "@babel/core": "^7.29.7",
799
799
  "@babel/preset-react": "^7.29.7",
@@ -24,6 +24,7 @@ import {
24
24
  ContextMenuRadioItem,
25
25
  } from './index';
26
26
  import {DropdownMenuItem} from '../DropdownMenu/DropdownMenuItem';
27
+ import {DropdownMenuSubMenu} from '../DropdownMenu/DropdownMenuSubMenu';
27
28
  import {Divider} from '../Divider';
28
29
  import {useLayerDismissal} from '../Layer/useLayerDismissal';
29
30
  import {warmMenuBottomSheet} from '../DropdownMenu/__tests__/warmMenuBottomSheet';
@@ -706,6 +707,46 @@ describe('ContextMenu dividers', () => {
706
707
  });
707
708
 
708
709
  describe('ContextMenu compound mode', () => {
710
+ it('shows a sub-menu with a header or footer as its rows alone, and says why in development', async () => {
711
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
712
+ try {
713
+ render(
714
+ <ContextMenu
715
+ menuContent={
716
+ <>
717
+ <DropdownMenuItem label="Rename" onClick={() => {}} />
718
+ <DropdownMenuSubMenu
719
+ label="Labels"
720
+ header={<input aria-label="Filter labels" />}
721
+ footer={<button type="button">Create label</button>}>
722
+ <DropdownMenuItem label="Bug" onClick={() => {}} />
723
+ </DropdownMenuSubMenu>
724
+ </>
725
+ }>
726
+ <div>Right-click me</div>
727
+ </ContextMenu>,
728
+ );
729
+ fireEvent.contextMenu(screen.getByText('Right-click me'));
730
+ const row = screen.getByRole('menuitem', {name: /Labels/, hidden: true});
731
+ // No region can sit outside this menu, so none renders inside it.
732
+ expect(row).toHaveAttribute('aria-haspopup', 'menu');
733
+ fireEvent.click(row, {detail: 1});
734
+ await waitFor(() => expect(row).toHaveAttribute('aria-expanded', 'true'));
735
+ expect(screen.queryByRole('dialog', {hidden: true})).toBeNull();
736
+ expect(
737
+ screen.queryByRole('textbox', {name: 'Filter labels', hidden: true}),
738
+ ).toBeNull();
739
+ expect(
740
+ screen.getByRole('menuitem', {name: 'Bug', hidden: true}),
741
+ ).toBeInTheDocument();
742
+ expect(warn).toHaveBeenCalledWith(
743
+ expect.stringContaining('DropdownMenuSubMenu: header and footer'),
744
+ );
745
+ } finally {
746
+ warn.mockRestore();
747
+ }
748
+ });
749
+
709
750
  it('renders menuContent as menu items', () => {
710
751
  render(
711
752
  <ContextMenu
@@ -86,7 +86,7 @@ Consumer migration instructions belong in consumer docs and release notes.
86
86
  ## Public concepts
87
87
 
88
88
  Consumer props, item shapes, subcomponents, and presentation policy remain
89
- documented in `DropdownMenu.doc.mjs` and the subcomponent docs. Three component-local concepts are added, by DEC-2, DEC-3 and DEC-8; each
89
+ documented in `DropdownMenu.doc.mjs` and the subcomponent docs. Four component-local concepts are added, by DEC-2, DEC-3, DEC-8 and proposed DEC-13; each
90
90
  keeps its released default.
91
91
 
92
92
  | Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
@@ -94,6 +94,7 @@ keeps its released default.
94
94
  | Menu height cap | `menuMaxHeight`: a number of pixels | Lifts the 300px cap for a menu that must fit its rows; the viewport still bounds it. | Pointer presentation | `300px` | `component:DropdownMenu` | stable | Ignored by the touch sheet. |
95
95
  | Trigger source | `button` (Button props) or `renderTrigger` (render prop) | Which control the menu hangs off. `trigger` receives `DropdownMenuTriggerProps` — the press model, keyboard opens, toggle click and ARIA wiring — and names the menu. | Both presentations | `button` | `component:DropdownMenu` | stable | Both given: `trigger` wins and a dev warning fires. |
96
96
  | Header and footer | `header`, `footer`: any React node | Content above and below the rows, beside the menu inside the same popover or sheet — never inside the `role="menu"` element. With either, the pointer popover is a modal dialog around the menu. | Both presentations | none | `component:DropdownMenu` | stable | A false or empty value renders no region. |
97
+ | Sub-menu regions | `header`, `footer` on a sub-menu: any React node | Content above and below a sub-menu's rows, beside its menu inside its own surface; with either, that surface is a modal dialog named by the row (proposed FR20). | Popover presentation | none | `component:DropdownMenu` | proposed | A false or empty value renders no region. |
97
98
 
98
99
  ## Behavioral and layout contract
99
100
 
@@ -119,6 +120,7 @@ keeps its released default.
119
120
  | FR15 | With `header` or `footer`, the pointer popover is a `role="dialog"` with `aria-modal="true"`, named like the menu, holding the header, then the `role="menu"` element, then the footer; the menu element holds only its rows. The trigger says `aria-haspopup="dialog"` and its `aria-controls` names the popover that holds the dialog. A region keeps its own keys and presses: neither the menu's key handling nor its press model claims an event that begins there. The menu is one Tab stop between the regions. Tab and Shift+Tab move through the header's controls, the menu, then the footer's controls, and wrap at either end; a control that is hidden, or under a hidden ancestor within its region, is not a stop. From a row, or a row of an open sub-menu, Tab and Shift+Tab close every open flyout and continue from the menu's stop. With no other stop, focus stays in the menu. Escape closes from anywhere in the popover, and focus returns to the trigger. The bottom sheet, already a dialog, shows the header above the actions and the footer below them. Without either prop, the menu renders and behaves exactly as it does without them: no dialog, `aria-haspopup="menu"`, and Tab closes the menu. | DEC-8, DEC-10, docs, and tests | Proposed; awaiting owner approval |
120
121
  | FR16 | `DropdownMenuCheckboxItem` MUST expose one `menuitemcheckbox` row whose `aria-checked` equals its controlled value; enabled activation reports `!value`, stays open by default, and composes consumer click and pointer-move handlers with owned activation and hover focus. Disabled activation calls no consumer activation handler. | Released subcomponent API; `architecture:public-component-api/INV5–INV8`; `DropdownMenuSelectable.test.tsx` | Verified current behavior; consumer handlers are preserved |
121
122
  | FR17 | Disabled menu rows SHOULD remain keyboard-discoverable while activation stays blocked. Current shared `useListFocus` deliberately skips every `aria-disabled` row, including checkbox items. | WAI-ARIA Menu pattern; current shared navigation source/tests | Objective mismatch; shared mechanism remains undecided in OQ1 |
123
+ | FR20 | With `header` or `footer` on a sub-menu (`DropdownMenuSubMenu`), its surface is a modal dialog named by its row (AR7), holding the header, the sub-menu's `role="menu"` (rows only), then the footer. It sits in a host `DropdownMenu` renders beside its menu, never inside a menu; under a root with no such host (`ContextMenu`) the sub-menu shows its rows alone. On a fine pointer the dialog is the flyout; on a compact touch display, the drilled-in view, which replaces the root's menu and regions until popped. A region keeps its own keys and presses. The rows are one Tab stop: Tab and Shift+Tab walk the header's controls, the rows, then the footer's, and wrap; from a deeper flyout, that flyout closes and the walk goes on from the rows. An open lands as for any sub-menu (FR11, FR12), not on a region. Escape in the dialog, outside an IME composition and unless a control there handled it, returns focus to the row and leaves the root open. A pointer leaving the flyout keeps it open until focus leaves, and it closes with the flyout holding its row. Without either prop nothing changes. | Proposed DEC-13; FR15; AR5 | Proposed; verified in jsdom and Chromium, pending owner review |
122
124
 
123
125
  ### Allowed variation
124
126
 
@@ -200,13 +202,20 @@ visibility helper.
200
202
  trigger keeps `aria-expanded`; its `aria-haspopup` says `dialog`, and its
201
203
  `aria-controls` names the popover that holds the dialog rather than the
202
204
  menu, as Popover's own trigger does.
203
-
204
205
  - **AR6 — Checkbox rows remain one control.** The row owns
205
206
  `menuitemcheckbox`, its accessible name, checked state, focus, and activation;
206
207
  Indicator's checkbox marker is decorative. Disabled activation is inert.
207
208
  Current shared navigation skips disabled rows instead of making them
208
209
  discoverable as the WAI-ARIA Menu pattern recommends; FR17 and OQ1 retain that
209
210
  gap without inventing a component-local focus path.
211
+ - **AR7 (proposed) — A sub-menu with a header or footer adopts the same APG
212
+ Dialog (Modal) parts for its own surface** (`spec:AST-020/FR15`):
213
+ `role="dialog"`, `aria-modal="true"`, and an accessible name, its row's;
214
+ focus moves into the dialog on open, to the sub-menu's first row or its
215
+ menu as FR11 and FR12 already state; Tab and Shift+Tab cycle within it
216
+ (FR20); Escape closes it and focus returns to the row. The sub-menu's menu
217
+ inside keeps AR3. The row keeps `aria-expanded`; its `aria-haspopup` says
218
+ `dialog` and its `aria-controls` names the dialog.
210
219
 
211
220
  ## Design relationships
212
221
 
@@ -324,6 +333,7 @@ than adding a DropdownMenu-owned heading target.
324
333
  | FR15 | `DropdownMenu.test.tsx` header and footer suite; `__tests__/MenuHeaderFooter.a11y.chromium.spec.ts` in real Chromium | dialog role, modality, name and trigger wiring; regions outside the menu element; typing in a header field; Tab walk header → menu → footer with wrap at both ends; Tab and Shift+Tab from an open sub-menu; hidden and hidden-wrapper controls; Escape from the header; a finger tap on a footer button; bottom sheet order; no regions | A region rendered inside the menu element, a popover with regions that is not a named modal dialog, a trigger still saying `aria-haspopup="menu"` with regions, a header key taken by typeahead, a Tab that leaves the dialog or closes it, focus stranded outside the dialog, or a footer tap swallowed by the press model fails. |
325
334
  | FR16 | `DropdownMenuSelectable.test.tsx`, source inspection, scoped `pr-a11y` over the `a11y/Checkbox pattern` stories, and owned Storybook states | checked, unchecked, disabled, caller click/pointer handlers, marker size and ownership | A dropped consumer handler, wrong checked state, nested semantic checkbox, disabled activation, or menu that closes by default fails. | `audit:DropdownMenuCheckboxItem/behavior` |
326
335
  | FR17, AR6 | Shared navigation source and DropdownMenu keyboard tests | disabled checkbox row first, middle, and last | Current tests prove the shared hook skips the row; no keyboard-discoverability proof exists until OQ1 is settled. | `audit:DropdownMenuCheckboxItem/accessibility` |
336
+ | FR20, AR7 | `DropdownMenuSubMenu.test.tsx` "header and footer" suite; `ContextMenu.test.tsx` rows-alone case; `__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts` in real Chromium (fine pointer, coarse pointer) | flyout and phone view; dialog role, name, row wiring; regions outside menus; Tab walk and wrap from a field, row, deeper flyout; field keys, presses; Escape, IME and handled Escapes; keyboard open; pointer leaving, focus in then out; parent closing a nested flyout; view tracking its regions; ContextMenu rows alone | A region inside a menu element, a surface with regions that is not a named modal dialog, a row still saying `aria-haspopup="menu"`, a field key taken by the menu, a Tab that leaves or closes the dialog, a flyout closed under focus by a pointer leaving, a flyout outliving its parent, or the root menu beside a view, fails. | `audit:DropdownMenu/behavior` |
327
337
 
328
338
  ## Decision log
329
339
 
@@ -541,3 +551,24 @@ the menu. Tab and Shift+Tab wrap within the dialog, as the APG Dialog
541
551
  pattern requires; Escape and a press outside close it. Without either prop
542
552
  the component is unchanged: a Menu Button whose Tab closes the menu. Rejected:
543
553
  closing on Tab past either end, which leaves a modal dialog by Tab.
554
+
555
+ ### DEC-13 (proposed) — A sub-menu's header and footer make its surface a dialog beside the menu
556
+
557
+ **Reference:** `component:DropdownMenu/DEC-13`
558
+ **Decider:** proposed by `vjeux`, 2026-10-14; pending owner review
559
+
560
+ A filter field above a sub-menu's rows, or a door below them, is a control
561
+ that is not a menu item, so DEC-8 and DEC-10 apply to the sub-menu's own
562
+ surface as they apply to the root's: the sub-menu's menu holds only its rows,
563
+ and the surface around it is a named modal dialog whose Tab order wraps. A
564
+ flyout renders inside the menu that holds its row, so a dialog there would
565
+ still sit inside that menu; the `DropdownMenu` therefore renders a host beside its menu,
566
+ inside its popover, and a sub-menu with regions renders its surface there (the
567
+ flyout layer, or the drilled-in view, which then takes the root menu's place).
568
+ Rendered there, a flyout no longer hides with the flyout that holds its row,
569
+ so it closes with it. The pointer leaving that flyout does not close it while
570
+ focus is inside, because what was typed into its field would go with it.
571
+ Rejected: regions inside the flyout beside the sub-menu's menu,
572
+ which puts them inside the parent menu; a separate DropdownMenu for the level,
573
+ which loses the row, the drill-in and the single press model; and closing the
574
+ flyout when the pointer leaves it as any flyout does.
@@ -61,7 +61,10 @@ import {
61
61
  import {
62
62
  DropdownMenuContext,
63
63
  DropdownMenuRootOpenContext,
64
+ DropdownMenuSurfaceContext,
64
65
  type DropdownMenuContextValue,
66
+ type DropdownMenuSurfaceHost,
67
+ type DropdownMenuSurfaceValue,
65
68
  } from './DropdownMenuContext';
66
69
  import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
67
70
  import {useListFocus} from '../hooks/useListFocus';
@@ -112,6 +115,14 @@ const MENU_MAX_BLOCK_SIZE = `min(${MENU_BLOCK_CAP}, ${layerViewportInset.maxBloc
112
115
  const MENU_MAX_BLOCK_SIZE_FALLBACK = `min(${MENU_BLOCK_CAP}, ${layerViewportInset.maxBlockSizeFallback})`;
113
116
  const MENU_TRIGGER_OPEN_BACKGROUND = `linear-gradient(${colorVars['--color-overlay-pressed']}, ${colorVars['--color-overlay-pressed']})`;
114
117
 
118
+ // A sub-menu's drilled-in view with a header or footer renders in the surface
119
+ // host that follows the menu, as the frame marked
120
+ // `data-astryx-submenu-regions="view"` (hidden while a deeper view shows).
121
+ // While one shows, the menu and its header and footer step aside. The rule
122
+ // reads that from the DOM, so no state mirrors which view shows.
123
+ const STEPS_ASIDE_FOR_VIEW =
124
+ ':has(~ * > [data-astryx-submenu-regions="view"]:not([hidden]))';
125
+
115
126
  const styles = stylex.create({
116
127
  triggerOpen: {
117
128
  backgroundImage: MENU_TRIGGER_OPEN_BACKGROUND,
@@ -125,7 +136,12 @@ const styles = stylex.create({
125
136
  },
126
137
  dropdown: {
127
138
  boxSizing: 'border-box',
128
- display: 'flex',
139
+ display: {
140
+ default: 'flex',
141
+ // A sub-menu's drilled-in view with a header or footer shows in its
142
+ // place (see `stepsAsideForView`).
143
+ [STEPS_ASIDE_FOR_VIEW]: 'none',
144
+ },
129
145
  flexDirection: 'column',
130
146
  gap: spacingVars['--spacing-0-5'],
131
147
  // Pointer opens focus this container so subsequent arrow keys and Escape
@@ -175,6 +191,22 @@ const styles = stylex.create({
175
191
  flexShrink: 1,
176
192
  minBlockSize: 0,
177
193
  },
194
+ // Where a sub-menu with a header or footer renders its surface: beside the
195
+ // menu, never inside it. It owns no box, so the popover's layout is
196
+ // unchanged.
197
+ surfaceHost: {
198
+ display: 'contents',
199
+ },
200
+ // While a sub-menu's drilled-in view with its own header or footer shows
201
+ // in the surface host, the header and footer step aside for it, as the menu
202
+ // does (see `dropdown`). Derived from the DOM by the rule itself: the view
203
+ // is the frame in the host that is not hidden.
204
+ stepsAsideForView: {
205
+ display: {
206
+ default: null,
207
+ [STEPS_ASIDE_FOR_VIEW]: 'none',
208
+ },
209
+ },
178
210
  region: {
179
211
  flexShrink: 0,
180
212
  padding: spacingVars['--spacing-1'],
@@ -1455,12 +1487,39 @@ function DropdownMenuPopover({
1455
1487
 
1456
1488
  // The drill-in view stack for sub-menus on a phone.
1457
1489
  const {drillIn, wrapContent} = useMenuDrillIn(isOpen);
1490
+ // The surface host is owned through a ref. State keeps only a key that
1491
+ // changes whenever a host element attaches, the host included when it
1492
+ // moves (a header or footer coming or going), so sub-menus re-render into
1493
+ // the element that is mounted.
1494
+ const surfaceHostRef = useRef<HTMLDivElement | null>(null);
1495
+ const [surfaceHostKey, setSurfaceHostKey] = useState(0);
1496
+ const attachSurfaceHost = useCallback((node: HTMLDivElement | null) => {
1497
+ surfaceHostRef.current = node;
1498
+ if (node != null) {
1499
+ setSurfaceHostKey(key => key + 1);
1500
+ }
1501
+ }, []);
1502
+ const surfaceHost = useMemo<DropdownMenuSurfaceHost>(
1503
+ () => ({
1504
+ getElement: () => (surfaceHostKey > 0 ? surfaceHostRef.current : null),
1505
+ }),
1506
+ [surfaceHostKey],
1507
+ );
1458
1508
  const resolvedMaxHeight = menuMaxHeight == null ? null : `${menuMaxHeight}px`;
1459
1509
  // Context for compound items
1460
1510
  const contextValue = useMemo<DropdownMenuContextValue>(
1461
1511
  () => ({closeMenu, menuSize, drillIn, menuLabel}),
1462
1512
  [closeMenu, menuSize, drillIn, menuLabel],
1463
1513
  );
1514
+ // What a sub-menu with a header or footer needs, on an internal context.
1515
+ const surfaceValue = useMemo<DropdownMenuSurfaceValue>(
1516
+ () => ({
1517
+ host: surfaceHost,
1518
+ ownerId: menuId,
1519
+ menuMaxHeight: resolvedMaxHeight ?? undefined,
1520
+ }),
1521
+ [surfaceHost, menuId, resolvedMaxHeight],
1522
+ );
1464
1523
 
1465
1524
  // Resolve menu content: data-driven items become components
1466
1525
  const menuContent = wrapContent(
@@ -1469,7 +1528,13 @@ function DropdownMenuPopover({
1469
1528
  const hasOverflow = useMenuOverflow(listRef, menuContent, popover.isOpen);
1470
1529
 
1471
1530
  // The header and footer sit beside the menu inside the popover, never inside
1472
- // the role="menu" element: a menu holds only its rows.
1531
+ // the role="menu" element: a menu holds only its rows. The surface host
1532
+ // follows the menu, so a sub-menu with a header or footer renders beside the
1533
+ // menu too; while such a sub-menu's drilled-in view shows there, the menu and
1534
+ // the regions step aside (STEPS_ASIDE_FOR_VIEW).
1535
+ const surfaceHostElement = (
1536
+ <div ref={attachSurfaceHost} {...stylex.props(styles.surfaceHost)} />
1537
+ );
1473
1538
  const renderWithRegions = (menu: ReactNode): ReactNode =>
1474
1539
  hasRegions ? (
1475
1540
  <div
@@ -1482,7 +1547,7 @@ function DropdownMenuPopover({
1482
1547
  <div
1483
1548
  ref={headerRef}
1484
1549
  onKeyDown={handleRegionKeyDown}
1485
- {...stylex.props(styles.region)}>
1550
+ {...stylex.props(styles.region, styles.stepsAsideForView)}>
1486
1551
  {header}
1487
1552
  </div>
1488
1553
  )}
@@ -1491,13 +1556,17 @@ function DropdownMenuPopover({
1491
1556
  <div
1492
1557
  ref={footerRef}
1493
1558
  onKeyDown={handleRegionKeyDown}
1494
- {...stylex.props(styles.region)}>
1559
+ {...stylex.props(styles.region, styles.stepsAsideForView)}>
1495
1560
  {footer}
1496
1561
  </div>
1497
1562
  )}
1563
+ {surfaceHostElement}
1498
1564
  </div>
1499
1565
  ) : (
1500
- menu
1566
+ <>
1567
+ {menu}
1568
+ {surfaceHostElement}
1569
+ </>
1501
1570
  );
1502
1571
 
1503
1572
  const triggerProps: DropdownMenuTriggerProps = {
@@ -1636,9 +1705,11 @@ function DropdownMenuPopover({
1636
1705
  )}>
1637
1706
  <MenuRegionsContext value={hasRegions}>
1638
1707
  <DropdownMenuContext value={contextValue}>
1639
- <DropdownMenuRootOpenContext value={isOpen}>
1640
- {menuContent}
1641
- </DropdownMenuRootOpenContext>
1708
+ <DropdownMenuSurfaceContext value={surfaceValue}>
1709
+ <DropdownMenuRootOpenContext value={isOpen}>
1710
+ {menuContent}
1711
+ </DropdownMenuRootOpenContext>
1712
+ </DropdownMenuSurfaceContext>
1642
1713
  </DropdownMenuContext>
1643
1714
  </MenuRegionsContext>
1644
1715
  </div>,
@@ -0,0 +1,22 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file DropdownMenuContext.public.test.ts
5
+ * @input The DropdownMenu barrel and its public `DropdownMenuContextValue`
6
+ * @output Guards that the menu context keeps its members and that the internal
7
+ * surface context a sub-menu with a header or footer reads is not exported
8
+ * @position Public API test guarding @astryxdesign/core/DropdownMenu
9
+ */
10
+
11
+ import {describe, expect, expectTypeOf, it} from 'vitest';
12
+ import * as dropdownMenu from './index';
13
+ import type {DropdownMenuContextValue} from './index';
14
+
15
+ describe('DropdownMenu public context', () => {
16
+ it('keeps its members and exports no surface context', () => {
17
+ expectTypeOf<keyof DropdownMenuContextValue>().toEqualTypeOf<
18
+ 'closeMenu' | 'menuSize' | 'drillIn' | 'menuLabel'
19
+ >();
20
+ expect('DropdownMenuSurfaceContext' in dropdownMenu).toBe(false);
21
+ });
22
+ });