@astryxdesign/core 0.7.6-canary.e736b92 → 0.7.6-canary.e7530db

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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file useTriggerMenu.tsx
3
- * @input Uses React, usePopover, SearchSource
3
+ * @input Uses React, usePopover, SearchSource, useBusyAnnouncement
4
4
  * @output Exports useTriggerMenu hook for trigger-based menus in contentEditable
5
5
  * @position Internal hook; consumed by ChatComposerInput
6
6
  *
@@ -1 +1 @@
1
- {"version":3,"file":"useTriggerMenu.d.ts","sourceRoot":"","sources":["../../src/Chat/useTriggerMenu.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAML,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAaf,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,oBAAoB,CAAC;AAGvD,OAAO,KAAK,EAAC,mBAAmB,EAAE,iBAAiB,EAAC,MAAM,qBAAqB,CAAC;AAMhF,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,OAAO,CAAC;IAClB,aAAa,EAAE,mBAAmB,GAAG,IAAI,CAAC;IAC1C,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,OAAO,CAAC;IACnB;;;OAGG;IACH,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,EAAE,mBAAmB,EAAE,CAAC;IACjC,WAAW,EAAE,KAAK,CAAC,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IACpD,aAAa,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAClD,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,YAAY,EAAE,MAAM,IAAI,CAAC;IACzB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,gBAAgB,CAAC;IACxB,gEAAgE;IAChE,WAAW,EAAE,MAAM,IAAI,CAAC;IACxB,+DAA+D;IAC/D,aAAa,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,aAAa,KAAK,OAAO,CAAC;IACnD,sCAAsC;IACtC,UAAU,EAAE,MAAM,SAAS,CAAC;IAC5B,mCAAmC;IACnC,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB;;;;;OAKG;IACH,SAAS,EAAE;QACT,IAAI,EAAE,UAAU,GAAG,SAAS,CAAC;QAC7B,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,eAAe,CAAC,EAAE,OAAO,CAAC;QAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,uBAAuB,CAAC,EAAE,MAAM,CAAC;QACjC,eAAe,CAAC,EAAE,SAAS,CAAC;KAC7B,CAAC;CACH;AAoOD,wBAAgB,cAAc,CAC5B,OAAO,EAAE,qBAAqB,GAC7B,oBAAoB,CAsqBtB"}
1
+ {"version":3,"file":"useTriggerMenu.d.ts","sourceRoot":"","sources":["../../src/Chat/useTriggerMenu.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAML,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAaf,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,oBAAoB,CAAC;AAGvD,OAAO,KAAK,EAAC,mBAAmB,EAAE,iBAAiB,EAAC,MAAM,qBAAqB,CAAC;AAMhF,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,OAAO,CAAC;IAClB,aAAa,EAAE,mBAAmB,GAAG,IAAI,CAAC;IAC1C,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,OAAO,CAAC;IACnB;;;OAGG;IACH,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,EAAE,mBAAmB,EAAE,CAAC;IACjC,WAAW,EAAE,KAAK,CAAC,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IACpD,aAAa,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAClD,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,YAAY,EAAE,MAAM,IAAI,CAAC;IACzB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,gBAAgB,CAAC;IACxB,gEAAgE;IAChE,WAAW,EAAE,MAAM,IAAI,CAAC;IACxB,+DAA+D;IAC/D,aAAa,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,aAAa,KAAK,OAAO,CAAC;IACnD,sCAAsC;IACtC,UAAU,EAAE,MAAM,SAAS,CAAC;IAC5B,mCAAmC;IACnC,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB;;;;;OAKG;IACH,SAAS,EAAE;QACT,IAAI,EAAE,UAAU,GAAG,SAAS,CAAC;QAC7B,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,eAAe,CAAC,EAAE,OAAO,CAAC;QAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,uBAAuB,CAAC,EAAE,MAAM,CAAC;QACjC,eAAe,CAAC,EAAE,SAAS,CAAC;KAC7B,CAAC;CACH;AAoOD,wBAAgB,cAAc,CAC5B,OAAO,EAAE,qBAAqB,GAC7B,oBAAoB,CA4pBtB"}
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file useTriggerMenu.tsx
7
- * @input Uses React, usePopover, SearchSource
7
+ * @input Uses React, usePopover, SearchSource, useBusyAnnouncement
8
8
  * @output Exports useTriggerMenu hook for trigger-based menus in contentEditable
9
9
  * @position Internal hook; consumed by ChatComposerInput
10
10
  *
@@ -24,7 +24,7 @@
24
24
  import { useState, useCallback, useRef, useEffect, useId } from 'react';
25
25
  import * as stylex from '@stylexjs/stylex';
26
26
  import { usePopover } from "../Popover/usePopover.js";
27
- import { useAnnounce } from "../hooks/useAnnounce.js";
27
+ import { useBusyAnnouncement } from "../hooks/useBusyAnnouncement.js";
28
28
  import { useHighlightedOptionScroll } from "../hooks/useHighlightedOptionScroll.js";
29
29
  import "../theme/tokens.stylex.js";
30
30
  import { colorVars, spacingVars, radiusVars, typeScaleVars, typographyVars } from "../theme/tokens.stylex.js";
@@ -194,34 +194,16 @@ export function useTriggerMenu(options) {
194
194
  });
195
195
 
196
196
  // The loading row carries no live role: it mounts already holding its text
197
- // and is removed when results arrive, so it would be skipped. The shared
198
- // channel announces each loading episode once instead (spec:AST-086 FR2,
199
- // FR6), once the row has been painted: the second animation frame runs
200
- // after the first paint, and a search that settles before then never shows
201
- // the row, so it announces nothing (FR7). The flag clears when the loading
202
- // row goes away, so the next search is a new occasion.
203
- const announce = useAnnounce();
204
- const hasAnnouncedLoadingRef = useRef(false);
197
+ // and is removed when results arrive, so it would be skipped. "Searching…"
198
+ // is the menu's busy state, so the shared busy path announces each loading
199
+ // episode once (spec:AST-086 FR6, FR8): rerenders and further keystrokes
200
+ // while loading do not repeat it, results arriving re-arm it, and a search
201
+ // that settles before the announcement goes out announces nothing.
205
202
  // Mirrors renderMenu: no row exists without an active trigger or while hidden.
206
203
  const isLoadingRowShown = state.isLoading && !state.isHidden && state.activeTrigger !== null;
207
204
  // One catalog string serves the row and the announcement (FR7).
208
205
  const loadingText = state.activeTrigger?.loadingText ?? t('@astryx.chatTriggerMenu.searching');
209
- useEffect(() => {
210
- if (!isLoadingRowShown) {
211
- hasAnnouncedLoadingRef.current = false;
212
- return;
213
- }
214
- if (hasAnnouncedLoadingRef.current) {
215
- return;
216
- }
217
- let frame = requestAnimationFrame(() => {
218
- frame = requestAnimationFrame(() => {
219
- hasAnnouncedLoadingRef.current = true;
220
- announce(loadingText, 'polite');
221
- });
222
- });
223
- return () => cancelAnimationFrame(frame);
224
- }, [announce, isLoadingRowShown, loadingText]);
206
+ const settleBusyAnnouncement = useBusyAnnouncement(isLoadingRowShown, loadingText, editableRef);
225
207
  const triggerStartRef = useRef(-1);
226
208
  const searchTimeoutRef = useRef(null);
227
209
  const searchRequestIdRef = useRef(0);
@@ -374,6 +356,9 @@ export function useTriggerMenu(options) {
374
356
  return true;
375
357
  };
376
358
  const applyResults = items => {
359
+ // Results that arrive before the busy start is heard withdraw it, so a
360
+ // search that settles on its first frame announces nothing (FR8).
361
+ settleBusyAnnouncement();
377
362
  const isHidden = isUnmatchedMultiWordQuery(trigger, items, false);
378
363
  setState(prev => ({
379
364
  ...prev,
@@ -399,7 +384,7 @@ export function useTriggerMenu(options) {
399
384
  trigger.searchSource.cancel?.();
400
385
  const result = trigger.searchSource.search(query);
401
386
  // A synchronous source already has its results, so it never shows
402
- // or announces the loading row (spec:AST-086 FR7).
387
+ // or announces the loading row.
403
388
  if (!Array.isArray(result)) {
404
389
  setState(prev => ({
405
390
  ...prev,
@@ -435,7 +420,7 @@ export function useTriggerMenu(options) {
435
420
  doSearch();
436
421
  }
437
422
  }
438
- }, [debounceMs, editableRef, triggers, reset, setAnchor, hidePopover, showPopover]);
423
+ }, [debounceMs, editableRef, triggers, reset, setAnchor, hidePopover, showPopover, settleBusyAnnouncement]);
439
424
  const selectItem = useCallback(item => {
440
425
  const trigger = state.activeTrigger;
441
426
  if (!trigger) {
@@ -1 +1 @@
1
- {"version":3,"file":"TopNavMegaMenuFeaturedCard.d.ts","sourceRoot":"","sources":["../../src/TopNav/TopNavMegaMenuFeaturedCard.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,EAAC,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAS5C,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAiD5C,MAAM,WAAW,+BAAgC,SAAQ,SAAS,CAAC,cAAc,CAAC;IAChF,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAChC,kBAAkB;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,wCAAwC;IACxC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mDAAmD;IACnD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,qBAAqB;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oBAAoB;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,uDAAuD;IACvD,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AAMD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,0BAA0B,CAAC,EACzC,GAAG,EACH,KAAK,EACL,WAAW,EACX,KAAK,EACL,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,QAAQ,EACR,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,IAAI,EACR,EAAE,+BAA+B,qBAsCjC;yBAnDe,0BAA0B"}
1
+ {"version":3,"file":"TopNavMegaMenuFeaturedCard.d.ts","sourceRoot":"","sources":["../../src/TopNav/TopNavMegaMenuFeaturedCard.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,EAAC,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAS5C,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAqD5C,MAAM,WAAW,+BAAgC,SAAQ,SAAS,CAAC,cAAc,CAAC;IAChF,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAChC,kBAAkB;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,wCAAwC;IACxC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mDAAmD;IACnD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,qBAAqB;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oBAAoB;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,uDAAuD;IACvD,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AAMD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,0BAA0B,CAAC,EACzC,GAAG,EACH,KAAK,EACL,WAAW,EACX,KAAK,EACL,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,QAAQ,EACR,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,IAAI,EACR,EAAE,+BAA+B,qBAyCjC;yBAtDe,0BAA0B"}
@@ -21,7 +21,7 @@ import React from 'react';
21
21
  import * as stylex from '@stylexjs/stylex';
22
22
  import "../theme/tokens.stylex.js";
23
23
  import { colorVars, spacingVars, typeScaleVars, fontWeightVars } from "../theme/tokens.stylex.js";
24
- import { mergeProps } from "../utils/index.js";
24
+ import { mergeProps, rtlStyles } from "../utils/index.js";
25
25
  import { useLinkComponent } from "../Link/useLinkComponent.js";
26
26
  import { themeProps } from "../utils/themeProps.js";
27
27
 
@@ -34,6 +34,10 @@ const styles = {
34
34
  k1xSpc: "x78zum5",
35
35
  kXwgrk: "xdt5ytf",
36
36
  $$css: true
37
+ },
38
+ linkArrow: {
39
+ k1xSpc: "x1rg5ohu",
40
+ $$css: true
37
41
  }
38
42
  };
39
43
 
@@ -121,7 +125,10 @@ export function TopNavMegaMenuFeaturedCard({
121
125
  ...{
122
126
  className: "x141an7d x2mo6ok x1ltkj2j xjse4m1 x1hl2dhg"
123
127
  },
124
- children: [linkLabel, " \u2192"]
128
+ children: [linkLabel, ' ', /*#__PURE__*/_jsx("span", {
129
+ ...stylex.props(styles.linkArrow, rtlStyles.mirror),
130
+ children: "\u2192"
131
+ })]
125
132
  }), children]
126
133
  })]
127
134
  });
@@ -1 +1 @@
1
- {"version":3,"file":"TreeList.d.ts","sourceRoot":"","sources":["../../src/TreeList/TreeList.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;GAWG;AAEH,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAKf,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAE5C,OAAO,KAAK,EACV,gBAAgB,EAChB,eAAe,EACf,eAAe,EAChB,MAAM,iBAAiB,CAAC;AAIzB,OAAO,EAML,KAAK,cAAc,EAEpB,MAAM,0BAA0B,CAAC;AAMlC,OAAO,EAAC,KAAK,eAAe,EAAE,KAAK,eAAe,EAAC,MAAM,iBAAiB,CAAC;AAE3E,MAAM,WAAW,aAAc,SAAQ,SAAS,CAAC,cAAc,CAAC;IAC9D,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAEhC;;;;OAIG;IACH,cAAc,CAAC,EAAE,cAAc,CAAC;IAEhC;;;OAGG;IACH,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAE1B;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,eAAe,CAAC;IAE1B;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,eAAe,CAAC;IAE1B;;;OAGG;IACH,MAAM,CAAC,EAAE,SAAS,CAAC;IAEnB;;OAEG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AA6HD,wBAAgB,QAAQ,CAAC,EACvB,KAAK,EACL,OAAoB,EACpB,OAAsB,EACtB,MAAM,EACN,MAAM,EACN,SAAS,EACT,KAAK,EACL,aAAa,EAAE,MAAM,EACrB,YAAY,EAAE,SAAS,EACvB,iBAAiB,EAAE,cAAc,EACjC,GAAG,EACH,SAAS,EAAE,aAAa,EACxB,cAAc,EACd,GAAG,SAAS,EACb,EAAE,aAAa,+BA8Xf;yBA7Ye,QAAQ"}
1
+ {"version":3,"file":"TreeList.d.ts","sourceRoot":"","sources":["../../src/TreeList/TreeList.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;GAWG;AAEH,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAKf,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAE5C,OAAO,KAAK,EACV,gBAAgB,EAChB,eAAe,EACf,eAAe,EAChB,MAAM,iBAAiB,CAAC;AAYzB,OAAO,EAML,KAAK,cAAc,EAEpB,MAAM,0BAA0B,CAAC;AAMlC,OAAO,EAAC,KAAK,eAAe,EAAE,KAAK,eAAe,EAAC,MAAM,iBAAiB,CAAC;AAE3E,MAAM,WAAW,aAAc,SAAQ,SAAS,CAAC,cAAc,CAAC;IAC9D,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAEhC;;;;OAIG;IACH,cAAc,CAAC,EAAE,cAAc,CAAC;IAEhC;;;OAGG;IACH,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAE1B;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,eAAe,CAAC;IAE1B;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,eAAe,CAAC;IAE1B;;;OAGG;IACH,MAAM,CAAC,EAAE,SAAS,CAAC;IAEnB;;OAEG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AA6HD,wBAAgB,QAAQ,CAAC,EACvB,KAAK,EACL,OAAoB,EACpB,OAAsB,EACtB,MAAM,EACN,MAAM,EACN,SAAS,EACT,KAAK,EACL,aAAa,EAAE,MAAM,EACrB,YAAY,EAAE,SAAS,EACvB,iBAAiB,EAAE,cAAc,EACjC,GAAG,EACH,SAAS,EAAE,aAAa,EACxB,cAAc,EACd,GAAG,SAAS,EACb,EAAE,aAAa,+BAmcf;yBAlde,QAAQ"}
@@ -24,6 +24,10 @@ import { TreeListItem } from "./TreeListItem.js";
24
24
  import { themeProps } from "../utils/themeProps.js";
25
25
  import { useTreeFocusInternal } from "../hooks/useTreeFocus.js";
26
26
  import { LayoutChangeSnapshot } from "../motion/LayoutChangeSnapshot.js";
27
+ import { moveFocusLeftBehind, noteFocusWithin } from "../utils/focusReturn.js";
28
+ import { useInteractionModalityTracking } from "../utils/interactionModality.js";
29
+ import { useFocusLeavingChange } from "../utils/useFocusLeavingChange.js";
30
+ import { useMergedRefs } from "../hooks/useMergedRefs.js";
27
31
  import { applyLayoutChangeTransition, holdLayoutExit, playLayoutChange, releaseLayoutExit, reportLayoutChange } from "../motion/animatedLayout.js";
28
32
 
29
33
  // =============================================================================
@@ -259,6 +263,58 @@ export function TreeList({
259
263
  });
260
264
  }
261
265
  }, [animatedLayout, expandedIds]);
266
+ // Focus inside a branch's child group when it collapses would be lost once
267
+ // the group goes (or turns inert for its exit). It moves to the collapsing
268
+ // row itself (AR2); focus anywhere else is not moved. The shared sequence
269
+ // notes it in the commit before the DOM changes and moves it once the
270
+ // collapse has committed.
271
+ // The tree's own document's last input decides the moved focus's ring.
272
+ const {
273
+ ref: modalityRef
274
+ } = useInteractionModalityTracking();
275
+ const noteCollapsingFocus = useFocusLeavingChange(expandedIds,
276
+ // Every expanded row whose group holds focus, outermost first, found by
277
+ // walking up from the focused element: which of them collapsed is known
278
+ // only after the commit.
279
+ previous => {
280
+ const tree = treeElementRef.current;
281
+ const active = tree?.ownerDocument.activeElement;
282
+ if (tree == null || active == null || !tree.contains(active)) {
283
+ return null;
284
+ }
285
+ let focus = null;
286
+ const ids = [];
287
+ for (let element = active.parentElement; element != null && element !== tree; element = element.parentElement) {
288
+ const id = element.parentElement?.dataset.treeId;
289
+ if (element.getAttribute('role') === 'group' && id != null && previous.has(id)) {
290
+ focus = noteFocusWithin(element);
291
+ ids.unshift(id);
292
+ }
293
+ }
294
+ return focus == null ? null : {
295
+ focus,
296
+ ids
297
+ };
298
+ }, (noted, expanded) => {
299
+ // The outermost row that collapsed: a row inside it goes with it.
300
+ const id = noted.ids.find(candidate => !expanded.has(candidate));
301
+ if (id == null) {
302
+ return;
303
+ }
304
+ const row = treeElementRef.current?.querySelector(`[data-tree-id="${CSS.escape(id)}"]`);
305
+ const moved = moveFocusLeftBehind(noted.focus, row, row?.querySelector(':scope > [role="group"]') ?? null);
306
+ // The row focus moved to holds the tree's one Tab stop, unless it is
307
+ // disabled (AR1, AR2).
308
+ if (moved && row != null) {
309
+ takeTabStop(row);
310
+ }
311
+ });
312
+ const beforeExpansionChange = useCallback(previous => {
313
+ noteCollapsingFocus(previous);
314
+ if (animatedLayout != null) {
315
+ measureChanges(previous);
316
+ }
317
+ }, [animatedLayout, measureChanges, noteCollapsingFocus]);
262
318
  // Without a handle the chevrons keep the theme's own turn: a spring a
263
319
  // handle set stays only while it is passed (`spec:AST-105` FR2).
264
320
  useLayoutEffect(() => {
@@ -341,7 +397,8 @@ export function TreeList({
341
397
  const {
342
398
  treeRef,
343
399
  handleKeyDown,
344
- handleFocus
400
+ handleFocus,
401
+ takeTabStop
345
402
  } = useTreeFocusInternal({
346
403
  onToggleExpand: handleToggle,
347
404
  onActivate: activateItem,
@@ -355,6 +412,7 @@ export function TreeList({
355
412
  useLayoutEffect(() => {
356
413
  treeElementRef.current = treeRef.current;
357
414
  });
415
+ const mergedTreeRef = useMergedRefs(treeRef, modalityRef);
358
416
  const handleRootKeyDown = useCallback(e => {
359
417
  onKeyDownProp?.(e);
360
418
  if (e.defaultPrevented) {
@@ -429,11 +487,11 @@ export function TreeList({
429
487
  className: "x1p37lm5"
430
488
  },
431
489
  children: header
432
- }), animatedLayout != null && /*#__PURE__*/_jsx(LayoutChangeSnapshot, {
490
+ }), /*#__PURE__*/_jsx(LayoutChangeSnapshot, {
433
491
  value: expandedIds,
434
- onBeforeChange: measureChanges
492
+ onBeforeChange: beforeExpansionChange
435
493
  }), /*#__PURE__*/_jsx("ul", {
436
- ref: treeRef,
494
+ ref: mergedTreeRef,
437
495
  role: "tree",
438
496
  "aria-label": hasHeader ? undefined : ariaLabel,
439
497
  "aria-labelledby": hasHeader ? headerId : ariaLabelledby,
@@ -170,9 +170,19 @@ export type UseTreeFocusInternalOptions = UseTreeFocusOptions & {
170
170
  */
171
171
  matchLabelledBy?: boolean;
172
172
  };
173
+ /** What the package's own components get beyond the public hook. */
174
+ export type UseTreeFocusInternalReturn<T extends HTMLElement = HTMLElement> = UseTreeFocusReturn<T> & {
175
+ /**
176
+ * With `hasRovingTabIndex`, makes `item` the tree's one Tab stop, by the
177
+ * same rule the hook's own moves follow: a disabled or hidden row never
178
+ * takes it, and the stop stays where it is. TreeList calls it when it
179
+ * moves focus to a collapsing row (`component:TreeList` AR2).
180
+ */
181
+ takeTabStop: (item: HTMLElement) => void;
182
+ };
173
183
  /**
174
184
  * The implementation behind {@link useTreeFocus}. Not exported from the
175
185
  * package; the public hook calls it without the internal options.
176
186
  */
177
- export declare function useTreeFocusInternal<T extends HTMLElement = HTMLElement>(options?: UseTreeFocusInternalOptions): UseTreeFocusReturn<T>;
187
+ export declare function useTreeFocusInternal<T extends HTMLElement = HTMLElement>(options?: UseTreeFocusInternalOptions): UseTreeFocusInternalReturn<T>;
178
188
  //# sourceMappingURL=useTreeFocus.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useTreeFocus.d.ts","sourceRoot":"","sources":["../../src/hooks/useTreeFocus.ts"],"names":[],"mappings":"AAoCA;;;;;;;;;GASG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC;IAEhD;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,MAAM,CAAC;IAEzC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC;IAE5C;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC;IAE7C;;;;;OAKG;IACH,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,MAAM,GAAG,SAAS,CAAC;IAEtD;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAEtC;;;;;;;;;OASG;IACH,UAAU,CAAC,EAAE,CACX,IAAI,EAAE,WAAW,EACjB,EAAE,EAAE,MAAM,GAAG,SAAS,KACnB,OAAO,GAAG,SAAS,CAAC;IAEzB;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB,gEAAgE;IAChE,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAE1B;;;OAGG;IACH,cAAc,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,CAAC;IAElD;;;;;;;;;;;;;;;;OAgBG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW;IACrE,iEAAiE;IACjE,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAEnC,wDAAwD;IACxD,aAAa,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,aAAa,KAAK,IAAI,CAAC;IAEhD;;;;OAIG;IACH,WAAW,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,KAAK,IAAI,CAAC;IAE3C,gDAAgD;IAChD,UAAU,EAAE,MAAM,IAAI,CAAC;IAEvB,+CAA+C;IAC/C,SAAS,EAAE,MAAM,IAAI,CAAC;CACvB;AAwBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,wBAAgB,YAAY,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,EAC9D,OAAO,GAAE,mBAAwB,GAChC,kBAAkB,CAAC,CAAC,CAAC,CAIvB;AAED;;;;;GAKG;AACH,MAAM,MAAM,2BAA2B,GAAG,mBAAmB,GAAG;IAC9D;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B,CAAC;AAEF;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,EACtE,OAAO,GAAE,2BAAgC,GACxC,kBAAkB,CAAC,CAAC,CAAC,CAsXvB"}
1
+ {"version":3,"file":"useTreeFocus.d.ts","sourceRoot":"","sources":["../../src/hooks/useTreeFocus.ts"],"names":[],"mappings":"AAoCA;;;;;;;;;GASG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC;IAEhD;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,MAAM,CAAC;IAEzC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC;IAE5C;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC;IAE7C;;;;;OAKG;IACH,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,MAAM,GAAG,SAAS,CAAC;IAEtD;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAEtC;;;;;;;;;OASG;IACH,UAAU,CAAC,EAAE,CACX,IAAI,EAAE,WAAW,EACjB,EAAE,EAAE,MAAM,GAAG,SAAS,KACnB,OAAO,GAAG,SAAS,CAAC;IAEzB;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB,gEAAgE;IAChE,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAE1B;;;OAGG;IACH,cAAc,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,CAAC;IAElD;;;;;;;;;;;;;;;;OAgBG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW;IACrE,iEAAiE;IACjE,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAEnC,wDAAwD;IACxD,aAAa,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,aAAa,KAAK,IAAI,CAAC;IAEhD;;;;OAIG;IACH,WAAW,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,KAAK,IAAI,CAAC;IAE3C,gDAAgD;IAChD,UAAU,EAAE,MAAM,IAAI,CAAC;IAEvB,+CAA+C;IAC/C,SAAS,EAAE,MAAM,IAAI,CAAC;CACvB;AAwBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,wBAAgB,YAAY,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,EAC9D,OAAO,GAAE,mBAAwB,GAChC,kBAAkB,CAAC,CAAC,CAAC,CAQvB;AAED;;;;;GAKG;AACH,MAAM,MAAM,2BAA2B,GAAG,mBAAmB,GAAG;IAC9D;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B,CAAC;AAEF,oEAAoE;AACpE,MAAM,MAAM,0BAA0B,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,IACxE,kBAAkB,CAAC,CAAC,CAAC,GAAG;IACtB;;;;;OAKG;IACH,WAAW,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,IAAI,CAAC;CAC1C,CAAC;AAEJ;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,EACtE,OAAO,GAAE,2BAAgC,GACxC,0BAA0B,CAAC,CAAC,CAAC,CAoY/B"}
@@ -90,11 +90,15 @@ function itemLabelText(item, matchLabelledBy) {
90
90
 
91
91
  export function useTreeFocus(options = {}) {
92
92
  // The flag is pinned off here so an untyped options object cannot smuggle
93
- // it through the public hook.
94
- return useTreeFocusInternal({
93
+ // it through the public hook, and the internal stop move stays internal.
94
+ const {
95
+ takeTabStop: _takeTabStop,
96
+ ...result
97
+ } = useTreeFocusInternal({
95
98
  ...options,
96
99
  matchLabelledBy: false
97
100
  });
101
+ return result;
98
102
  }
99
103
 
100
104
  /**
@@ -104,6 +108,8 @@ export function useTreeFocus(options = {}) {
104
108
  * `UseTreeFocusOptions` stays exactly as released.
105
109
  */
106
110
 
111
+ /** What the package's own components get beyond the public hook. */
112
+
107
113
  /**
108
114
  * The implementation behind {@link useTreeFocus}. Not exported from the
109
115
  * package; the public hook calls it without the internal options.
@@ -390,11 +396,21 @@ export function useTreeFocusInternal(options = {}) {
390
396
  syncTabStops();
391
397
  }
392
398
  }, [hasRovingTabIndex, syncTabStops]);
399
+ const takeTabStop = useCallback(item => {
400
+ if (!hasRovingTabIndex || itemDisabled(item)) {
401
+ return;
402
+ }
403
+ const items = getItems();
404
+ if (items.includes(item)) {
405
+ moveTabStop(items, item);
406
+ }
407
+ }, [getItems, hasRovingTabIndex, itemDisabled, moveTabStop]);
393
408
  return {
394
409
  treeRef,
395
410
  handleKeyDown,
396
411
  handleFocus,
397
412
  focusFirst,
398
- focusLast
413
+ focusLast,
414
+ takeTabStop
399
415
  };
400
416
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.7.6-canary.e736b92",
3
+ "version": "0.7.6-canary.e7530db",
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",
@@ -860,9 +860,9 @@
860
860
  "react-dom": ">=19.0.0"
861
861
  },
862
862
  "devDependencies": {
863
- "@astryxdesign/a11y-spec": "0.7.6-canary.e736b92",
864
- "@astryxdesign/cli": "0.7.6-canary.e736b92",
865
- "@astryxdesign/interaction-harness": "0.7.6-canary.e736b92",
863
+ "@astryxdesign/a11y-spec": "0.7.6-canary.e7530db",
864
+ "@astryxdesign/cli": "0.7.6-canary.e7530db",
865
+ "@astryxdesign/interaction-harness": "0.7.6-canary.e7530db",
866
866
  "@babel/cli": "^7.29.7",
867
867
  "@babel/core": "^7.29.7",
868
868
  "@babel/preset-react": "^7.29.7",
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file useTriggerMenu.tsx
7
- * @input Uses React, usePopover, SearchSource
7
+ * @input Uses React, usePopover, SearchSource, useBusyAnnouncement
8
8
  * @output Exports useTriggerMenu hook for trigger-based menus in contentEditable
9
9
  * @position Internal hook; consumed by ChatComposerInput
10
10
  *
@@ -32,7 +32,7 @@ import {
32
32
  } from 'react';
33
33
  import * as stylex from '@stylexjs/stylex';
34
34
  import {usePopover} from '../Popover/usePopover';
35
- import {useAnnounce} from '../hooks/useAnnounce';
35
+ import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
36
36
  import {useHighlightedOptionScroll} from '../hooks/useHighlightedOptionScroll';
37
37
  import {
38
38
  colorVars,
@@ -355,36 +355,22 @@ export function useTriggerMenu(
355
355
  });
356
356
 
357
357
  // The loading row carries no live role: it mounts already holding its text
358
- // and is removed when results arrive, so it would be skipped. The shared
359
- // channel announces each loading episode once instead (spec:AST-086 FR2,
360
- // FR6), once the row has been painted: the second animation frame runs
361
- // after the first paint, and a search that settles before then never shows
362
- // the row, so it announces nothing (FR7). The flag clears when the loading
363
- // row goes away, so the next search is a new occasion.
364
- const announce = useAnnounce();
365
- const hasAnnouncedLoadingRef = useRef(false);
358
+ // and is removed when results arrive, so it would be skipped. "Searching…"
359
+ // is the menu's busy state, so the shared busy path announces each loading
360
+ // episode once (spec:AST-086 FR6, FR8): rerenders and further keystrokes
361
+ // while loading do not repeat it, results arriving re-arm it, and a search
362
+ // that settles before the announcement goes out announces nothing.
366
363
  // Mirrors renderMenu: no row exists without an active trigger or while hidden.
367
364
  const isLoadingRowShown =
368
365
  state.isLoading && !state.isHidden && state.activeTrigger !== null;
369
366
  // One catalog string serves the row and the announcement (FR7).
370
367
  const loadingText =
371
368
  state.activeTrigger?.loadingText ?? t('@astryx.chatTriggerMenu.searching');
372
- useEffect(() => {
373
- if (!isLoadingRowShown) {
374
- hasAnnouncedLoadingRef.current = false;
375
- return;
376
- }
377
- if (hasAnnouncedLoadingRef.current) {
378
- return;
379
- }
380
- let frame = requestAnimationFrame(() => {
381
- frame = requestAnimationFrame(() => {
382
- hasAnnouncedLoadingRef.current = true;
383
- announce(loadingText, 'polite');
384
- });
385
- });
386
- return () => cancelAnimationFrame(frame);
387
- }, [announce, isLoadingRowShown, loadingText]);
369
+ const settleBusyAnnouncement = useBusyAnnouncement(
370
+ isLoadingRowShown,
371
+ loadingText,
372
+ editableRef,
373
+ );
388
374
 
389
375
  const triggerStartRef = useRef<number>(-1);
390
376
  const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -570,6 +556,9 @@ export function useTriggerMenu(
570
556
  };
571
557
 
572
558
  const applyResults = (items: SearchableItem[]) => {
559
+ // Results that arrive before the busy start is heard withdraw it, so a
560
+ // search that settles on its first frame announces nothing (FR8).
561
+ settleBusyAnnouncement();
573
562
  const isHidden = isUnmatchedMultiWordQuery(trigger, items, false);
574
563
  setState(prev => ({
575
564
  ...prev,
@@ -596,7 +585,7 @@ export function useTriggerMenu(
596
585
  trigger.searchSource.cancel?.();
597
586
  const result = trigger.searchSource.search(query);
598
587
  // A synchronous source already has its results, so it never shows
599
- // or announces the loading row (spec:AST-086 FR7).
588
+ // or announces the loading row.
600
589
  if (!Array.isArray(result)) {
601
590
  setState(prev => ({...prev, isLoading: true}));
602
591
  }
@@ -638,6 +627,7 @@ export function useTriggerMenu(
638
627
  setAnchor,
639
628
  hidePopover,
640
629
  showPopover,
630
+ settleBusyAnnouncement,
641
631
  ],
642
632
  );
643
633
 
@@ -26,7 +26,7 @@ import {
26
26
  typeScaleVars,
27
27
  fontWeightVars,
28
28
  } from '../theme/tokens.stylex';
29
- import {mergeProps} from '../utils';
29
+ import {mergeProps, rtlStyles} from '../utils';
30
30
  import type {BaseProps} from '../BaseProps';
31
31
  import {useLinkComponent} from '../Link/useLinkComponent';
32
32
  import {themeProps} from '../utils/themeProps';
@@ -70,6 +70,10 @@ const styles = stylex.create({
70
70
  color: colorVars['--color-text-accent'],
71
71
  textDecoration: 'none',
72
72
  },
73
+ // A transform needs a box: an inline span would not mirror.
74
+ linkArrow: {
75
+ display: 'inline-block',
76
+ },
73
77
  });
74
78
 
75
79
  // =============================================================================
@@ -175,7 +179,10 @@ export function TopNavMegaMenuFeaturedCard({
175
179
  )}
176
180
  {linkLabel && linkHref && (
177
181
  <LinkComponent href={linkHref} {...stylex.props(styles.link)}>
178
- {linkLabel} →
182
+ {linkLabel}{' '}
183
+ {/* The arrow points toward the inline end, so it mirrors in RTL
184
+ (design:bidirectionality DR4); U+2192 is not bidi-mirrored. */}
185
+ <span {...stylex.props(styles.linkArrow, rtlStyles.mirror)}>→</span>
179
186
  </LinkComponent>
180
187
  )}
181
188
  {children}
@@ -38,6 +38,14 @@ import type {
38
38
  import {themeProps} from '../utils/themeProps';
39
39
  import {useTreeFocusInternal} from '../hooks/useTreeFocus';
40
40
  import {LayoutChangeSnapshot} from '../motion/LayoutChangeSnapshot';
41
+ import {
42
+ moveFocusLeftBehind,
43
+ noteFocusWithin,
44
+ type FocusWithin,
45
+ } from '../utils/focusReturn';
46
+ import {useInteractionModalityTracking} from '../utils/interactionModality';
47
+ import {useFocusLeavingChange} from '../utils/useFocusLeavingChange';
48
+ import {useMergedRefs} from '../hooks/useMergedRefs';
41
49
  import {
42
50
  applyLayoutChangeTransition,
43
51
  holdLayoutExit,
@@ -383,6 +391,76 @@ export function TreeList({
383
391
  },
384
392
  [animatedLayout, expandedIds],
385
393
  );
394
+ // Focus inside a branch's child group when it collapses would be lost once
395
+ // the group goes (or turns inert for its exit). It moves to the collapsing
396
+ // row itself (AR2); focus anywhere else is not moved. The shared sequence
397
+ // notes it in the commit before the DOM changes and moves it once the
398
+ // collapse has committed.
399
+ // The tree's own document's last input decides the moved focus's ring.
400
+ const {ref: modalityRef} = useInteractionModalityTracking();
401
+ const noteCollapsingFocus = useFocusLeavingChange<
402
+ ReadonlySet<string>,
403
+ {focus: FocusWithin; ids: string[]}
404
+ >(
405
+ expandedIds,
406
+ // Every expanded row whose group holds focus, outermost first, found by
407
+ // walking up from the focused element: which of them collapsed is known
408
+ // only after the commit.
409
+ (previous: ReadonlySet<string>) => {
410
+ const tree = treeElementRef.current;
411
+ const active = tree?.ownerDocument.activeElement;
412
+ if (tree == null || active == null || !tree.contains(active)) {
413
+ return null;
414
+ }
415
+ let focus: FocusWithin | null = null;
416
+ const ids: string[] = [];
417
+ for (
418
+ let element = active.parentElement;
419
+ element != null && element !== tree;
420
+ element = element.parentElement
421
+ ) {
422
+ const id = element.parentElement?.dataset.treeId;
423
+ if (
424
+ element.getAttribute('role') === 'group' &&
425
+ id != null &&
426
+ previous.has(id)
427
+ ) {
428
+ focus = noteFocusWithin(element);
429
+ ids.unshift(id);
430
+ }
431
+ }
432
+ return focus == null ? null : {focus, ids};
433
+ },
434
+ (noted, expanded) => {
435
+ // The outermost row that collapsed: a row inside it goes with it.
436
+ const id = noted.ids.find(candidate => !expanded.has(candidate));
437
+ if (id == null) {
438
+ return;
439
+ }
440
+ const row = treeElementRef.current?.querySelector<HTMLElement>(
441
+ `[data-tree-id="${CSS.escape(id)}"]`,
442
+ );
443
+ const moved = moveFocusLeftBehind(
444
+ noted.focus,
445
+ row,
446
+ row?.querySelector(':scope > [role="group"]') ?? null,
447
+ );
448
+ // The row focus moved to holds the tree's one Tab stop, unless it is
449
+ // disabled (AR1, AR2).
450
+ if (moved && row != null) {
451
+ takeTabStop(row);
452
+ }
453
+ },
454
+ );
455
+ const beforeExpansionChange = useCallback(
456
+ (previous: ReadonlySet<string>) => {
457
+ noteCollapsingFocus(previous);
458
+ if (animatedLayout != null) {
459
+ measureChanges(previous);
460
+ }
461
+ },
462
+ [animatedLayout, measureChanges, noteCollapsingFocus],
463
+ );
386
464
  // Without a handle the chevrons keep the theme's own turn: a spring a
387
465
  // handle set stays only while it is passed (`spec:AST-105` FR2).
388
466
  useLayoutEffect(() => {
@@ -481,7 +559,7 @@ export function TreeList({
481
559
  }
482
560
  }, [expandedIds, animatedLayout, endExit]);
483
561
 
484
- const {treeRef, handleKeyDown, handleFocus} =
562
+ const {treeRef, handleKeyDown, handleFocus, takeTabStop} =
485
563
  useTreeFocusInternal<HTMLUListElement>({
486
564
  onToggleExpand: handleToggle,
487
565
  onActivate: activateItem,
@@ -495,6 +573,7 @@ export function TreeList({
495
573
  useLayoutEffect(() => {
496
574
  treeElementRef.current = treeRef.current;
497
575
  });
576
+ const mergedTreeRef = useMergedRefs(treeRef, modalityRef);
498
577
 
499
578
  const handleRootKeyDown = useCallback(
500
579
  (e: React.KeyboardEvent<HTMLDivElement>) => {
@@ -605,14 +684,12 @@ export function TreeList({
605
684
  {header}
606
685
  </div>
607
686
  )}
608
- {animatedLayout != null && (
609
- <LayoutChangeSnapshot
610
- value={expandedIds}
611
- onBeforeChange={measureChanges}
612
- />
613
- )}
687
+ <LayoutChangeSnapshot
688
+ value={expandedIds}
689
+ onBeforeChange={beforeExpansionChange}
690
+ />
614
691
  <ul
615
- ref={treeRef}
692
+ ref={mergedTreeRef}
616
693
  role="tree"
617
694
  aria-label={hasHeader ? undefined : ariaLabel}
618
695
  aria-labelledby={hasHeader ? headerId : ariaLabelledby}
@@ -236,8 +236,12 @@ export function useTreeFocus<T extends HTMLElement = HTMLElement>(
236
236
  options: UseTreeFocusOptions = {},
237
237
  ): UseTreeFocusReturn<T> {
238
238
  // The flag is pinned off here so an untyped options object cannot smuggle
239
- // it through the public hook.
240
- return useTreeFocusInternal<T>({...options, matchLabelledBy: false});
239
+ // it through the public hook, and the internal stop move stays internal.
240
+ const {takeTabStop: _takeTabStop, ...result} = useTreeFocusInternal<T>({
241
+ ...options,
242
+ matchLabelledBy: false,
243
+ });
244
+ return result;
241
245
  }
242
246
 
243
247
  /**
@@ -257,13 +261,25 @@ export type UseTreeFocusInternalOptions = UseTreeFocusOptions & {
257
261
  matchLabelledBy?: boolean;
258
262
  };
259
263
 
264
+ /** What the package's own components get beyond the public hook. */
265
+ export type UseTreeFocusInternalReturn<T extends HTMLElement = HTMLElement> =
266
+ UseTreeFocusReturn<T> & {
267
+ /**
268
+ * With `hasRovingTabIndex`, makes `item` the tree's one Tab stop, by the
269
+ * same rule the hook's own moves follow: a disabled or hidden row never
270
+ * takes it, and the stop stays where it is. TreeList calls it when it
271
+ * moves focus to a collapsing row (`component:TreeList` AR2).
272
+ */
273
+ takeTabStop: (item: HTMLElement) => void;
274
+ };
275
+
260
276
  /**
261
277
  * The implementation behind {@link useTreeFocus}. Not exported from the
262
278
  * package; the public hook calls it without the internal options.
263
279
  */
264
280
  export function useTreeFocusInternal<T extends HTMLElement = HTMLElement>(
265
281
  options: UseTreeFocusInternalOptions = {},
266
- ): UseTreeFocusReturn<T> {
282
+ ): UseTreeFocusInternalReturn<T> {
267
283
  const {
268
284
  itemSelector = '[role="treeitem"]',
269
285
  isItemDisabled,
@@ -630,11 +646,25 @@ export function useTreeFocusInternal<T extends HTMLElement = HTMLElement>(
630
646
  }
631
647
  }, [hasRovingTabIndex, syncTabStops]);
632
648
 
649
+ const takeTabStop = useCallback(
650
+ (item: HTMLElement) => {
651
+ if (!hasRovingTabIndex || itemDisabled(item)) {
652
+ return;
653
+ }
654
+ const items = getItems();
655
+ if (items.includes(item)) {
656
+ moveTabStop(items, item);
657
+ }
658
+ },
659
+ [getItems, hasRovingTabIndex, itemDisabled, moveTabStop],
660
+ );
661
+
633
662
  return {
634
663
  treeRef,
635
664
  handleKeyDown,
636
665
  handleFocus,
637
666
  focusFirst,
638
667
  focusLast,
668
+ takeTabStop,
639
669
  };
640
670
  }