@primitiv-ui/react 0.1.29 → 0.1.31
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/README.md +15 -2
- package/package.json +1 -1
- package/src/AccessibleIcon/AccessibleIcon.tsx +1 -1
- package/src/Accordion/Accordion.tsx +7 -7
- package/src/Accordion/README.md +2 -2
- package/src/Avatar/__tests__/Avatar.fallback.test.tsx +3 -3
- package/src/Breadcrumb/Breadcrumb.tsx +7 -7
- package/src/Breadcrumb/README.md +2 -2
- package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +1 -1
- package/src/Breadcrumb/types.ts +1 -1
- package/src/Button/__tests__/Button.contract.test.tsx +1 -1
- package/src/Carousel/Carousel.tsx +6 -6
- package/src/Carousel/README.md +22 -22
- package/src/Carousel/__tests__/Carousel.infinite.test.tsx +5 -5
- package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +2 -2
- package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +1 -1
- package/src/Carousel/loopEngine.ts +1 -1
- package/src/Carousel/types.ts +1 -1
- package/src/Checkbox/Checkbox.tsx +2 -2
- package/src/CheckboxCard/CheckboxCard.tsx +1 -1
- package/src/CheckboxCard/README.md +1 -1
- package/src/Collapsible/README.md +2 -2
- package/src/Combobox/Combobox.tsx +508 -0
- package/src/Combobox/ComboboxContext.ts +23 -0
- package/src/Combobox/README.md +170 -0
- package/src/Combobox/__tests__/Combobox.asChild.test.tsx +62 -0
- package/src/Combobox/__tests__/Combobox.basic-rendering.test.tsx +16 -0
- package/src/Combobox/__tests__/Combobox.content.test.tsx +36 -0
- package/src/Combobox/__tests__/Combobox.controlled-state.test.tsx +88 -0
- package/src/Combobox/__tests__/Combobox.empty.test.tsx +42 -0
- package/src/Combobox/__tests__/Combobox.ids.test.tsx +66 -0
- package/src/Combobox/__tests__/Combobox.item.test.tsx +76 -0
- package/src/Combobox/__tests__/Combobox.keyboard-interaction.test.tsx +196 -0
- package/src/Combobox/__tests__/Combobox.light-dismiss.test.tsx +101 -0
- package/src/Combobox/__tests__/Combobox.query.test.tsx +105 -0
- package/src/Combobox/hooks/index.ts +1 -0
- package/src/Combobox/hooks/useComboboxRoot.ts +2 -0
- package/src/Combobox/index.ts +3 -0
- package/src/Combobox/types.ts +133 -0
- package/src/ContextMenu/ContextMenu.tsx +3 -3
- package/src/ContextMenu/README.md +1 -1
- package/src/DirectionProvider/DirectionProvider.tsx +5 -5
- package/src/DirectionProvider/README.md +4 -4
- package/src/Divider/__tests__/Divider.contract.test.tsx +1 -1
- package/src/Drawer/Drawer.tsx +5 -5
- package/src/Drawer/README.md +2 -2
- package/src/Dropdown/Dropdown.tsx +2 -2
- package/src/Dropdown/README.md +7 -5
- package/src/EmptyState/README.md +2 -2
- package/src/Field/README.md +1 -1
- package/src/Field/hooks/useFieldProps.ts +1 -1
- package/src/Fieldset/Fieldset.tsx +3 -3
- package/src/Fieldset/README.md +2 -2
- package/src/Input/Input.tsx +1 -1
- package/src/Input/README.md +2 -2
- package/src/Listbox/Listbox.tsx +475 -0
- package/src/Listbox/ListboxContext.ts +48 -0
- package/src/Listbox/README.md +248 -0
- package/src/Listbox/__tests__/Listbox.active-descendant.test.tsx +128 -0
- package/src/Listbox/__tests__/Listbox.asChild.test.tsx +91 -0
- package/src/Listbox/__tests__/Listbox.basic-rendering.test.tsx +16 -0
- package/src/Listbox/__tests__/Listbox.controlled-state.test.tsx +114 -0
- package/src/Listbox/__tests__/Listbox.disabled.test.tsx +90 -0
- package/src/Listbox/__tests__/Listbox.fixtures.ts +27 -0
- package/src/Listbox/__tests__/Listbox.group-label.test.tsx +97 -0
- package/src/Listbox/__tests__/Listbox.group.test.tsx +45 -0
- package/src/Listbox/__tests__/Listbox.keyboard-edge-cases.test.tsx +144 -0
- package/src/Listbox/__tests__/Listbox.keyboard-interaction.test.tsx +112 -0
- package/src/Listbox/__tests__/Listbox.modifier-keys.test.tsx +86 -0
- package/src/Listbox/__tests__/Listbox.multi-select-keyboard.test.tsx +368 -0
- package/src/Listbox/__tests__/Listbox.multiple-mode.test.tsx +91 -0
- package/src/Listbox/__tests__/Listbox.mutation-hardening.test.tsx +480 -0
- package/src/Listbox/__tests__/Listbox.orientation.test.tsx +59 -0
- package/src/Listbox/__tests__/Listbox.reading-direction.test.tsx +65 -0
- package/src/Listbox/__tests__/Listbox.reorder.test.tsx +78 -0
- package/src/Listbox/__tests__/Listbox.selection-follows-focus.test.tsx +64 -0
- package/src/Listbox/__tests__/Listbox.strict-context.test.tsx +21 -0
- package/src/Listbox/__tests__/Listbox.typeahead.test.tsx +123 -0
- package/src/Listbox/__tests__/Listbox.uncontrolled-state.test.tsx +48 -0
- package/src/Listbox/constants.ts +2 -0
- package/src/Listbox/hooks/index.ts +1 -0
- package/src/Listbox/hooks/useListboxRoot.ts +407 -0
- package/src/Listbox/index.ts +3 -0
- package/src/Listbox/types.ts +238 -0
- package/src/MillerColumns/MillerColumns.tsx +104 -30
- package/src/MillerColumns/README.md +90 -20
- package/src/MillerColumns/__tests__/MillerColumns.aria.test.tsx +45 -0
- package/src/MillerColumns/__tests__/MillerColumns.auto-scroll.test.tsx +4 -2
- package/src/MillerColumns/__tests__/MillerColumns.basic-rendering.test.tsx +18 -3
- package/src/MillerColumns/__tests__/MillerColumns.data-attributes.test.tsx +48 -0
- package/src/MillerColumns/__tests__/MillerColumns.keyboard-interaction.test.tsx +35 -2
- package/src/MillerColumns/__tests__/MillerColumns.keyboard-selection-state.test.tsx +96 -0
- package/src/MillerColumns/__tests__/MillerColumns.preview-panel.test.tsx +29 -4
- package/src/MillerColumns/__tests__/MillerColumns.preview-visibility.test.tsx +79 -0
- package/src/MillerColumns/__tests__/MillerColumns.resize.test.tsx +189 -0
- package/src/MillerColumns/__tests__/MillerColumns.styled-parts.test.tsx +143 -0
- package/src/MillerColumns/__tests__/MillerColumns.typeahead.test.tsx +104 -0
- package/src/MillerColumns/constants.ts +2 -0
- package/src/MillerColumns/hooks/useMillerColumnsColumn.ts +5 -2
- package/src/MillerColumns/hooks/useMillerColumnsItem.ts +29 -7
- package/src/MillerColumns/hooks/useMillerColumnsResizeHandle.ts +80 -10
- package/src/MillerColumns/hooks/useMillerColumnsRoot.ts +0 -0
- package/src/MillerColumns/index.ts +4 -0
- package/src/MillerColumns/types.ts +56 -6
- package/src/MillerColumns/utils.ts +69 -3
- package/src/Modal/Modal.tsx +4 -4
- package/src/Modal/README.md +5 -5
- package/src/Modal/types.ts +1 -1
- package/src/NavigationMenu/NavigationMenu.tsx +6 -6
- package/src/NavigationMenu/README.md +3 -3
- package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +1 -1
- package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +1 -1
- package/src/Popover/Popover.tsx +2 -2
- package/src/Popover/README.md +1 -1
- package/src/Portal/Portal.tsx +2 -2
- package/src/Portal/README.md +2 -2
- package/src/Progress/README.md +1 -1
- package/src/Radio/Radio.tsx +1 -1
- package/src/RadioCard/README.md +2 -1
- package/src/RadioCard/RadioCard.tsx +5 -2
- package/src/RadioCard/__tests__/RadioCard.disabled-items.test.tsx +23 -0
- package/src/RadioGroup/README.md +2 -2
- package/src/RadioGroup/RadioGroup.tsx +1 -1
- package/src/SegmentedControl/README.md +2 -2
- package/src/SegmentedControl/SegmentedControl.tsx +1 -1
- package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +1 -0
- package/src/Select/README.md +2 -2
- package/src/Select/Select.tsx +8 -8
- package/src/Select/__tests__/Select.placeholder.test.tsx +3 -3
- package/src/Select/__tests__/Select.typeahead.test.tsx +3 -3
- package/src/Select/__tests__/Select.value-mirror.test.tsx +1 -1
- package/src/Select/types.ts +2 -2
- package/src/SkipNav/README.md +3 -3
- package/src/SkipNav/SkipNav.tsx +5 -5
- package/src/Slider/README.md +37 -7
- package/src/Slider/Slider.tsx +9 -3
- package/src/Slot/Slot.tsx +2 -2
- package/src/Slot/__tests__/Slot.test.tsx +2 -2
- package/src/Slot/types.ts +1 -1
- package/src/SplitButton/README.md +225 -0
- package/src/SplitButton/SplitButton.tsx +457 -0
- package/src/SplitButton/SplitButtonContext.ts +26 -0
- package/src/SplitButton/__tests__/SplitButton.accessibility.test.tsx +55 -0
- package/src/SplitButton/__tests__/SplitButton.asChild.test.tsx +51 -0
- package/src/SplitButton/__tests__/SplitButton.basic-rendering.test.tsx +57 -0
- package/src/SplitButton/__tests__/SplitButton.data-attributes.test.tsx +28 -0
- package/src/SplitButton/__tests__/SplitButton.disabled.test.tsx +79 -0
- package/src/SplitButton/__tests__/SplitButton.error-handling.test.tsx +29 -0
- package/src/SplitButton/__tests__/SplitButton.keyboard-interaction.test.tsx +56 -0
- package/src/SplitButton/__tests__/SplitButton.state-modes.test.tsx +68 -0
- package/src/SplitButton/index.ts +2 -0
- package/src/SplitButton/types.ts +186 -0
- package/src/Switch/README.md +1 -1
- package/src/Switch/Switch.tsx +2 -2
- package/src/Switch/types.ts +1 -1
- package/src/Table/README.md +110 -8
- package/src/Table/Table.tsx +206 -10
- package/src/Table/TableExpandableContext.ts +7 -0
- package/src/Table/__tests__/Table.DetailRow.test.tsx +83 -0
- package/src/Table/__tests__/Table.ExpandTrigger.test.tsx +102 -0
- package/src/Table/__tests__/Table.Expandable.test.tsx +121 -0
- package/src/Table/types.ts +125 -0
- package/src/Tabs/README.md +4 -4
- package/src/Tabs/Tabs.tsx +5 -4
- package/src/Tabs/__tests__/Tabs.activation-mode.test.tsx +58 -0
- package/src/Tabs/__tests__/Tabs.basic-rendering.test.tsx +15 -0
- package/src/Tabs/__tests__/Tabs.change-event-callbacks.test.tsx +24 -0
- package/src/Tabs/__tests__/Tabs.disabled-tabs.test.tsx +36 -0
- package/src/Tabs/__tests__/Tabs.lazy-mount.test.tsx +17 -0
- package/src/Tabs/hooks/useTabsContent.ts +5 -0
- package/src/Tabs/hooks/useTabsRoot.ts +29 -5
- package/src/Tabs/hooks/useTabsTrigger.ts +7 -2
- package/src/ToggleGroup/README.md +4 -4
- package/src/Tooltip/README.md +7 -7
- package/src/Tooltip/Tooltip.tsx +2 -2
- package/src/Tooltip/__tests__/Tooltip.basic-rendering.test.tsx +50 -0
- package/src/Tooltip/__tests__/Tooltip.error-handling.test.tsx +37 -0
- package/src/Tooltip/__tests__/Tooltip.escape-hatches.test.tsx +73 -0
- package/src/Tooltip/__tests__/Tooltip.keyboard-interaction.test.tsx +134 -0
- package/src/Tooltip/__tests__/Tooltip.skip-delay.test.tsx +216 -0
- package/src/Tooltip/__tests__/Tooltip.timer-coordination.test.tsx +223 -0
- package/src/Tooltip/hooks/useTooltipContent.ts +3 -0
- package/src/Tooltip/hooks/useTooltipProvider.ts +40 -14
- package/src/Tooltip/hooks/useTooltipRoot.ts +30 -6
- package/src/Tree/README.md +69 -5
- package/src/Tree/Tree.tsx +1 -1
- package/src/Tree/__tests__/Tree.styled-wrapper.test.tsx +159 -0
- package/src/Tree/hooks/useTreePath.ts +33 -1
- package/src/Tree/index.ts +6 -1
- package/src/Tree/utils.ts +46 -4
- package/src/VisuallyHidden/VisuallyHidden.tsx +1 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useTypeahead.ts +95 -0
- package/src/index.ts +6 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.reading.test.tsx +31 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.resilience.test.tsx +97 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.ssr.test.tsx +20 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.writing.test.tsx +72 -0
- package/src/useLocalStorage/index.ts +1 -0
- package/src/useLocalStorage/useLocalStorage.ts +140 -0
- package/src/useMediaQuery/README.md +66 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.matching.test.tsx +42 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.ssr.test.tsx +19 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.subscription.test.tsx +89 -0
- package/src/useMediaQuery/index.ts +1 -0
- package/src/useMediaQuery/useMediaQuery.ts +49 -0
- package/src/usePagination/README.md +170 -0
- package/src/usePagination/__tests__/usePagination.navigation.test.tsx +109 -0
- package/src/usePagination/__tests__/usePagination.page-count.test.tsx +56 -0
- package/src/usePagination/__tests__/usePagination.page-state.test.tsx +87 -0
- package/src/usePagination/__tests__/usePagination.range.test.tsx +82 -0
- package/src/usePagination/__tests__/usePagination.slice.test.tsx +59 -0
- package/src/usePagination/__tests__/usePagination.windowing.test.tsx +68 -0
- package/src/usePagination/index.ts +1 -0
- package/src/usePagination/usePagination.ts +292 -0
- package/src/utils/getKeyToActionMap.ts +1 -1
|
@@ -29,6 +29,9 @@ export function useTooltipContent({
|
|
|
29
29
|
useEffect(() => {
|
|
30
30
|
if (!open) return;
|
|
31
31
|
const handlePointerDown = (event: PointerEvent) => {
|
|
32
|
+
// Stryker disable next-line OptionalChaining: equivalent — this listener
|
|
33
|
+
// is only attached while `open` is true, and the Content `<div>` (and
|
|
34
|
+
// therefore internalRef.current) is always mounted whenever open is true.
|
|
32
35
|
if (internalRef.current?.contains(event.target as Node)) return;
|
|
33
36
|
onPointerDownOutside?.(event);
|
|
34
37
|
if (event.defaultPrevented) return;
|
|
@@ -14,15 +14,32 @@ export function useTooltipProvider({
|
|
|
14
14
|
const [isOpenGlobally, setIsOpenGlobally] = useState(false);
|
|
15
15
|
const skipDelayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
16
16
|
|
|
17
|
-
const onOpenGlobally = useCallback(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
const onOpenGlobally = useCallback(
|
|
18
|
+
() => {
|
|
19
|
+
// Stryker disable next-line ConditionalExpression: equivalent —
|
|
20
|
+
// clearTimeout(null) is a spec'd no-op, so forcing this guard true is
|
|
21
|
+
// harmless when no skip-delay timer is pending; the false / never-clear
|
|
22
|
+
// variant is killed by the "clears a pending skip-delay timer when a
|
|
23
|
+
// tooltip opens again within the skip window" test.
|
|
24
|
+
if (skipDelayTimerRef.current !== null) {
|
|
25
|
+
clearTimeout(skipDelayTimerRef.current);
|
|
26
|
+
skipDelayTimerRef.current = null;
|
|
27
|
+
}
|
|
28
|
+
setIsOpenGlobally(true);
|
|
29
|
+
},
|
|
30
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — no dependency to freeze.
|
|
31
|
+
[],
|
|
32
|
+
);
|
|
24
33
|
|
|
25
34
|
const onCloseGlobally = useCallback(() => {
|
|
35
|
+
// Cancel before re-scheduling. The ref tracks only the newest timer, so
|
|
36
|
+
// overwriting it without cancelling orphans the previous one: it survives,
|
|
37
|
+
// fires unobserved, and can no longer be cancelled by a later open. Two
|
|
38
|
+
// closes in a row is not hypothetical — `closeImmediate` is unguarded and
|
|
39
|
+
// both Escape and blur are wired straight to it, so pressing Escape and then
|
|
40
|
+
// tabbing away leaks a timer. `clearTimeout(undefined)` is a spec'd no-op, so
|
|
41
|
+
// this needs no guard for the first close.
|
|
42
|
+
clearTimeout(skipDelayTimerRef.current ?? undefined);
|
|
26
43
|
skipDelayTimerRef.current = setTimeout(() => {
|
|
27
44
|
setIsOpenGlobally(false);
|
|
28
45
|
skipDelayTimerRef.current = null;
|
|
@@ -33,13 +50,22 @@ export function useTooltipProvider({
|
|
|
33
50
|
// component it was scheduled from unless explicitly cancelled — without
|
|
34
51
|
// this, an in-flight timer fires after unmount and can throw once its
|
|
35
52
|
// surrounding environment (e.g. a test's jsdom `window`) is gone.
|
|
36
|
-
useEffect(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
53
|
+
useEffect(
|
|
54
|
+
() => {
|
|
55
|
+
return () => {
|
|
56
|
+
// Stryker disable next-line ConditionalExpression: equivalent —
|
|
57
|
+
// clearTimeout(null) is a spec'd no-op, so forcing this guard true is
|
|
58
|
+
// harmless when no skip-delay timer is pending; the false / never-clear
|
|
59
|
+
// variant is killed by the "clears the pending skip-delay timeout on
|
|
60
|
+
// unmount" test.
|
|
61
|
+
if (skipDelayTimerRef.current !== null) {
|
|
62
|
+
clearTimeout(skipDelayTimerRef.current);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — no dependency to freeze (mount/unmount only).
|
|
67
|
+
[],
|
|
68
|
+
);
|
|
43
69
|
|
|
44
70
|
const contextValue: TooltipProviderContextValue = {
|
|
45
71
|
delayDuration,
|
|
@@ -31,6 +31,11 @@ export function useTooltipRoot({
|
|
|
31
31
|
const gracePeriodTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
32
32
|
|
|
33
33
|
const openImmediate = useCallback(() => {
|
|
34
|
+
// Stryker disable next-line ConditionalExpression: equivalent —
|
|
35
|
+
// clearTimeout(null) is a spec'd no-op, so forcing this guard true is
|
|
36
|
+
// harmless when no open timer is pending; the false / never-clear variant
|
|
37
|
+
// is killed by the "cancels a pending hover-delay open timer when the
|
|
38
|
+
// trigger is focused before it elapses" test.
|
|
34
39
|
if (openTimerRef.current !== null) {
|
|
35
40
|
clearTimeout(openTimerRef.current);
|
|
36
41
|
openTimerRef.current = null;
|
|
@@ -52,6 +57,11 @@ export function useTooltipRoot({
|
|
|
52
57
|
}, [openImmediate, effectiveDelayDuration, providerCtx.isOpenGlobally]);
|
|
53
58
|
|
|
54
59
|
const closeImmediate = useCallback(() => {
|
|
60
|
+
// Stryker disable next-line ConditionalExpression: equivalent —
|
|
61
|
+
// clearTimeout(null) is a spec'd no-op, so forcing this guard true is
|
|
62
|
+
// harmless when no grace timer is pending; the false / never-clear variant
|
|
63
|
+
// is killed by the "cancels a pending grace-period timer when the tooltip
|
|
64
|
+
// closes immediately during the grace window" test.
|
|
55
65
|
if (gracePeriodTimerRef.current !== null) {
|
|
56
66
|
clearTimeout(gracePeriodTimerRef.current);
|
|
57
67
|
gracePeriodTimerRef.current = null;
|
|
@@ -60,14 +70,28 @@ export function useTooltipRoot({
|
|
|
60
70
|
providerCtx.onCloseGlobally();
|
|
61
71
|
}, [setOpen, providerCtx]);
|
|
62
72
|
|
|
63
|
-
const cancelGrace = useCallback(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
const cancelGrace = useCallback(
|
|
74
|
+
() => {
|
|
75
|
+
// Stryker disable next-line ConditionalExpression: equivalent —
|
|
76
|
+
// clearTimeout(null) is a spec'd no-op, so forcing this guard true is
|
|
77
|
+
// harmless when no grace timer is pending; the false / never-clear
|
|
78
|
+
// variant is killed by the "cancels the grace-period close timer when
|
|
79
|
+
// the pointer moves into the content" test.
|
|
80
|
+
if (gracePeriodTimerRef.current !== null) {
|
|
81
|
+
clearTimeout(gracePeriodTimerRef.current);
|
|
82
|
+
gracePeriodTimerRef.current = null;
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — no dependency to freeze.
|
|
86
|
+
[],
|
|
87
|
+
);
|
|
69
88
|
|
|
70
89
|
const closeWithGrace = useCallback(() => {
|
|
90
|
+
// Stryker disable next-line ConditionalExpression: equivalent —
|
|
91
|
+
// clearTimeout(null) is a spec'd no-op, so forcing this guard true is
|
|
92
|
+
// harmless when no open timer is pending; the false / never-clear variant
|
|
93
|
+
// is killed by the "cancels a pending hover-delay open timer when the
|
|
94
|
+
// pointer leaves before it elapses" test.
|
|
71
95
|
if (openTimerRef.current !== null) {
|
|
72
96
|
clearTimeout(openTimerRef.current);
|
|
73
97
|
openTimerRef.current = null;
|
package/src/Tree/README.md
CHANGED
|
@@ -56,7 +56,7 @@ Passing both `default*` and the controlled prop is a type error.
|
|
|
56
56
|
|
|
57
57
|
```ts
|
|
58
58
|
// Uncontrolled
|
|
59
|
-
<Tree.Root defaultExpandedValues={["src"]} onExpandedChange={(v) =>
|
|
59
|
+
<Tree.Root defaultExpandedValues={["src"]} onExpandedChange={(v) => ...}>
|
|
60
60
|
|
|
61
61
|
// Controlled
|
|
62
62
|
<Tree.Root expandedValues={values} onExpandedChange={setValues}>
|
|
@@ -70,14 +70,14 @@ The selection shape depends on `selectionMode`:
|
|
|
70
70
|
|
|
71
71
|
```ts
|
|
72
72
|
// Single (default) — selectedValue is `string | null`
|
|
73
|
-
<Tree.Root defaultSelectedValue="readme" onSelectedValueChange={(v) =>
|
|
73
|
+
<Tree.Root defaultSelectedValue="readme" onSelectedValueChange={(v) => ...}>
|
|
74
74
|
<Tree.Root selectedValue={v} onSelectedValueChange={setV}>
|
|
75
75
|
|
|
76
76
|
// Multiple — selectedValues is `string[]`
|
|
77
77
|
<Tree.Root
|
|
78
78
|
selectionMode="multiple"
|
|
79
79
|
defaultSelectedValues={["readme", "index"]}
|
|
80
|
-
onSelectedValuesChange={(v) =>
|
|
80
|
+
onSelectedValuesChange={(v) => ...}
|
|
81
81
|
>
|
|
82
82
|
<Tree.Root
|
|
83
83
|
selectionMode="multiple"
|
|
@@ -187,7 +187,7 @@ styles.
|
|
|
187
187
|
> [role="treeitem"][data-depth="2"] > .row {
|
|
188
188
|
> padding-inline-start: 3rem;
|
|
189
189
|
> }
|
|
190
|
-
> /*
|
|
190
|
+
> /* ...extend as deep as your tree can go */
|
|
191
191
|
> ```
|
|
192
192
|
>
|
|
193
193
|
> **2. Set a CSS variable inline from the render layer** — the
|
|
@@ -223,6 +223,70 @@ styles.
|
|
|
223
223
|
> }
|
|
224
224
|
> ```
|
|
225
225
|
|
|
226
|
+
## Wrapping Tree in a styled layer
|
|
227
|
+
|
|
228
|
+
`Tree.Branch` finds its control and content by **inspecting its children before
|
|
229
|
+
rendering them**, so it sees the component you wrote — not what that component
|
|
230
|
+
renders. A styling layer that wraps each part in its own component to attach
|
|
231
|
+
classes is therefore invisible to it, and the branch throws:
|
|
232
|
+
|
|
233
|
+
```
|
|
234
|
+
A Tree.Branch must contain a <Tree.BranchControl>.
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Set `TREE_PART` on the wrapper and it partitions like the part it renders:
|
|
238
|
+
|
|
239
|
+
```tsx
|
|
240
|
+
import { TREE_PART, Tree } from "@primitiv-ui/react";
|
|
241
|
+
|
|
242
|
+
function StyledBranchControl(props: TreeBranchControlProps) {
|
|
243
|
+
return <Tree.BranchControl {...props} className="row" />;
|
|
244
|
+
}
|
|
245
|
+
StyledBranchControl[TREE_PART] = "branch-control";
|
|
246
|
+
|
|
247
|
+
function StyledBranchContent(props: TreeBranchContentProps) {
|
|
248
|
+
return <Tree.BranchContent {...props} className="group" />;
|
|
249
|
+
}
|
|
250
|
+
StyledBranchContent[TREE_PART] = "branch-content";
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The accepted names are `"branch-control"` and `"branch-content"`
|
|
254
|
+
(`TreePartName`). Only these two parts need it — every other part is found by
|
|
255
|
+
position or context, not by inspection. The one-control and one-content rules
|
|
256
|
+
still apply: two marked controls in a branch is still an error.
|
|
257
|
+
|
|
258
|
+
This is what the registry `tree` component does; no other primitive in the
|
|
259
|
+
library needs a marker, because none of the others read their children's types.
|
|
260
|
+
|
|
261
|
+
### Indenting without `attr()`
|
|
262
|
+
|
|
263
|
+
`useTreeLevel()` returns `{ depth }` — `0` directly under `Tree.Root`, one
|
|
264
|
+
deeper per enclosing `BranchContent`. A styling layer needs the depth as a
|
|
265
|
+
*number* to apply the indent as the **row's own padding**, which is what keeps
|
|
266
|
+
each row's box full-width so a hover or selected band runs edge to edge rather
|
|
267
|
+
than starting at the nested level. Reading `data-depth` back in CSS would need
|
|
268
|
+
`attr(data-depth type(<integer>))` — Chrome 133+ / Safari 18.2+, unsupported in
|
|
269
|
+
Firefox — so the depth is exposed here instead.
|
|
270
|
+
|
|
271
|
+
```tsx
|
|
272
|
+
function StyledItem(props: TreeItemProps) {
|
|
273
|
+
const { depth } = useTreeLevel();
|
|
274
|
+
return (
|
|
275
|
+
<Tree.Item
|
|
276
|
+
{...props}
|
|
277
|
+
style={{ "--depth": depth } as CSSProperties}
|
|
278
|
+
className="row"
|
|
279
|
+
/>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
```css
|
|
285
|
+
.row {
|
|
286
|
+
padding-inline-start: calc(var(--depth, 0) * 1.25rem + 0.5rem);
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
226
290
|
## Selection path
|
|
227
291
|
|
|
228
292
|
`Tree.Item` and `Tree.Branch` accept an optional `label` prop. It does
|
|
@@ -256,7 +320,7 @@ import { Tree } from "@primitiv-ui/react";
|
|
|
256
320
|
import { ChevronRight } from "@primitiv-ui/icons";
|
|
257
321
|
|
|
258
322
|
<Tree.Root defaultSelectedValue="index">
|
|
259
|
-
|
|
323
|
+
...
|
|
260
324
|
<Tree.SelectionPath separator={<ChevronRight />} />
|
|
261
325
|
</Tree.Root>;
|
|
262
326
|
```
|
package/src/Tree/Tree.tsx
CHANGED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import type { ComponentPropsWithRef } from "react";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { Tree, TREE_PART, useTreeLevel } from "../index";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A styled layer wraps every part in its own component to attach classes — the
|
|
9
|
+
* shape `primitiv add tree` copies into a consumer repo. `Tree.Branch` finds its
|
|
10
|
+
* control and content by inspecting its children, so those wrappers have to be
|
|
11
|
+
* recognisable as the parts they render; without that, Tree is the one primitive
|
|
12
|
+
* in the library that cannot be wrapped at all.
|
|
13
|
+
*/
|
|
14
|
+
function StyledBranchControl(props: ComponentPropsWithRef<typeof Tree.BranchControl>) {
|
|
15
|
+
return <Tree.BranchControl {...props} className="styled-control" />;
|
|
16
|
+
}
|
|
17
|
+
StyledBranchControl[TREE_PART] = "branch-control";
|
|
18
|
+
|
|
19
|
+
function StyledBranchContent(props: ComponentPropsWithRef<typeof Tree.BranchContent>) {
|
|
20
|
+
return <Tree.BranchContent {...props} className="styled-content" />;
|
|
21
|
+
}
|
|
22
|
+
StyledBranchContent[TREE_PART] = "branch-content";
|
|
23
|
+
|
|
24
|
+
describe("Tree — styled wrappers", () => {
|
|
25
|
+
it("accepts a wrapper component in place of Tree.BranchControl", () => {
|
|
26
|
+
// Arrange / Act
|
|
27
|
+
render(
|
|
28
|
+
<Tree.Root defaultExpandedValues={["src"]}>
|
|
29
|
+
<Tree.Branch value="src">
|
|
30
|
+
<StyledBranchControl>src</StyledBranchControl>
|
|
31
|
+
<StyledBranchContent>
|
|
32
|
+
<Tree.Item value="index.ts">index.ts</Tree.Item>
|
|
33
|
+
</StyledBranchContent>
|
|
34
|
+
</Tree.Branch>
|
|
35
|
+
</Tree.Root>,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// Assert
|
|
39
|
+
expect(screen.getByText("src")).toHaveClass("styled-control");
|
|
40
|
+
expect(screen.getByRole("group")).toHaveClass("styled-content");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("labels the branch by its wrapped control, so the name is just the row", () => {
|
|
44
|
+
// Arrange / Act
|
|
45
|
+
render(
|
|
46
|
+
<Tree.Root defaultExpandedValues={["src"]}>
|
|
47
|
+
<Tree.Branch value="src">
|
|
48
|
+
<StyledBranchControl>src</StyledBranchControl>
|
|
49
|
+
<StyledBranchContent>
|
|
50
|
+
<Tree.Item value="index.ts">index.ts</Tree.Item>
|
|
51
|
+
</StyledBranchContent>
|
|
52
|
+
</Tree.Branch>
|
|
53
|
+
</Tree.Root>,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Assert — the branch treeitem takes its accessible name from the control
|
|
57
|
+
// row alone, not from the whole subtree.
|
|
58
|
+
expect(screen.getByRole("treeitem", { name: "src" })).toHaveAttribute(
|
|
59
|
+
"aria-expanded",
|
|
60
|
+
"true",
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("reads forceMount from the wrapper element, not from what it renders", () => {
|
|
65
|
+
// A styled layer animates the open/close transition, which needs the group
|
|
66
|
+
// to stay mounted while closed. Tree.Branch reads `forceMount` off the
|
|
67
|
+
// element it is handed, so setting it *inside* the wrapper is invisible —
|
|
68
|
+
// the subtree unmounts and there is nothing left to animate. This is the
|
|
69
|
+
// contract the registry surface depends on.
|
|
70
|
+
|
|
71
|
+
// Arrange / Act — closed, with forceMount on the wrapper element
|
|
72
|
+
const { rerender } = render(
|
|
73
|
+
<Tree.Root>
|
|
74
|
+
<Tree.Branch value="src">
|
|
75
|
+
<StyledBranchControl>src</StyledBranchControl>
|
|
76
|
+
<StyledBranchContent forceMount>
|
|
77
|
+
<Tree.Item value="index.ts">index.ts</Tree.Item>
|
|
78
|
+
</StyledBranchContent>
|
|
79
|
+
</Tree.Branch>
|
|
80
|
+
</Tree.Root>,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Assert — still in the DOM, marked closed and hidden from AT
|
|
84
|
+
const group = document.querySelector('[role="group"]');
|
|
85
|
+
expect(group).toHaveAttribute("data-state", "closed");
|
|
86
|
+
expect(group).toHaveAttribute("aria-hidden", "true");
|
|
87
|
+
|
|
88
|
+
// Act — without it, the branch drops the subtree entirely
|
|
89
|
+
rerender(
|
|
90
|
+
<Tree.Root>
|
|
91
|
+
<Tree.Branch value="src">
|
|
92
|
+
<StyledBranchControl>src</StyledBranchControl>
|
|
93
|
+
<StyledBranchContent>
|
|
94
|
+
<Tree.Item value="index.ts">index.ts</Tree.Item>
|
|
95
|
+
</StyledBranchContent>
|
|
96
|
+
</Tree.Branch>
|
|
97
|
+
</Tree.Root>,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Assert
|
|
101
|
+
expect(document.querySelector('[role="group"]')).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("still rejects two controls when both are wrappers", () => {
|
|
105
|
+
// Arrange / Act / Assert
|
|
106
|
+
expect(() =>
|
|
107
|
+
render(
|
|
108
|
+
<Tree.Root>
|
|
109
|
+
<Tree.Branch value="src">
|
|
110
|
+
<StyledBranchControl>one</StyledBranchControl>
|
|
111
|
+
<StyledBranchControl>two</StyledBranchControl>
|
|
112
|
+
</Tree.Branch>
|
|
113
|
+
</Tree.Root>,
|
|
114
|
+
),
|
|
115
|
+
).toThrow("A Tree.Branch may contain at most one <Tree.BranchControl>.");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("useTreeLevel", () => {
|
|
120
|
+
function Depth() {
|
|
121
|
+
const { depth } = useTreeLevel();
|
|
122
|
+
return <span data-testid={`depth-${depth}`}>{depth}</span>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
it("reports the nesting depth so a styled layer can indent without attr()", () => {
|
|
126
|
+
// A styled layer needs the depth as a NUMBER to compute a row's indent.
|
|
127
|
+
// Reading it back off `data-depth` in CSS needs `attr(... type(<integer>))`,
|
|
128
|
+
// which Firefox does not support — so the depth is exposed here instead.
|
|
129
|
+
|
|
130
|
+
// Arrange / Act
|
|
131
|
+
render(
|
|
132
|
+
<Tree.Root defaultExpandedValues={["a", "b"]}>
|
|
133
|
+
<Depth />
|
|
134
|
+
<Tree.Branch value="a">
|
|
135
|
+
<StyledBranchControl>a</StyledBranchControl>
|
|
136
|
+
<StyledBranchContent>
|
|
137
|
+
<Depth />
|
|
138
|
+
<Tree.Branch value="b">
|
|
139
|
+
<StyledBranchControl>b</StyledBranchControl>
|
|
140
|
+
<StyledBranchContent>
|
|
141
|
+
<Depth />
|
|
142
|
+
</StyledBranchContent>
|
|
143
|
+
</Tree.Branch>
|
|
144
|
+
</StyledBranchContent>
|
|
145
|
+
</Tree.Branch>
|
|
146
|
+
</Tree.Root>,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// Assert — 0 at the root, one deeper per BranchContent
|
|
150
|
+
expect(screen.getByTestId("depth-0")).toBeInTheDocument();
|
|
151
|
+
expect(screen.getByTestId("depth-1")).toBeInTheDocument();
|
|
152
|
+
expect(screen.getByTestId("depth-2")).toBeInTheDocument();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("throws outside a Tree", () => {
|
|
156
|
+
// Arrange / Act / Assert
|
|
157
|
+
expect(() => render(<Depth />)).toThrow();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useTreeContext } from "../TreeContext";
|
|
1
|
+
import { useTreeContext, useTreeLevelContext } from "../TreeContext";
|
|
2
2
|
|
|
3
3
|
import type { TreePathSegment } from "../types";
|
|
4
4
|
|
|
@@ -66,3 +66,35 @@ export function useTreeSelectionPaths(): TreePathSegment[][] {
|
|
|
66
66
|
const { selectedOrder, getPath } = useTreeContext();
|
|
67
67
|
return selectedOrder.map((value) => getPath(value));
|
|
68
68
|
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read the current nesting depth inside a `Tree` — `0` for a node directly
|
|
72
|
+
* under {@link TreeRoot | `Tree.Root`}, one deeper per enclosing
|
|
73
|
+
* {@link TreeBranchContent | `Tree.BranchContent`}.
|
|
74
|
+
*
|
|
75
|
+
* A styling layer needs the depth as a *number* to compute a row's indent, and
|
|
76
|
+
* so to make the row span the tree's full width (a hover or selected band that
|
|
77
|
+
* runs edge to edge, as VS Code and most file trees do). Reading it back off
|
|
78
|
+
* the `data-depth` attribute in CSS requires `attr(data-depth type(<integer>))`,
|
|
79
|
+
* which Firefox does not support — hence this hook.
|
|
80
|
+
*
|
|
81
|
+
* @throws If called outside a `Tree.Root`.
|
|
82
|
+
*
|
|
83
|
+
* @example Indenting a row so its background still spans the full width
|
|
84
|
+
* ```tsx
|
|
85
|
+
* function StyledItem(props: TreeItemProps) {
|
|
86
|
+
* const { depth } = useTreeLevel();
|
|
87
|
+
* return (
|
|
88
|
+
* <Tree.Item
|
|
89
|
+
* {...props}
|
|
90
|
+
* style={{ "--depth": depth } as CSSProperties}
|
|
91
|
+
* className="row"
|
|
92
|
+
* />
|
|
93
|
+
* );
|
|
94
|
+
* }
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export function useTreeLevel(): { depth: number } {
|
|
98
|
+
const { depth } = useTreeLevelContext();
|
|
99
|
+
return { depth };
|
|
100
|
+
}
|
package/src/Tree/index.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
export * from "./Tree";
|
|
2
2
|
export * from "./types";
|
|
3
|
-
export {
|
|
3
|
+
export { TREE_PART, type TreePartName } from "./utils";
|
|
4
|
+
export {
|
|
5
|
+
useTreeLevel,
|
|
6
|
+
useTreePath,
|
|
7
|
+
useTreeSelectionPaths,
|
|
8
|
+
} from "./hooks/useTreePath";
|
package/src/Tree/utils.ts
CHANGED
|
@@ -3,14 +3,56 @@ import type { ReactElement, ReactNode } from "react";
|
|
|
3
3
|
|
|
4
4
|
import { TreeBranchControl, TreeBranchContent } from "./Tree";
|
|
5
5
|
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* The property a component sets on itself to declare which `Tree` part it
|
|
8
|
+
* stands in for.
|
|
9
|
+
*
|
|
10
|
+
* `Tree.Branch` finds its control and content by inspecting its children before
|
|
11
|
+
* rendering them, so it sees the component the consumer wrote — not what that
|
|
12
|
+
* component renders. A styled layer wraps every part in its own component to
|
|
13
|
+
* attach classes (that is exactly what `primitiv add tree` copies), and without
|
|
14
|
+
* a marker those wrappers are unrecognisable, making `Tree` the one primitive
|
|
15
|
+
* in the library that cannot be wrapped at all.
|
|
16
|
+
*
|
|
17
|
+
* Set it on the wrapper and it partitions like the part it renders:
|
|
18
|
+
*
|
|
19
|
+
* ```tsx
|
|
20
|
+
* function StyledBranchControl(props: TreeBranchControlProps) {
|
|
21
|
+
* return <Tree.BranchControl {...props} className="row" />;
|
|
22
|
+
* }
|
|
23
|
+
* StyledBranchControl[TREE_PART] = "branch-control";
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export const TREE_PART: "__primitivTreePart" = "__primitivTreePart";
|
|
27
|
+
|
|
28
|
+
/** The part names {@link TREE_PART} accepts. */
|
|
29
|
+
export type TreePartName = "branch-control" | "branch-content";
|
|
30
|
+
|
|
31
|
+
/** Read a component's {@link TREE_PART} marker, if it declares one. */
|
|
32
|
+
function treePartOf(type: unknown): TreePartName | undefined {
|
|
33
|
+
return (type as Record<string, TreePartName | undefined> | null)?.[TREE_PART];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether `node` is a `Tree.BranchControl` element — either the part itself, or
|
|
38
|
+
* a component that marks itself as standing in for it (see {@link TREE_PART}).
|
|
39
|
+
*/
|
|
7
40
|
export function isBranchControlElement(node: ReactNode): node is ReactElement {
|
|
8
|
-
return
|
|
41
|
+
return (
|
|
42
|
+
isValidElement(node) &&
|
|
43
|
+
(node.type === TreeBranchControl || treePartOf(node.type) === "branch-control")
|
|
44
|
+
);
|
|
9
45
|
}
|
|
10
46
|
|
|
11
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Whether `node` is a `Tree.BranchContent` element — either the part itself, or
|
|
49
|
+
* a component that marks itself as standing in for it (see {@link TREE_PART}).
|
|
50
|
+
*/
|
|
12
51
|
export function isBranchContentElement(node: ReactNode): node is ReactElement {
|
|
13
|
-
return
|
|
52
|
+
return (
|
|
53
|
+
isValidElement(node) &&
|
|
54
|
+
(node.type === TreeBranchContent || treePartOf(node.type) === "branch-content")
|
|
55
|
+
);
|
|
14
56
|
}
|
|
15
57
|
|
|
16
58
|
/**
|
package/src/hooks/index.ts
CHANGED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useCallback, useRef, type KeyboardEvent } from "react";
|
|
2
|
+
|
|
3
|
+
type UseTypeaheadArgs = {
|
|
4
|
+
/** Reads the navigable (enabled) option values in current DOM order. Called
|
|
5
|
+
* per keystroke so an in-place reorder is picked up. */
|
|
6
|
+
getNavigable: () => string[];
|
|
7
|
+
/** Reads an option's visible label, used for prefix matching. */
|
|
8
|
+
getLabel: (optionValue: string) => string;
|
|
9
|
+
/** The option the cursor is on, or `undefined` before it is seeded. */
|
|
10
|
+
currentKey: string | undefined;
|
|
11
|
+
/** Moves the cursor to the matched option. */
|
|
12
|
+
onMatch: (optionValue: string) => void;
|
|
13
|
+
/** Milliseconds of inactivity after which the accumulated query resets. */
|
|
14
|
+
resetMs: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Printable-character typeahead for a list cursor, per APG's
|
|
19
|
+
* recommendation for lists longer than about seven options. Shared by
|
|
20
|
+
* `Listbox` and `MillerColumns` (which scopes it to the focused column).
|
|
21
|
+
*
|
|
22
|
+
* Keystrokes accumulate into a query that resets after `resetMs` of
|
|
23
|
+
* inactivity. A query of all-identical
|
|
24
|
+
* characters is treated as a repeat of that single character, which cycles
|
|
25
|
+
* through the options starting with it rather than searching for a literal
|
|
26
|
+
* run — so pressing "a" three times walks the "a" options instead of looking
|
|
27
|
+
* for "aaa". Matching is a case-insensitive prefix test against the option's
|
|
28
|
+
* text, and the search wraps.
|
|
29
|
+
*/
|
|
30
|
+
export function useTypeahead({
|
|
31
|
+
getNavigable,
|
|
32
|
+
getLabel,
|
|
33
|
+
currentKey,
|
|
34
|
+
onMatch,
|
|
35
|
+
resetMs,
|
|
36
|
+
}: UseTypeaheadArgs): {
|
|
37
|
+
handleTypeahead: (event: KeyboardEvent<HTMLElement>) => void;
|
|
38
|
+
} {
|
|
39
|
+
const stateRef = useRef<{ query: string; timer: number | null }>({
|
|
40
|
+
query: "",
|
|
41
|
+
timer: null,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const handleTypeahead = useCallback(
|
|
45
|
+
(event: KeyboardEvent<HTMLElement>) => {
|
|
46
|
+
if (event.key.length !== 1) return;
|
|
47
|
+
|
|
48
|
+
const navigable = getNavigable();
|
|
49
|
+
// Stryker disable next-line ConditionalExpression: equivalent — the match loop is bounded by navigable.length, so an empty list exits immediately anyway.
|
|
50
|
+
if (navigable.length === 0) return;
|
|
51
|
+
|
|
52
|
+
const state = stateRef.current;
|
|
53
|
+
// Stryker disable next-line ConditionalExpression,EqualityOperator: equivalent — keystroke handling is synchronous, so clearing (or not clearing) the pending reset cannot change this search's outcome.
|
|
54
|
+
if (state.timer !== null) window.clearTimeout(state.timer);
|
|
55
|
+
state.query = (state.query + event.key).toLowerCase();
|
|
56
|
+
state.timer = window.setTimeout(() => {
|
|
57
|
+
state.query = "";
|
|
58
|
+
state.timer = null;
|
|
59
|
+
}, resetMs);
|
|
60
|
+
|
|
61
|
+
const isRepeat =
|
|
62
|
+
// Stryker disable next-line ConditionalExpression,EqualityOperator: equivalent — for a single character `every()` is trivially true, so both forms yield the same isRepeat.
|
|
63
|
+
state.query.length > 1 &&
|
|
64
|
+
state.query.split("").every((char) => char === state.query[0]);
|
|
65
|
+
const searchQuery = isRepeat ? state.query[0] : state.query;
|
|
66
|
+
|
|
67
|
+
// A single character (or a repeat of one) advances past the current
|
|
68
|
+
// option so pressing the same letter walks the matches; a genuine
|
|
69
|
+
// multi-character query narrows in place instead. With no cursor at all
|
|
70
|
+
// there is nothing to advance past, so the search starts at the top.
|
|
71
|
+
const currentIndex =
|
|
72
|
+
// Stryker disable next-line ConditionalExpression: equivalent — indexOf(undefined) is itself -1.
|
|
73
|
+
currentKey === undefined ? -1 : navigable.indexOf(currentKey);
|
|
74
|
+
// Stryker disable next-line ConditionalExpression,EqualityOperator: equivalent — with no cursor the two spellings produce the same scan: startIndex -1 with offset 1 and startIndex 0 with offset 0 both begin at index 0 and step identically.
|
|
75
|
+
const hasCursor = currentIndex >= 0;
|
|
76
|
+
const startIndex = hasCursor ? currentIndex : 0;
|
|
77
|
+
const offset =
|
|
78
|
+
hasCursor && (searchQuery.length === 1 || isRepeat) ? 1 : 0;
|
|
79
|
+
|
|
80
|
+
// Stryker disable next-line EqualityOperator: equivalent — the index wraps modulo navigable.length, so an extra iteration only re-checks an already-visited option.
|
|
81
|
+
for (let i = 0; i < navigable.length; i++) {
|
|
82
|
+
const index = (startIndex + offset + i) % navigable.length;
|
|
83
|
+
const label = getLabel(navigable[index]).trim().toLowerCase();
|
|
84
|
+
if (label.startsWith(searchQuery)) {
|
|
85
|
+
event.preventDefault();
|
|
86
|
+
onMatch(navigable[index]);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
[getNavigable, getLabel, currentKey, onMatch, resetMs],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return { handleTypeahead };
|
|
95
|
+
}
|