@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.
- package/dist/Chat/useTriggerMenu.d.ts +1 -1
- package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
- package/dist/Chat/useTriggerMenu.js +13 -28
- package/dist/TopNav/TopNavMegaMenuFeaturedCard.d.ts.map +1 -1
- package/dist/TopNav/TopNavMegaMenuFeaturedCard.js +9 -2
- package/dist/TreeList/TreeList.d.ts.map +1 -1
- package/dist/TreeList/TreeList.js +62 -4
- package/dist/hooks/useTreeFocus.d.ts +11 -1
- package/dist/hooks/useTreeFocus.d.ts.map +1 -1
- package/dist/hooks/useTreeFocus.js +19 -3
- package/package.json +4 -4
- package/src/Chat/useTriggerMenu.tsx +17 -27
- package/src/TopNav/TopNavMegaMenuFeaturedCard.tsx +9 -2
- package/src/TreeList/TreeList.tsx +85 -8
- package/src/hooks/useTreeFocus.ts +33 -3
|
@@ -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,
|
|
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 {
|
|
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.
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
// the
|
|
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
|
-
|
|
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
|
|
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;
|
|
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, "
|
|
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;
|
|
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
|
-
}),
|
|
490
|
+
}), /*#__PURE__*/_jsx(LayoutChangeSnapshot, {
|
|
433
491
|
value: expandedIds,
|
|
434
|
-
onBeforeChange:
|
|
492
|
+
onBeforeChange: beforeExpansionChange
|
|
435
493
|
}), /*#__PURE__*/_jsx("ul", {
|
|
436
|
-
ref:
|
|
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):
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
864
|
-
"@astryxdesign/cli": "0.7.6-canary.
|
|
865
|
-
"@astryxdesign/interaction-harness": "0.7.6-canary.
|
|
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 {
|
|
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.
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
// the
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
/>
|
|
613
|
-
)}
|
|
687
|
+
<LayoutChangeSnapshot
|
|
688
|
+
value={expandedIds}
|
|
689
|
+
onBeforeChange={beforeExpansionChange}
|
|
690
|
+
/>
|
|
614
691
|
<ul
|
|
615
|
-
ref={
|
|
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
|
-
|
|
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
|
-
):
|
|
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
|
}
|