@12-apps/ui 4.0.2 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/package.json +2 -1
  2. package/src/components/data-display/DataViews/data-views-category-pill.tsx +98 -0
  3. package/src/components/data-display/DataViews/data-views-filter-panel.tsx +11 -14
  4. package/src/components/data-display/DataViews/data-views-inline-bar.tsx +8 -13
  5. package/src/components/data-display/DataViews/data-views-types.ts +10 -2
  6. package/src/components/form/CategorySelect/CategoryIcons.tsx +52 -0
  7. package/src/components/form/CategorySelect/CategoryPanel.tsx +155 -0
  8. package/src/components/form/CategorySelect/CategoryPanelFoot.tsx +94 -0
  9. package/src/components/form/CategorySelect/CategoryPanelHead.tsx +167 -0
  10. package/src/components/form/CategorySelect/CategoryPanelList.tsx +207 -0
  11. package/src/components/form/CategorySelect/CategoryPanelSurface.tsx +56 -0
  12. package/src/components/form/CategorySelect/CategoryQuickActions.tsx +69 -0
  13. package/src/components/form/CategorySelect/CategoryRows.tsx +211 -0
  14. package/src/components/form/CategorySelect/CategorySelect.md +134 -0
  15. package/src/components/form/CategorySelect/CategorySelect.styles.ts +354 -0
  16. package/src/components/form/CategorySelect/CategorySelect.tsx +183 -0
  17. package/src/components/form/CategorySelect/CategorySelect.types.ts +103 -0
  18. package/src/components/form/CategorySelect/CategoryTrigger.tsx +109 -0
  19. package/src/components/form/CategorySelect/category-keyboard.ts +136 -0
  20. package/src/components/form/CategorySelect/category-tree.ts +210 -0
  21. package/src/components/form/CategorySelect/index.ts +19 -0
  22. package/src/components/form/CategorySelect/useCategoryCommit.ts +101 -0
  23. package/src/components/form/CategorySelect/useCategoryKeyHandler.ts +66 -0
  24. package/src/components/form/CategorySelect/useCategorySelect.ts +172 -0
  25. package/src/components/form/total-form/Fields/CategoryField.tsx +57 -0
  26. package/src/components/form/total-form/Fields/index.ts +2 -0
  27. package/src/components/form/total-form/index.ts +2 -2
@@ -0,0 +1,109 @@
1
+ 'use client';
2
+
3
+ import { Box } from '@mui/material';
4
+
5
+ import { CaretGlyph } from './CategoryIcons';
6
+ import {
7
+ triggerChevronSx,
8
+ triggerClearSx,
9
+ triggerCountSx,
10
+ triggerSx,
11
+ } from './CategorySelect.styles';
12
+
13
+ interface CategoryTriggerProps {
14
+ /** Text shown when nothing is selected. */
15
+ placeholder: string;
16
+ /** Resolved label when there IS a selection (single mode shows "Pai › Filha"). */
17
+ selectionLabel?: string;
18
+ /** Selection size — rendered as the count pill in multi-select. */
19
+ count?: number;
20
+ open: boolean;
21
+ disabled?: boolean;
22
+ fullWidth: boolean;
23
+ onOpen: () => void;
24
+ /** Absent when there is nothing to clear. */
25
+ onClear?: () => void;
26
+ triggerRef: React.RefObject<HTMLButtonElement | null>;
27
+ dataTestId: string;
28
+ }
29
+
30
+ /**
31
+ * The closed control.
32
+ *
33
+ * The clear (×) is a real button nested in the trigger button, so "remove the
34
+ * filter" is one click from the closed state — without it, clearing means open,
35
+ * find Clear, apply. Its click is stopped from reaching the trigger, which would
36
+ * otherwise open the panel on the way out.
37
+ */
38
+ export function CategoryTrigger({
39
+ placeholder,
40
+ selectionLabel,
41
+ count,
42
+ open,
43
+ disabled,
44
+ fullWidth,
45
+ onOpen,
46
+ onClear,
47
+ triggerRef,
48
+ dataTestId,
49
+ }: CategoryTriggerProps): React.JSX.Element {
50
+ const hasSelection = Boolean(selectionLabel) || (count ?? 0) > 0;
51
+ return (
52
+ <Box
53
+ component="button"
54
+ type="button"
55
+ ref={triggerRef}
56
+ disabled={disabled}
57
+ aria-haspopup="dialog"
58
+ aria-expanded={open}
59
+ data-testid={`${dataTestId}-trigger`}
60
+ onClick={onOpen}
61
+ sx={(theme) => ({
62
+ ...triggerSx(theme, hasSelection, open),
63
+ width: fullWidth ? '100%' : 'auto',
64
+ })}
65
+ >
66
+ <Box
67
+ component="span"
68
+ sx={{
69
+ whiteSpace: 'nowrap',
70
+ overflow: 'hidden',
71
+ textOverflow: 'ellipsis',
72
+ maxWidth: 190,
73
+ flex: fullWidth ? 1 : '0 1 auto',
74
+ textAlign: 'left',
75
+ }}
76
+ >
77
+ {selectionLabel ?? placeholder}
78
+ </Box>
79
+ {count !== undefined && count > 0 && (
80
+ <Box component="span" sx={triggerCountSx} data-testid={`${dataTestId}-count`}>
81
+ {count}
82
+ </Box>
83
+ )}
84
+ {hasSelection && onClear && (
85
+ <Box
86
+ component="span"
87
+ role="button"
88
+ tabIndex={0}
89
+ aria-label="Limpar seleção"
90
+ data-testid={`${dataTestId}-clear-trigger`}
91
+ sx={triggerClearSx}
92
+ onClick={(event: React.MouseEvent) => {
93
+ event.stopPropagation();
94
+ onClear();
95
+ }}
96
+ onKeyDown={(event: React.KeyboardEvent) => {
97
+ if (event.key !== 'Enter' && event.key !== ' ') return;
98
+ event.preventDefault();
99
+ event.stopPropagation();
100
+ onClear();
101
+ }}
102
+ >
103
+ ×
104
+ </Box>
105
+ )}
106
+ <CaretGlyph style={triggerChevronSx(open) as React.CSSProperties} />
107
+ </Box>
108
+ );
109
+ }
@@ -0,0 +1,136 @@
1
+ import type { CategoryRowRef } from './category-tree';
2
+ import type { CategoryGroup } from './CategorySelect.types';
3
+
4
+ /** What a key handler is allowed to do to the panel. */
5
+ export interface CategoryKeyContext {
6
+ rows: CategoryRowRef[];
7
+ activeIndex: number;
8
+ groups: CategoryGroup[];
9
+ /** Single-select mode picks on Enter/Space instead of toggling a draft. */
10
+ single: boolean;
11
+ /** Categories are headings, not checkboxes, unless parents are selectable. */
12
+ allowParentSelection: boolean;
13
+ setActiveIndex: (next: number) => void;
14
+ toggleExpanded: (categoryId: string) => void;
15
+ expand: (categoryId: string) => void;
16
+ collapse: (categoryId: string) => void;
17
+ toggleCategory: (group: CategoryGroup) => void;
18
+ toggleSubcategory: (id: string) => void;
19
+ commit: () => void;
20
+ cancel: () => void;
21
+ pick: (id: string) => void;
22
+ }
23
+
24
+ /** The row under the keyboard cursor, if the cursor is on one. */
25
+ function activeRow(context: CategoryKeyContext): CategoryRowRef | undefined {
26
+ return context.activeIndex >= 0 ? context.rows[context.activeIndex] : undefined;
27
+ }
28
+
29
+ function groupOf(context: CategoryKeyContext, id: string): CategoryGroup | undefined {
30
+ return context.groups.find((group) => group.category.id === id);
31
+ }
32
+
33
+ function moveCursor(context: CategoryKeyContext, delta: number): void {
34
+ const { rows, activeIndex } = context;
35
+ if (rows.length === 0) return;
36
+ const next =
37
+ delta > 0
38
+ ? Math.min(rows.length - 1, activeIndex + 1)
39
+ : Math.max(0, activeIndex - 1);
40
+ context.setActiveIndex(next);
41
+ }
42
+
43
+ /** → opens a category. On a subcategory it is a no-op: there is nothing deeper. */
44
+ function handleRight(context: CategoryKeyContext): void {
45
+ const row = activeRow(context);
46
+ if (row?.kind === 'category') context.expand(row.id);
47
+ }
48
+
49
+ /** ← collapses a category, or walks a subcategory back up to its parent. */
50
+ function handleLeft(context: CategoryKeyContext): void {
51
+ const row = activeRow(context);
52
+ if (!row) return;
53
+ if (row.kind === 'category') {
54
+ context.collapse(row.id);
55
+ return;
56
+ }
57
+ if (!row.parentId) return;
58
+ context.collapse(row.parentId);
59
+ const parentIndex = context.rows.findIndex(
60
+ (candidate) => candidate.kind === 'category' && candidate.id === row.parentId,
61
+ );
62
+ if (parentIndex >= 0) context.setActiveIndex(parentIndex);
63
+ }
64
+
65
+ /**
66
+ * Space marks. On a category that is only a HEADING it expands instead — the
67
+ * row has no checkbox, so "mark" has nothing to mean there.
68
+ */
69
+ function handleSpace(context: CategoryKeyContext): void {
70
+ const row = activeRow(context);
71
+ if (!row) return;
72
+ if (context.single) {
73
+ context.pick(row.id);
74
+ return;
75
+ }
76
+ if (row.kind === 'subcategory') {
77
+ context.toggleSubcategory(row.id);
78
+ return;
79
+ }
80
+ const group = groupOf(context, row.id);
81
+ if (!group) return;
82
+ if (context.allowParentSelection) context.toggleCategory(group);
83
+ else context.toggleExpanded(row.id);
84
+ }
85
+
86
+ function handleEnter(context: CategoryKeyContext): void {
87
+ const row = activeRow(context);
88
+ if (context.single) {
89
+ if (row) context.pick(row.id);
90
+ return;
91
+ }
92
+ context.commit();
93
+ }
94
+
95
+ /** Key → behaviour. A map keeps this dispatch flat instead of a long if-chain. */
96
+ const HANDLERS: Record<string, (context: CategoryKeyContext) => void> = {
97
+ ArrowDown: (context) => moveCursor(context, 1),
98
+ ArrowUp: (context) => moveCursor(context, -1),
99
+ ArrowRight: handleRight,
100
+ ArrowLeft: handleLeft,
101
+ ' ': handleSpace,
102
+ Enter: handleEnter,
103
+ Escape: (context) => context.cancel(),
104
+ };
105
+
106
+ /**
107
+ * Keys the SEARCH BOX has a claim on: Space types a space, and the horizontal
108
+ * arrows move the caret.
109
+ *
110
+ * They are only yielded while the field actually holds text. The panel opens
111
+ * with focus in an EMPTY search box, so yielding unconditionally — as the
112
+ * prototype does — would make Space and ←/→ dead on arrival for anyone driving
113
+ * the tree from the keyboard, which is most of the point of having it. With no
114
+ * text there is no caret to move and no word to space, so the tree takes them;
115
+ * type one character and they revert to the field.
116
+ */
117
+ const TEXT_ENTRY_KEYS = new Set([' ', 'ArrowLeft', 'ArrowRight']);
118
+
119
+ /**
120
+ * Drive the tree from the keyboard.
121
+ *
122
+ * Returns `true` when the key was consumed, so the caller can `preventDefault`
123
+ * only for keys this actually handled — an unhandled key must still reach the
124
+ * search input untouched.
125
+ */
126
+ export function handleCategoryKeyDown(
127
+ key: string,
128
+ editingSearchText: boolean,
129
+ context: CategoryKeyContext,
130
+ ): boolean {
131
+ if (editingSearchText && TEXT_ENTRY_KEYS.has(key)) return false;
132
+ const handler = HANDLERS[key];
133
+ if (!handler) return false;
134
+ handler(context);
135
+ return true;
136
+ }
@@ -0,0 +1,210 @@
1
+ import type {
2
+ CategoryGroup,
3
+ CategorySelectionChip,
4
+ CategorySelectOption,
5
+ } from './CategorySelect.types';
6
+
7
+ /**
8
+ * Fold a string to its comparison form: no diacritics, lower case.
9
+ *
10
+ * Portuguese category names are full of them ("Águas", "Cápsulas", "Grãos"), and
11
+ * an admin typing "agua" or "graos" on a hurried phone keyboard means the same
12
+ * thing. Comparing folded forms on both sides makes the search accent-blind in
13
+ * both directions.
14
+ */
15
+ export function foldText(text: string): string {
16
+ return text
17
+ .normalize('NFD')
18
+ .replace(/[\u0300-\u036f]/g, '')
19
+ .toLowerCase();
20
+ }
21
+
22
+ /**
23
+ * Group a flat `parentId` list into top level + children, preserving input order.
24
+ *
25
+ * An option whose `parentId` names no top-level row is promoted to top level
26
+ * rather than dropped — a category is never invisible just because its parent
27
+ * was filtered out of the payload, or because the tree is deeper than two levels.
28
+ */
29
+ export function buildCategoryGroups(options: CategorySelectOption[]): CategoryGroup[] {
30
+ const roots = options.filter((option) => !option.parentId);
31
+ const rootIds = new Set(roots.map((root) => root.id));
32
+ const groups = new Map<string, CategoryGroup>(
33
+ roots.map((root) => [root.id, { category: root, subcategories: [] }]),
34
+ );
35
+ const orphans: CategoryGroup[] = [];
36
+
37
+ options.forEach((option) => {
38
+ if (!option.parentId) return;
39
+ const parent = groups.get(option.parentId);
40
+ if (parent) {
41
+ parent.subcategories.push(option);
42
+ return;
43
+ }
44
+ if (!rootIds.has(option.id)) orphans.push({ category: option, subcategories: [] });
45
+ });
46
+
47
+ return [...groups.values(), ...orphans];
48
+ }
49
+
50
+ /** Every selectable leaf id across all groups, in display order. */
51
+ export function collectLeafIds(groups: CategoryGroup[]): string[] {
52
+ return groups.flatMap((group) =>
53
+ group.subcategories.length > 0
54
+ ? group.subcategories.map((sub) => sub.id)
55
+ : [group.category.id],
56
+ );
57
+ }
58
+
59
+ /** The leaves a category stands for — its subcategories, or itself when childless. */
60
+ export function leavesOf(group: CategoryGroup): string[] {
61
+ return group.subcategories.length > 0
62
+ ? group.subcategories.map((sub) => sub.id)
63
+ : [group.category.id];
64
+ }
65
+
66
+ export type CategoryCheckState = 'off' | 'partial' | 'on';
67
+
68
+ /** Whether none, some, or all of a category's leaves are selected. */
69
+ export function categoryCheckState(
70
+ group: CategoryGroup,
71
+ selected: ReadonlySet<string>,
72
+ ): CategoryCheckState {
73
+ const leaves = leavesOf(group);
74
+ const hits = leaves.filter((id) => selected.has(id)).length;
75
+ if (hits === 0) return 'off';
76
+ return hits === leaves.length ? 'on' : 'partial';
77
+ }
78
+
79
+ /** Select every leaf of a category, or clear them all when already complete. */
80
+ export function toggleCategoryLeaves(
81
+ group: CategoryGroup,
82
+ selected: ReadonlySet<string>,
83
+ ): Set<string> {
84
+ const next = new Set(selected);
85
+ const leaves = leavesOf(group);
86
+ const turningOff = categoryCheckState(group, selected) === 'on';
87
+ leaves.forEach((id) => (turningOff ? next.delete(id) : next.add(id)));
88
+ return next;
89
+ }
90
+
91
+ /** Add an id when absent, remove it when present. */
92
+ export function toggleLeaf(id: string, selected: ReadonlySet<string>): Set<string> {
93
+ const next = new Set(selected);
94
+ if (!next.delete(id)) next.add(id);
95
+ return next;
96
+ }
97
+
98
+ /**
99
+ * Collapse a raw id set into the chips a human reads: a category that is fully
100
+ * selected becomes ONE chip bearing the category's name, rather than one chip
101
+ * per subcategory — "Bebidas" instead of six drinks.
102
+ */
103
+ export function summarizeSelection(
104
+ groups: CategoryGroup[],
105
+ selected: ReadonlySet<string>,
106
+ ): CategorySelectionChip[] {
107
+ return groups.flatMap((group): CategorySelectionChip[] => {
108
+ const state = categoryCheckState(group, selected);
109
+ if (state === 'off') return [];
110
+ if (state === 'on') {
111
+ return [{ id: group.category.id, label: group.category.name, whole: true }];
112
+ }
113
+ return group.subcategories
114
+ .filter((sub) => selected.has(sub.id))
115
+ .map((sub) => ({ id: sub.id, label: sub.name, whole: false }));
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Drop the leaves a chip stands for. A `whole` chip removes the whole category;
121
+ * any other chip removes just that leaf.
122
+ */
123
+ export function removeChip(
124
+ groups: CategoryGroup[],
125
+ chipId: string,
126
+ selected: ReadonlySet<string>,
127
+ ): Set<string> {
128
+ const next = new Set(selected);
129
+ const group = groups.find((candidate) => candidate.category.id === chipId);
130
+ if (group) {
131
+ leavesOf(group).forEach((id) => next.delete(id));
132
+ return next;
133
+ }
134
+ next.delete(chipId);
135
+ return next;
136
+ }
137
+
138
+ /**
139
+ * Search across BOTH levels, keeping every hit under its parent.
140
+ *
141
+ * A category that matches keeps all of its subcategories, so "Bebidas" shows the
142
+ * whole group. A category that does not match keeps only the subcategories that
143
+ * do — the hit still arrives with its parent visible above it, which is the
144
+ * context that tells "Massas" (mercearia) from "Massas" (pratos principais).
145
+ */
146
+ export function filterCategoryGroups(groups: CategoryGroup[], query: string): CategoryGroup[] {
147
+ const needle = foldText(query.trim());
148
+ if (!needle) return groups;
149
+ return groups.flatMap((group) => {
150
+ if (foldText(group.category.name).includes(needle)) return [group];
151
+ const subcategories = group.subcategories.filter((sub) =>
152
+ foldText(sub.name).includes(needle),
153
+ );
154
+ return subcategories.length > 0 ? [{ category: group.category, subcategories }] : [];
155
+ });
156
+ }
157
+
158
+ /** A run of text, flagged when it is the part that matched the query. */
159
+ interface HighlightSegment {
160
+ text: string;
161
+ match: boolean;
162
+ }
163
+
164
+ /**
165
+ * Split a label around the query hit so the view can mark it.
166
+ *
167
+ * Slicing uses indices from the FOLDED text but cuts the ORIGINAL, which is safe
168
+ * because folding is per-character here: NFD-stripping combining marks and
169
+ * lower-casing both preserve offsets for the scripts these names use.
170
+ */
171
+ export function highlightSegments(text: string, query: string): HighlightSegment[] {
172
+ const needle = foldText(query.trim());
173
+ if (!needle) return [{ text, match: false }];
174
+ const at = foldText(text).indexOf(needle);
175
+ if (at < 0) return [{ text, match: false }];
176
+ return [
177
+ { text: text.slice(0, at), match: false },
178
+ { text: text.slice(at, at + needle.length), match: true },
179
+ { text: text.slice(at + needle.length), match: false },
180
+ ].filter((segment) => segment.text.length > 0);
181
+ }
182
+
183
+ /** A flattened row, so keyboard navigation can walk what is actually on screen. */
184
+ export interface CategoryRowRef {
185
+ kind: 'category' | 'subcategory';
186
+ id: string;
187
+ /** Owning category id — lets ← jump from a subcategory back to its parent. */
188
+ parentId?: string;
189
+ }
190
+
191
+ /** The visible rows, in order, honouring which categories are expanded. */
192
+ export function flattenRows(
193
+ groups: CategoryGroup[],
194
+ isExpanded: (categoryId: string) => boolean,
195
+ ): CategoryRowRef[] {
196
+ return groups.flatMap((group) => {
197
+ const head: CategoryRowRef = { kind: 'category', id: group.category.id };
198
+ if (!isExpanded(group.category.id)) return [head];
199
+ return [
200
+ head,
201
+ ...group.subcategories.map(
202
+ (sub): CategoryRowRef => ({
203
+ kind: 'subcategory',
204
+ id: sub.id,
205
+ parentId: group.category.id,
206
+ }),
207
+ ),
208
+ ];
209
+ });
210
+ }
@@ -0,0 +1,19 @@
1
+ export { CategorySelect } from './CategorySelect';
2
+ export type {
3
+ CategoryGroup,
4
+ CategoryMultiSelectProps,
5
+ CategorySelectBaseProps,
6
+ CategorySelectionChip,
7
+ CategorySelectOption,
8
+ CategorySelectProps,
9
+ CategorySingleSelectProps,
10
+ } from './CategorySelect.types';
11
+ export {
12
+ buildCategoryGroups,
13
+ categoryCheckState,
14
+ collectLeafIds,
15
+ filterCategoryGroups,
16
+ foldText,
17
+ leavesOf,
18
+ summarizeSelection,
19
+ } from './category-tree';
@@ -0,0 +1,101 @@
1
+ import { useCallback, useMemo } from 'react';
2
+
3
+ import type { CategoryGroup, CategorySelectProps } from './CategorySelect.types';
4
+ import type { CategorySelectState } from './useCategorySelect';
5
+
6
+ /** The verbs a row, a footer button or a key press can invoke. */
7
+ export interface CategoryActions {
8
+ /** Publish the draft to the caller and close (multi-select). */
9
+ commit: () => void;
10
+ /** Choose one id and close (single-select). */
11
+ pick: (id: string) => void;
12
+ /** Close without publishing, returning focus to the trigger. */
13
+ cancel: () => void;
14
+ /** Empty the APPLIED selection straight from the closed trigger. */
15
+ clearSelection: () => void;
16
+ activateCategory: (group: CategoryGroup) => void;
17
+ activateSubcategory: (id: string) => void;
18
+ }
19
+
20
+ interface UseCategoryCommitArgs {
21
+ props: CategorySelectProps;
22
+ state: CategorySelectState;
23
+ single: boolean;
24
+ allowParentSelection: boolean;
25
+ }
26
+
27
+ /**
28
+ * The commit semantics, which are the one thing the two modes genuinely differ
29
+ * on: multi-select batches into a draft and publishes on Apply, single-select
30
+ * publishes the moment a row is chosen.
31
+ */
32
+ export function useCategoryCommit({
33
+ props,
34
+ state,
35
+ single,
36
+ allowParentSelection,
37
+ }: UseCategoryCommitArgs): CategoryActions {
38
+ const { closePanel, triggerRef, toggleCategory, toggleSubcategory, toggleExpanded } = state;
39
+
40
+ const closeAndRefocus = useCallback(() => {
41
+ closePanel();
42
+ triggerRef.current?.focus();
43
+ }, [closePanel, triggerRef]);
44
+
45
+ const commit = useCallback(() => {
46
+ if (props.mode === 'single') return;
47
+ props.onChange([...state.draft]);
48
+ closeAndRefocus();
49
+ }, [props, state.draft, closeAndRefocus]);
50
+
51
+ const pick = useCallback(
52
+ (id: string) => {
53
+ if (props.mode !== 'single') return;
54
+ props.onChange(id);
55
+ closeAndRefocus();
56
+ },
57
+ [props, closeAndRefocus],
58
+ );
59
+
60
+ const clearSelection = useCallback(() => {
61
+ if (props.mode === 'single') props.onChange(null);
62
+ else props.onChange([]);
63
+ }, [props]);
64
+
65
+ // A category row means different things per mode: pick the whole category,
66
+ // tick all its leaves, or — when it is only a heading — just fold it.
67
+ const activateCategory = useCallback(
68
+ (group: CategoryGroup) => {
69
+ if (allowParentSelection && single) {
70
+ pick(group.category.id);
71
+ return;
72
+ }
73
+ if (allowParentSelection) {
74
+ toggleCategory(group);
75
+ return;
76
+ }
77
+ toggleExpanded(group.category.id);
78
+ },
79
+ [allowParentSelection, single, pick, toggleCategory, toggleExpanded],
80
+ );
81
+
82
+ const activateSubcategory = useCallback(
83
+ (id: string) => {
84
+ if (single) pick(id);
85
+ else toggleSubcategory(id);
86
+ },
87
+ [single, pick, toggleSubcategory],
88
+ );
89
+
90
+ return useMemo(
91
+ () => ({
92
+ commit,
93
+ pick,
94
+ cancel: closeAndRefocus,
95
+ clearSelection,
96
+ activateCategory,
97
+ activateSubcategory,
98
+ }),
99
+ [commit, pick, closeAndRefocus, clearSelection, activateCategory, activateSubcategory],
100
+ );
101
+ }
@@ -0,0 +1,66 @@
1
+ import { useCallback } from 'react';
2
+
3
+ import { handleCategoryKeyDown } from './category-keyboard';
4
+ import type { CategoryActions } from './useCategoryCommit';
5
+ import type { CategorySelectState } from './useCategorySelect';
6
+
7
+ interface UseCategoryKeyHandlerArgs {
8
+ state: CategorySelectState;
9
+ single: boolean;
10
+ allowParentSelection: boolean;
11
+ actions: CategoryActions;
12
+ }
13
+
14
+ /**
15
+ * Bind the panel's keydown to the tree navigator.
16
+ *
17
+ * `preventDefault` fires only for keys the navigator actually consumed, so an
18
+ * unhandled key still reaches the search box — otherwise typing would be dead.
19
+ */
20
+ export function useCategoryKeyHandler({
21
+ state,
22
+ single,
23
+ allowParentSelection,
24
+ actions,
25
+ }: UseCategoryKeyHandlerArgs): (event: React.KeyboardEvent) => void {
26
+ const { isExpanded, toggleExpanded } = state;
27
+
28
+ const expand = useCallback(
29
+ (id: string) => {
30
+ if (!isExpanded(id)) toggleExpanded(id);
31
+ },
32
+ [isExpanded, toggleExpanded],
33
+ );
34
+
35
+ const collapse = useCallback(
36
+ (id: string) => {
37
+ if (isExpanded(id)) toggleExpanded(id);
38
+ },
39
+ [isExpanded, toggleExpanded],
40
+ );
41
+
42
+ return useCallback(
43
+ (event: React.KeyboardEvent) => {
44
+ const inSearchField = (event.target as HTMLElement | null)?.tagName === 'INPUT';
45
+ const editingText = inSearchField && state.query.length > 0;
46
+ const consumed = handleCategoryKeyDown(event.key, editingText, {
47
+ rows: state.rows,
48
+ activeIndex: state.activeIndex,
49
+ groups: state.visibleGroups,
50
+ single,
51
+ allowParentSelection,
52
+ setActiveIndex: state.setActiveIndex,
53
+ toggleExpanded,
54
+ expand,
55
+ collapse,
56
+ toggleCategory: state.toggleCategory,
57
+ toggleSubcategory: state.toggleSubcategory,
58
+ commit: actions.commit,
59
+ cancel: actions.cancel,
60
+ pick: actions.pick,
61
+ });
62
+ if (consumed) event.preventDefault();
63
+ },
64
+ [state, single, allowParentSelection, toggleExpanded, expand, collapse, actions],
65
+ );
66
+ }