@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.
- package/package.json +2 -1
- 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/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,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`.
|