@12-apps/ui 4.0.2 → 5.0.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,207 @@
1
+ 'use client';
2
+
3
+ import { Box, Button, Skeleton } from '@mui/material';
4
+
5
+ import { categoryCheckState } from './category-tree';
6
+ import {
7
+ CategoryHeadRow,
8
+ CategorySectionHeading,
9
+ SubcategoryRow,
10
+ } from './CategoryRows';
11
+ import { emptySx, listSx, skeletonSx } from './CategorySelect.styles';
12
+ import type { CategoryGroup } from './CategorySelect.types';
13
+
14
+ /** The design sets these labels in sentence case, not MUI's default caps. */
15
+ const SENTENCE_CASE = { textTransform: 'none' } as const;
16
+
17
+ /** How many skeleton rows stand in for the catalogue while it loads. */
18
+ const SKELETON_ROWS = 6;
19
+
20
+ interface PanelListProps {
21
+ groups: CategoryGroup[];
22
+ query: string;
23
+ draft: ReadonlySet<string>;
24
+ rowIds: string[];
25
+ activeIndex: number;
26
+ sheet: boolean;
27
+ single: boolean;
28
+ loading: boolean;
29
+ showCounts: boolean;
30
+ allowParentSelection: boolean;
31
+ isExpanded: (categoryId: string) => boolean;
32
+ onToggleExpanded: (categoryId: string) => void;
33
+ onActivateCategory: (group: CategoryGroup) => void;
34
+ onActivateSubcategory: (id: string) => void;
35
+ onClearQuery: () => void;
36
+ onCreateCategory?: (name?: string) => void;
37
+ listRef: React.RefObject<HTMLDivElement | null>;
38
+ dataTestId: string;
39
+ }
40
+
41
+ function LoadingRows(): React.JSX.Element {
42
+ return (
43
+ <>
44
+ {Array.from({ length: SKELETON_ROWS }, (_unused, index) => (
45
+ <Skeleton key={index} variant="rounded" sx={skeletonSx} />
46
+ ))}
47
+ </>
48
+ );
49
+ }
50
+
51
+ /** No categories exist at all — an onboarding moment, not an error. */
52
+ function EmptyCatalogue({
53
+ onCreateCategory,
54
+ dataTestId,
55
+ }: {
56
+ onCreateCategory?: (name?: string) => void;
57
+ dataTestId: string;
58
+ }): React.JSX.Element {
59
+ return (
60
+ <Box sx={emptySx} data-testid={`${dataTestId}-empty-catalogue`}>
61
+ <strong>Nenhuma categoria ainda</strong>
62
+ <p>Categorias organizam o cardápio e os filtros da loja.</p>
63
+ {onCreateCategory && (
64
+ <Button variant="contained" size="small" sx={SENTENCE_CASE} onClick={() => onCreateCategory()}>
65
+ Criar categoria
66
+ </Button>
67
+ )}
68
+ </Box>
69
+ );
70
+ }
71
+
72
+ /** The search found nothing — offer creation, or the way back. */
73
+ function NoResults({
74
+ query,
75
+ onClearQuery,
76
+ onCreateCategory,
77
+ dataTestId,
78
+ }: {
79
+ query: string;
80
+ onClearQuery: () => void;
81
+ onCreateCategory?: (name?: string) => void;
82
+ dataTestId: string;
83
+ }): React.JSX.Element {
84
+ return (
85
+ <Box sx={emptySx} data-testid={`${dataTestId}-no-results`}>
86
+ <strong>Nada encontrado para “{query}”</strong>
87
+ <p>Tente outro termo ou verifique a grafia.</p>
88
+ <Box sx={{ display: 'flex', gap: '6px', justifyContent: 'center' }}>
89
+ <Button variant="outlined" size="small" sx={SENTENCE_CASE} onClick={onClearQuery}>
90
+ Limpar busca
91
+ </Button>
92
+ {onCreateCategory && (
93
+ <Button
94
+ variant="contained"
95
+ size="small"
96
+ sx={SENTENCE_CASE}
97
+ data-testid={`${dataTestId}-create`}
98
+ onClick={() => onCreateCategory(query.trim())}
99
+ >
100
+ Criar “{query.trim()}”
101
+ </Button>
102
+ )}
103
+ </Box>
104
+ </Box>
105
+ );
106
+ }
107
+
108
+ /** One group: its heading or head row, then whichever children are visible. */
109
+ function CategoryGroupRows({
110
+ group,
111
+ props,
112
+ }: {
113
+ group: CategoryGroup;
114
+ props: PanelListProps;
115
+ }): React.JSX.Element {
116
+ const { draft, query, single, allowParentSelection, rowIds, activeIndex, sheet } = props;
117
+ const categoryId = group.category.id;
118
+ const expanded = props.isExpanded(categoryId);
119
+ const asHeading = single && !allowParentSelection;
120
+ const selectedCount = group.subcategories.filter((sub) => draft.has(sub.id)).length;
121
+
122
+ return (
123
+ <>
124
+ {asHeading ? (
125
+ <CategorySectionHeading option={group.category} query={query} />
126
+ ) : (
127
+ <CategoryHeadRow
128
+ option={group.category}
129
+ query={query}
130
+ expanded={expanded}
131
+ active={rowIds[activeIndex] === categoryId}
132
+ sheet={sheet}
133
+ checkState={
134
+ allowParentSelection ? categoryCheckState(group, draft) : undefined
135
+ }
136
+ selectedCount={selectedCount}
137
+ showCounts={props.showCounts}
138
+ onToggleExpanded={() => props.onToggleExpanded(categoryId)}
139
+ onActivate={() => props.onActivateCategory(group)}
140
+ dataTestId={props.dataTestId}
141
+ />
142
+ )}
143
+ {(expanded || asHeading) &&
144
+ group.subcategories.map((sub) => (
145
+ <SubcategoryRow
146
+ key={sub.id}
147
+ option={sub}
148
+ query={query}
149
+ selected={draft.has(sub.id)}
150
+ active={rowIds[activeIndex] === sub.id}
151
+ sheet={sheet}
152
+ single={single}
153
+ showCounts={props.showCounts}
154
+ onActivate={() => props.onActivateSubcategory(sub.id)}
155
+ dataTestId={props.dataTestId}
156
+ />
157
+ ))}
158
+ </>
159
+ );
160
+ }
161
+
162
+ /** The scrolling body of the panel, including its three non-list states. */
163
+ export function CategoryPanelList(props: PanelListProps): React.JSX.Element {
164
+ const { groups, loading, query, listRef, sheet, dataTestId } = props;
165
+
166
+ const body = (): React.JSX.Element => {
167
+ if (loading) return <LoadingRows />;
168
+ if (groups.length === 0 && query.trim().length > 0) {
169
+ return (
170
+ <NoResults
171
+ query={query}
172
+ onClearQuery={props.onClearQuery}
173
+ onCreateCategory={props.onCreateCategory}
174
+ dataTestId={dataTestId}
175
+ />
176
+ );
177
+ }
178
+ if (groups.length === 0) {
179
+ return (
180
+ <EmptyCatalogue
181
+ onCreateCategory={props.onCreateCategory}
182
+ dataTestId={dataTestId}
183
+ />
184
+ );
185
+ }
186
+ return (
187
+ <>
188
+ {groups.map((group) => (
189
+ <CategoryGroupRows key={group.category.id} group={group} props={props} />
190
+ ))}
191
+ </>
192
+ );
193
+ };
194
+
195
+ return (
196
+ <Box
197
+ ref={listRef}
198
+ role="listbox"
199
+ aria-multiselectable={props.single ? undefined : true}
200
+ tabIndex={-1}
201
+ data-testid={`${dataTestId}-list`}
202
+ sx={listSx(sheet)}
203
+ >
204
+ {body()}
205
+ </Box>
206
+ );
207
+ }
@@ -0,0 +1,56 @@
1
+ 'use client';
2
+
3
+ import { Drawer, Popover } from '@mui/material';
4
+
5
+ /** Chrome-free surface props: the panel draws its own border and shadow. */
6
+ const BARE_PAPER = { background: 'transparent', boxShadow: 'none' } as const;
7
+
8
+ interface SurfaceProps {
9
+ open: boolean;
10
+ /** True below the sheet breakpoint — anchor to the bottom edge instead. */
11
+ sheet: boolean;
12
+ anchorEl: HTMLElement | null;
13
+ onClose: () => void;
14
+ children: React.ReactNode;
15
+ }
16
+
17
+ /**
18
+ * Where the panel lives: anchored under the trigger on a pointer, or a bottom
19
+ * sheet on a phone.
20
+ *
21
+ * Both surfaces are modal, which is what gives the panel its dismiss-on-outside,
22
+ * Esc handling and focus containment for free rather than hand-rolled.
23
+ */
24
+ export function CategoryPanelSurface({
25
+ open,
26
+ sheet,
27
+ anchorEl,
28
+ onClose,
29
+ children,
30
+ }: SurfaceProps): React.JSX.Element {
31
+ if (sheet) {
32
+ return (
33
+ <Drawer
34
+ anchor="bottom"
35
+ open={open}
36
+ onClose={onClose}
37
+ slotProps={{ paper: { sx: BARE_PAPER } }}
38
+ >
39
+ {children}
40
+ </Drawer>
41
+ );
42
+ }
43
+ return (
44
+ <Popover
45
+ open={open}
46
+ anchorEl={anchorEl}
47
+ onClose={onClose}
48
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
49
+ transformOrigin={{ vertical: 'top', horizontal: 'left' }}
50
+ marginThreshold={8}
51
+ slotProps={{ paper: { sx: { ...BARE_PAPER, marginTop: '6px', overflow: 'visible' } } }}
52
+ >
53
+ {children}
54
+ </Popover>
55
+ );
56
+ }
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+
3
+ import { Box } from '@mui/material';
4
+ import { useMemo } from 'react';
5
+
6
+ import { collectLeafIds } from './category-tree';
7
+ import type { CategorySelectState } from './useCategorySelect';
8
+
9
+ /** The panel's text-button treatment. */
10
+ const linkButtonSx = {
11
+ background: 'none',
12
+ border: 0,
13
+ padding: 0,
14
+ font: 'inherit',
15
+ fontSize: 12,
16
+ color: 'primary.main',
17
+ cursor: 'pointer',
18
+ fontWeight: 550,
19
+ '&:hover': { textDecoration: 'underline' },
20
+ '&:disabled': { color: 'text.disabled', cursor: 'default', textDecoration: 'none' },
21
+ } as const;
22
+
23
+ /**
24
+ * Bulk actions for multi-select: mark everything, and fold everything.
25
+ *
26
+ * Both labels flip to their inverse once the action is spent ("Desmarcar tudo",
27
+ * "Recolher tudo"), so the control always says what pressing it will do rather
28
+ * than what state the list is in.
29
+ */
30
+ export function CategoryQuickActions({
31
+ state,
32
+ dataTestId,
33
+ }: {
34
+ state: CategorySelectState;
35
+ dataTestId: string;
36
+ }): React.JSX.Element {
37
+ const allLeafIds = useMemo(() => collectLeafIds(state.allGroups), [state.allGroups]);
38
+ const everythingPicked = allLeafIds.length > 0 && state.draft.size >= allLeafIds.length;
39
+ const allExpanded =
40
+ state.allGroups.length > 0 &&
41
+ state.allGroups.every((group) => state.isExpanded(group.category.id));
42
+
43
+ return (
44
+ <>
45
+ <Box
46
+ component="button"
47
+ type="button"
48
+ disabled={allLeafIds.length === 0}
49
+ data-testid={`${dataTestId}-select-all`}
50
+ sx={linkButtonSx}
51
+ onClick={() => state.setDraft(everythingPicked ? new Set() : new Set(allLeafIds))}
52
+ >
53
+ {everythingPicked ? 'Desmarcar tudo' : 'Marcar tudo'}
54
+ </Box>
55
+ <Box component="span" sx={{ color: 'divider' }}>
56
+ ·
57
+ </Box>
58
+ <Box
59
+ component="button"
60
+ type="button"
61
+ data-testid={`${dataTestId}-expand-all`}
62
+ sx={linkButtonSx}
63
+ onClick={() => state.setAllExpanded(!allExpanded)}
64
+ >
65
+ {allExpanded ? 'Recolher tudo' : 'Expandir tudo'}
66
+ </Box>
67
+ </>
68
+ );
69
+ }
@@ -0,0 +1,211 @@
1
+ 'use client';
2
+
3
+ import { Box } from '@mui/material';
4
+
5
+ import { CheckGlyph, DisclosureGlyph } from './CategoryIcons';
6
+ import { highlightSegments, type CategoryCheckState } from './category-tree';
7
+ import {
8
+ checkboxBarSx,
9
+ checkboxSx,
10
+ chevronButtonSx,
11
+ markSx,
12
+ METRICS,
13
+ radioSx,
14
+ rowMetaSx,
15
+ rowNameSx,
16
+ rowSx,
17
+ sectionHeadSx,
18
+ } from './CategorySelect.styles';
19
+ import type { CategorySelectOption } from './CategorySelect.types';
20
+
21
+ /** A label with the search hit marked. */
22
+ export function HighlightedName({
23
+ text,
24
+ query,
25
+ }: {
26
+ text: string;
27
+ query: string;
28
+ }): React.JSX.Element {
29
+ const segments = highlightSegments(text, query);
30
+ return (
31
+ <>
32
+ {segments.map((segment, index) =>
33
+ segment.match ? (
34
+ <Box component="mark" key={`${segment.text}-${index}`} sx={markSx}>
35
+ {segment.text}
36
+ </Box>
37
+ ) : (
38
+ <span key={`${segment.text}-${index}`}>{segment.text}</span>
39
+ ),
40
+ )}
41
+ </>
42
+ );
43
+ }
44
+
45
+ /** Tri-state checkbox. `partial` draws the bar the prototype uses for "some". */
46
+ export function CategoryCheckbox({ state }: { state: CategoryCheckState }): React.JSX.Element {
47
+ return (
48
+ <Box sx={(theme) => checkboxSx(theme, state)} aria-hidden="true">
49
+ {state === 'on' && <CheckGlyph />}
50
+ {state === 'partial' && <Box sx={checkboxBarSx} />}
51
+ </Box>
52
+ );
53
+ }
54
+
55
+ export function CategoryRadio({ on }: { on: boolean }): React.JSX.Element {
56
+ return <Box sx={(theme) => radioSx(theme, on)} aria-hidden="true" />;
57
+ }
58
+
59
+ interface CategoryHeadRowProps {
60
+ option: CategorySelectOption;
61
+ query: string;
62
+ expanded: boolean;
63
+ active: boolean;
64
+ sheet: boolean;
65
+ /** Absent in leaf-only mode, where the category is a heading with no checkbox. */
66
+ checkState?: CategoryCheckState;
67
+ selectedCount: number;
68
+ showCounts: boolean;
69
+ onToggleExpanded: () => void;
70
+ onActivate: () => void;
71
+ dataTestId: string;
72
+ }
73
+
74
+ /** The trailing number: how many children are picked, else the item count. */
75
+ function rowMeta(
76
+ selectedCount: number,
77
+ showCounts: boolean,
78
+ count: number | undefined,
79
+ ): { text: string; selected: boolean } | null {
80
+ if (selectedCount > 0) return { text: String(selectedCount), selected: true };
81
+ if (showCounts && count !== undefined) return { text: String(count), selected: false };
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * A top-level category row: disclosure chevron, optional checkbox, name, meta.
87
+ *
88
+ * The chevron is its OWN button inside the row button — clicking it only folds,
89
+ * while clicking the row does the row's job (expand as a heading, or tick when
90
+ * parents are selectable).
91
+ */
92
+ export function CategoryHeadRow({
93
+ option,
94
+ query,
95
+ expanded,
96
+ active,
97
+ sheet,
98
+ checkState,
99
+ selectedCount,
100
+ showCounts,
101
+ onToggleExpanded,
102
+ onActivate,
103
+ dataTestId,
104
+ }: CategoryHeadRowProps): React.JSX.Element {
105
+ const meta = rowMeta(selectedCount, showCounts, option.count);
106
+ return (
107
+ <Box
108
+ component="div"
109
+ role={checkState ? 'option' : 'button'}
110
+ aria-selected={checkState ? checkState === 'on' : undefined}
111
+ aria-expanded={expanded}
112
+ data-testid={`${dataTestId}-category-${option.id}`}
113
+ sx={(theme) => rowSx(theme, active, sheet)}
114
+ onClick={onActivate}
115
+ >
116
+ <Box
117
+ component="button"
118
+ type="button"
119
+ tabIndex={-1}
120
+ aria-label={`${expanded ? 'Recolher' : 'Expandir'} ${option.name}`}
121
+ data-testid={`${dataTestId}-expand-${option.id}`}
122
+ sx={(theme) => chevronButtonSx(theme, expanded)}
123
+ onClick={(event: React.MouseEvent) => {
124
+ event.stopPropagation();
125
+ onToggleExpanded();
126
+ }}
127
+ >
128
+ <DisclosureGlyph />
129
+ </Box>
130
+ {checkState ? (
131
+ <CategoryCheckbox state={checkState} />
132
+ ) : (
133
+ <Box sx={{ width: METRICS.boxSize, flex: '0 0 auto' }} />
134
+ )}
135
+ <Box component="span" sx={(theme) => rowNameSx(theme, true)}>
136
+ <HighlightedName text={option.name} query={query} />
137
+ </Box>
138
+ {meta && (
139
+ <Box component="span" sx={(theme) => rowMetaSx(theme, meta.selected)}>
140
+ {meta.text}
141
+ </Box>
142
+ )}
143
+ </Box>
144
+ );
145
+ }
146
+
147
+ interface SubcategoryRowProps {
148
+ option: CategorySelectOption;
149
+ query: string;
150
+ selected: boolean;
151
+ active: boolean;
152
+ sheet: boolean;
153
+ /** Single-select mode draws a radio; multi-select draws a checkbox. */
154
+ single: boolean;
155
+ showCounts: boolean;
156
+ onActivate: () => void;
157
+ dataTestId: string;
158
+ }
159
+
160
+ export function SubcategoryRow({
161
+ option,
162
+ query,
163
+ selected,
164
+ active,
165
+ sheet,
166
+ single,
167
+ showCounts,
168
+ onActivate,
169
+ dataTestId,
170
+ }: SubcategoryRowProps): React.JSX.Element {
171
+ return (
172
+ <Box
173
+ component="div"
174
+ role="option"
175
+ aria-selected={selected}
176
+ data-testid={`${dataTestId}-option-${option.id}`}
177
+ sx={(theme) => ({ ...rowSx(theme, active, sheet), paddingLeft: '14px' })}
178
+ onClick={onActivate}
179
+ >
180
+ <Box sx={{ width: METRICS.chevronButton, flex: '0 0 auto' }} />
181
+ {single ? (
182
+ <CategoryRadio on={selected} />
183
+ ) : (
184
+ <CategoryCheckbox state={selected ? 'on' : 'off'} />
185
+ )}
186
+ <Box component="span" sx={(theme) => rowNameSx(theme, false)}>
187
+ <HighlightedName text={option.name} query={query} />
188
+ </Box>
189
+ {showCounts && option.count !== undefined && (
190
+ <Box component="span" sx={(theme) => rowMetaSx(theme, false)}>
191
+ {option.count}
192
+ </Box>
193
+ )}
194
+ </Box>
195
+ );
196
+ }
197
+
198
+ /** The category as a non-interactive heading (single-select, leaf-only). */
199
+ export function CategorySectionHeading({
200
+ option,
201
+ query,
202
+ }: {
203
+ option: CategorySelectOption;
204
+ query: string;
205
+ }): React.JSX.Element {
206
+ return (
207
+ <Box sx={sectionHeadSx}>
208
+ <HighlightedName text={option.name} query={query} />
209
+ </Box>
210
+ );
211
+ }
@@ -0,0 +1,134 @@
1
+ # CategorySelect
2
+
3
+ A hierarchical category picker: **categories are the frame, subcategories are what
4
+ you pick.**
5
+
6
+ ```tsx
7
+ import { CategorySelect } from '@12-apps/ui/form/CategorySelect';
8
+ ```
9
+
10
+ ## Why it exists
11
+
12
+ A flat select over a two-level catalogue forces an ambiguity on the user: ticking
13
+ "Bebidas" might mean the category itself or everything under it, and there is no
14
+ way to tell which from looking. This component removes the question by making the
15
+ category a heading and the subcategory the selectable thing — and, where a caller
16
+ genuinely needs "the whole category" as a value, by making that an explicit opt-in
17
+ (`allowParentSelection`) with a tri-state checkbox that says so.
18
+
19
+ ## Options
20
+
21
+ Pass the catalogue **flat, carrying `parentId`** — the shape the API already
22
+ returns. The component nests it internally, memoised.
23
+
24
+ ```tsx
25
+ const options = [
26
+ { id: 'beb', name: 'Bebidas', count: 214 },
27
+ { id: 'beb.agua', name: 'Águas', parentId: 'beb', count: 38 },
28
+ { id: 'beb.refri', name: 'Refrigerantes', parentId: 'beb', count: 52 },
29
+ ];
30
+ ```
31
+
32
+ Never pre-indent `name` (`"— Águas"`). The row draws the nesting; a baked-in dash
33
+ travels everywhere the label goes, including the closed trigger.
34
+
35
+ A category with no children is selectable in its own right. An option whose
36
+ `parentId` matches nothing is promoted to top level rather than dropped, so a
37
+ partial payload never makes a category invisible.
38
+
39
+ ## The two modes
40
+
41
+ ### `multi` — the filter
42
+
43
+ Ticks accumulate in a **draft**; `onChange` fires only when Apply is pressed. This
44
+ is the point of the mode: the list behind the panel does not refetch on every
45
+ click, and dismissing the panel discards.
46
+
47
+ ```tsx
48
+ const [categories, setCategories] = useState<string[]>([]);
49
+
50
+ <CategorySelect options={options} value={categories} onChange={setCategories} />;
51
+ ```
52
+
53
+ ### `single` — the "move to…" picker
54
+
55
+ Choosing a row commits immediately; there is nothing to batch. The trigger then
56
+ reads `Pai › Filha`, so the chosen leaf keeps its context.
57
+
58
+ ```tsx
59
+ <CategorySelect
60
+ mode="single"
61
+ label="Categoria"
62
+ options={options}
63
+ value={categoryId}
64
+ onChange={setCategoryId}
65
+ />
66
+ ```
67
+
68
+ ## Behaviour worth knowing
69
+
70
+ - **Opens fully expanded.** Nothing a search could match hides behind a
71
+ disclosure.
72
+ - **Selected categories pin above the list.** They survive scrolling and
73
+ searching — a tick made at the bottom of a ten-category tree does not vanish
74
+ the moment you look for the next one.
75
+ - **Search is accent-insensitive, both directions.** `agua` finds `Águas`;
76
+ `graos` finds `Grãos e farináceos`. The hit is marked, and it always renders
77
+ under its parent, which is what tells `Massas` (mercearia) from `Massas`
78
+ (pratos principais).
79
+ - **A fully selected category collapses to one chip** bearing the category name,
80
+ rather than one chip per child.
81
+ - **Below 480px the panel becomes a bottom sheet.**
82
+
83
+ ## Keyboard
84
+
85
+ | Key | Does |
86
+ | --- | --- |
87
+ | `↑` `↓` | Move the cursor |
88
+ | `→` | Expand the category |
89
+ | `←` | Collapse it, or jump from a subcategory to its parent |
90
+ | `Space` | Mark the row (single-select: pick it) |
91
+ | `Enter` | Apply (single-select: pick) |
92
+ | `Esc` | Cancel, returning focus to the trigger |
93
+
94
+ `Space`, `←` and `→` belong to the search field **only while it holds text**. The
95
+ panel opens focused on an empty field, so yielding them unconditionally would
96
+ leave the tree undrivable from the keyboard; type one character and they revert to
97
+ the field.
98
+
99
+ ## Styling
100
+
101
+ Geometry is the design's absolute pixels — 38px trigger, 340px panel, 36px rows,
102
+ 1.5px checkbox borders. Those are deliberately not derived from `theme.spacing`,
103
+ which is an 8px grid and would round the design away.
104
+
105
+ **Colours come from the theme.** A tenant can white-label the palette, and the
106
+ library ships a dark mode; a hard-coded indigo would survive the rebrand while the
107
+ rest of the screen changed. On the default light theme the tokens resolve to the
108
+ design's own values.
109
+
110
+ ## Props
111
+
112
+ | Prop | Type | Default | |
113
+ | --- | --- | --- | --- |
114
+ | `options` | `CategorySelectOption[]` | — | Flat, with `parentId` |
115
+ | `mode` | `'multi' \| 'single'` | `'multi'` | |
116
+ | `value` | `string[]` / `string \| null` | — | Shape follows `mode` |
117
+ | `onChange` | `(next) => void` | — | Fires on Apply / on pick |
118
+ | `label` | `string` | — | Rendered above the trigger |
119
+ | `placeholder` | `string` | `'Categoria'` / `'Mover para…'` | |
120
+ | `error` | `string` | — | Message + errored styling |
121
+ | `disabled` | `boolean` | `false` | |
122
+ | `loading` | `boolean` | `false` | Skeleton rows |
123
+ | `fullWidth` | `boolean` | `false` | |
124
+ | `showCounts` | `boolean` | `false` | Trailing `count` per row |
125
+ | `allowParentSelection` | `boolean` | `false` | Makes categories selectable |
126
+ | `onCreateCategory` | `() => void` | — | CTA on the empty catalogue |
127
+ | `dataTestId` | `string` | `'category-select'` | |
128
+
129
+ ## Test ids
130
+
131
+ Derived from `dataTestId`: `-trigger`, `-count`, `-clear-trigger`, `-panel`,
132
+ `-search`, `-search-clear`, `-select-all`, `-expand-all`, `-pinned`,
133
+ `-unpin-<id>`, `-list`, `-category-<id>`, `-expand-<id>`, `-option-<id>`,
134
+ `-footer`, `-clear`, `-apply`, `-cancel`, `-no-results`, `-empty-catalogue`.