@12-apps/ui 4.0.1 → 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.
- package/package.json +2 -1
- package/src/components/data-display/Alert/Alert.styles.tsx +146 -39
- package/src/components/data-display/Alert/Alert.tsx +3 -14
- package/src/components/data-display/DataViews/data-views-category-pill.tsx +98 -0
- package/src/components/data-display/DataViews/data-views-filter-panel.tsx +11 -14
- package/src/components/data-display/DataViews/data-views-inline-bar.tsx +8 -13
- package/src/components/data-display/DataViews/data-views-types.ts +10 -2
- package/src/components/form/Button/Button.styles.ts +25 -6
- package/src/components/form/CategorySelect/CategoryIcons.tsx +52 -0
- package/src/components/form/CategorySelect/CategoryPanel.tsx +155 -0
- package/src/components/form/CategorySelect/CategoryPanelFoot.tsx +94 -0
- package/src/components/form/CategorySelect/CategoryPanelHead.tsx +167 -0
- package/src/components/form/CategorySelect/CategoryPanelList.tsx +207 -0
- package/src/components/form/CategorySelect/CategoryPanelSurface.tsx +56 -0
- package/src/components/form/CategorySelect/CategoryQuickActions.tsx +69 -0
- package/src/components/form/CategorySelect/CategoryRows.tsx +211 -0
- package/src/components/form/CategorySelect/CategorySelect.md +134 -0
- package/src/components/form/CategorySelect/CategorySelect.styles.ts +354 -0
- package/src/components/form/CategorySelect/CategorySelect.tsx +183 -0
- package/src/components/form/CategorySelect/CategorySelect.types.ts +103 -0
- package/src/components/form/CategorySelect/CategoryTrigger.tsx +109 -0
- package/src/components/form/CategorySelect/category-keyboard.ts +136 -0
- package/src/components/form/CategorySelect/category-tree.ts +210 -0
- package/src/components/form/CategorySelect/index.ts +19 -0
- package/src/components/form/CategorySelect/useCategoryCommit.ts +101 -0
- package/src/components/form/CategorySelect/useCategoryKeyHandler.ts +66 -0
- package/src/components/form/CategorySelect/useCategorySelect.ts +172 -0
- package/src/components/form/total-form/Fields/CategoryField.tsx +57 -0
- package/src/components/form/total-form/Fields/index.ts +2 -0
- 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
|
+
}
|