@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.
- 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,354 @@
|
|
|
1
|
+
import { alpha, type CSSObject, type Theme } from '@mui/material';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The prototype's METRICS, verbatim.
|
|
5
|
+
*
|
|
6
|
+
* Every number here is lifted from the approved design and is intentionally
|
|
7
|
+
* absolute rather than derived from `theme.spacing`: the design is specified on
|
|
8
|
+
* a half-pixel type scale (13.5px rows, 1.5px checkbox borders) that an 8px
|
|
9
|
+
* spacing grid cannot express without rounding the look away.
|
|
10
|
+
*
|
|
11
|
+
* COLOURS are the deliberate exception — they come from the theme, because a
|
|
12
|
+
* tenant can white-label the palette (a hard-coded indigo would survive the
|
|
13
|
+
* rebrand and the rest of the screen would not) and because the library ships a
|
|
14
|
+
* dark mode. On the default light indigo theme the tokens below resolve to the
|
|
15
|
+
* prototype's own values.
|
|
16
|
+
*/
|
|
17
|
+
export const METRICS = {
|
|
18
|
+
triggerHeight: 38,
|
|
19
|
+
triggerRadius: 10,
|
|
20
|
+
panelWidth: 340,
|
|
21
|
+
panelRadius: 14,
|
|
22
|
+
listMaxHeight: 290,
|
|
23
|
+
sheetListMaxHeight: 340,
|
|
24
|
+
rowHeight: 36,
|
|
25
|
+
sheetRowHeight: 42,
|
|
26
|
+
rowRadius: 8,
|
|
27
|
+
boxSize: 18,
|
|
28
|
+
chevronButton: 22,
|
|
29
|
+
footerButton: 32,
|
|
30
|
+
sheetFooterButton: 38,
|
|
31
|
+
/** Below this width the panel becomes a bottom sheet. */
|
|
32
|
+
sheetBreakpoint: 480,
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
/** Accent family, derived so a white-labelled palette carries through. */
|
|
36
|
+
const accent = (theme: Theme) => ({
|
|
37
|
+
main: theme.palette.primary.main,
|
|
38
|
+
ink: theme.palette.primary.dark,
|
|
39
|
+
soft: alpha(theme.palette.primary.main, 0.08),
|
|
40
|
+
edge: alpha(theme.palette.primary.main, 0.28),
|
|
41
|
+
ring: alpha(theme.palette.primary.main, 0.15),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** The closed control. `selected` swaps it to the accent-tinted "has filter" skin. */
|
|
45
|
+
export const triggerSx = (theme: Theme, selected: boolean, open: boolean): CSSObject => {
|
|
46
|
+
const brand = accent(theme);
|
|
47
|
+
return {
|
|
48
|
+
display: 'flex',
|
|
49
|
+
alignItems: 'center',
|
|
50
|
+
gap: '8px',
|
|
51
|
+
height: METRICS.triggerHeight,
|
|
52
|
+
padding: '0 10px 0 12px',
|
|
53
|
+
borderRadius: `${METRICS.triggerRadius}px`,
|
|
54
|
+
border: `1px solid ${open || selected ? brand.edge : theme.palette.divider}`,
|
|
55
|
+
background: selected ? brand.soft : theme.palette.background.paper,
|
|
56
|
+
color: selected ? brand.ink : theme.palette.text.primary,
|
|
57
|
+
font: 'inherit',
|
|
58
|
+
fontSize: 13,
|
|
59
|
+
cursor: 'pointer',
|
|
60
|
+
maxWidth: '100%',
|
|
61
|
+
transition: 'border-color .12s, box-shadow .12s',
|
|
62
|
+
boxShadow: open ? `0 0 0 3px ${brand.ring}` : 'none',
|
|
63
|
+
'&:hover': { borderColor: open || selected ? brand.edge : theme.palette.text.disabled },
|
|
64
|
+
'&:focus-visible': {
|
|
65
|
+
outline: 'none',
|
|
66
|
+
borderColor: brand.main,
|
|
67
|
+
boxShadow: `0 0 0 3px ${alpha(brand.main, 0.18)}`,
|
|
68
|
+
},
|
|
69
|
+
'&:disabled': { opacity: 0.5, cursor: 'default' },
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** The count pill inside the trigger. */
|
|
74
|
+
export const triggerCountSx = (theme: Theme): CSSObject => ({
|
|
75
|
+
fontWeight: 600,
|
|
76
|
+
fontSize: 11,
|
|
77
|
+
lineHeight: 1,
|
|
78
|
+
background: theme.palette.primary.main,
|
|
79
|
+
color: theme.palette.primary.contrastText,
|
|
80
|
+
borderRadius: '999px',
|
|
81
|
+
padding: '3px 6px',
|
|
82
|
+
minWidth: 18,
|
|
83
|
+
textAlign: 'center',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** The trigger's inline clear (×) and the chevron. */
|
|
87
|
+
export const triggerClearSx: CSSObject = {
|
|
88
|
+
width: 20,
|
|
89
|
+
height: 20,
|
|
90
|
+
flex: '0 0 auto',
|
|
91
|
+
border: 0,
|
|
92
|
+
background: 'transparent',
|
|
93
|
+
borderRadius: '5px',
|
|
94
|
+
display: 'grid',
|
|
95
|
+
placeItems: 'center',
|
|
96
|
+
cursor: 'pointer',
|
|
97
|
+
color: 'inherit',
|
|
98
|
+
opacity: 0.6,
|
|
99
|
+
padding: 0,
|
|
100
|
+
'&:hover': { background: alpha('#000', 0.08), opacity: 1 },
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const triggerChevronSx = (open: boolean): CSSObject => ({
|
|
104
|
+
width: 14,
|
|
105
|
+
height: 14,
|
|
106
|
+
flex: '0 0 auto',
|
|
107
|
+
opacity: 0.55,
|
|
108
|
+
transition: 'transform .15s',
|
|
109
|
+
transform: open ? 'rotate(180deg)' : 'none',
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/** The floating panel. */
|
|
113
|
+
export const panelSx = (theme: Theme): CSSObject => ({
|
|
114
|
+
width: METRICS.panelWidth,
|
|
115
|
+
maxWidth: '100%',
|
|
116
|
+
background: theme.palette.background.paper,
|
|
117
|
+
border: `1px solid ${theme.palette.divider}`,
|
|
118
|
+
borderRadius: `${METRICS.panelRadius}px`,
|
|
119
|
+
boxShadow: '0 1px 2px rgba(16,20,35,.05), 0 18px 40px -12px rgba(16,20,35,.28)',
|
|
120
|
+
display: 'flex',
|
|
121
|
+
flexDirection: 'column',
|
|
122
|
+
overflow: 'hidden',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/** Bottom-sheet skin, applied under {@link METRICS.sheetBreakpoint}. */
|
|
126
|
+
export const sheetSx = (theme: Theme): CSSObject => ({
|
|
127
|
+
width: '100%',
|
|
128
|
+
maxWidth: 'none',
|
|
129
|
+
borderRadius: '18px 18px 0 0',
|
|
130
|
+
borderBottom: 0,
|
|
131
|
+
position: 'relative',
|
|
132
|
+
paddingTop: '4px',
|
|
133
|
+
'&::before': {
|
|
134
|
+
content: '""',
|
|
135
|
+
position: 'absolute',
|
|
136
|
+
top: 7,
|
|
137
|
+
left: '50%',
|
|
138
|
+
transform: 'translateX(-50%)',
|
|
139
|
+
width: 36,
|
|
140
|
+
height: 4,
|
|
141
|
+
borderRadius: '2px',
|
|
142
|
+
background: theme.palette.divider,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
export const panelHeadSx = (theme: Theme, sheet: boolean): CSSObject => ({
|
|
147
|
+
padding: sheet ? '18px 10px 8px' : '10px 10px 8px',
|
|
148
|
+
borderBottom: `1px solid ${theme.palette.divider}`,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
export const pinnedSx = (theme: Theme): CSSObject => ({
|
|
152
|
+
padding: '9px 10px',
|
|
153
|
+
borderBottom: `1px solid ${theme.palette.divider}`,
|
|
154
|
+
background: alpha(theme.palette.primary.main, 0.03),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
export const pinnedLabelSx = (theme: Theme): CSSObject => ({
|
|
158
|
+
fontWeight: 600,
|
|
159
|
+
fontSize: 10,
|
|
160
|
+
lineHeight: 1,
|
|
161
|
+
letterSpacing: '.08em',
|
|
162
|
+
textTransform: 'uppercase',
|
|
163
|
+
color: theme.palette.text.secondary,
|
|
164
|
+
marginBottom: '7px',
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
export const chipWrapSx: CSSObject = {
|
|
168
|
+
display: 'flex',
|
|
169
|
+
flexWrap: 'wrap',
|
|
170
|
+
gap: '5px',
|
|
171
|
+
maxHeight: 74,
|
|
172
|
+
overflow: 'auto',
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** A selected-category chip, in the pinned tray and the applied bar alike. */
|
|
176
|
+
export const chipSx = (theme: Theme): CSSObject => {
|
|
177
|
+
const brand = accent(theme);
|
|
178
|
+
return {
|
|
179
|
+
display: 'inline-flex',
|
|
180
|
+
alignItems: 'center',
|
|
181
|
+
gap: '5px',
|
|
182
|
+
fontSize: 12,
|
|
183
|
+
background: brand.soft,
|
|
184
|
+
color: brand.ink,
|
|
185
|
+
border: `1px solid ${brand.edge}`,
|
|
186
|
+
borderRadius: '7px',
|
|
187
|
+
padding: '3px 4px 3px 8px',
|
|
188
|
+
'& button': {
|
|
189
|
+
border: 0,
|
|
190
|
+
background: 'transparent',
|
|
191
|
+
color: 'inherit',
|
|
192
|
+
cursor: 'pointer',
|
|
193
|
+
padding: 0,
|
|
194
|
+
width: 16,
|
|
195
|
+
height: 16,
|
|
196
|
+
borderRadius: '4px',
|
|
197
|
+
display: 'grid',
|
|
198
|
+
placeItems: 'center',
|
|
199
|
+
opacity: 0.65,
|
|
200
|
+
fontSize: 13,
|
|
201
|
+
lineHeight: 1,
|
|
202
|
+
'&:hover': { background: alpha(theme.palette.primary.main, 0.16), opacity: 1 },
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export const listSx = (sheet: boolean): CSSObject => ({
|
|
208
|
+
overflow: 'auto',
|
|
209
|
+
maxHeight: sheet ? METRICS.sheetListMaxHeight : METRICS.listMaxHeight,
|
|
210
|
+
padding: '6px 6px 8px',
|
|
211
|
+
scrollPadding: '8px 0',
|
|
212
|
+
overscrollBehavior: 'contain',
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
/** A row. `active` is the KEYBOARD cursor — deliberately distinct from hover. */
|
|
216
|
+
export const rowSx = (theme: Theme, active: boolean, sheet: boolean): CSSObject => ({
|
|
217
|
+
display: 'flex',
|
|
218
|
+
alignItems: 'center',
|
|
219
|
+
gap: '8px',
|
|
220
|
+
padding: '0 8px',
|
|
221
|
+
height: sheet ? METRICS.sheetRowHeight : METRICS.rowHeight,
|
|
222
|
+
borderRadius: `${METRICS.rowRadius}px`,
|
|
223
|
+
cursor: 'pointer',
|
|
224
|
+
userSelect: 'none',
|
|
225
|
+
position: 'relative',
|
|
226
|
+
width: '100%',
|
|
227
|
+
border: 0,
|
|
228
|
+
textAlign: 'left',
|
|
229
|
+
font: 'inherit',
|
|
230
|
+
background: active ? alpha(theme.palette.primary.main, 0.08) : 'transparent',
|
|
231
|
+
boxShadow: active ? `inset 0 0 0 1px ${alpha(theme.palette.primary.main, 0.3)}` : 'none',
|
|
232
|
+
'&:hover': {
|
|
233
|
+
background: active
|
|
234
|
+
? alpha(theme.palette.primary.main, 0.08)
|
|
235
|
+
: theme.palette.action.hover,
|
|
236
|
+
},
|
|
237
|
+
'&:focus-visible': { outline: `2px solid ${theme.palette.primary.main}`, outlineOffset: 1 },
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
export const rowNameSx = (theme: Theme, isCategory: boolean): CSSObject => ({
|
|
241
|
+
flex: 1,
|
|
242
|
+
minWidth: 0,
|
|
243
|
+
whiteSpace: 'nowrap',
|
|
244
|
+
overflow: 'hidden',
|
|
245
|
+
textOverflow: 'ellipsis',
|
|
246
|
+
fontSize: 13.5,
|
|
247
|
+
fontWeight: isCategory ? 600 : 450,
|
|
248
|
+
color: isCategory ? theme.palette.text.primary : theme.palette.text.secondary,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
export const rowMetaSx = (theme: Theme, selected: boolean): CSSObject => ({
|
|
252
|
+
font: '500 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
253
|
+
color: selected ? theme.palette.primary.main : theme.palette.text.secondary,
|
|
254
|
+
fontWeight: selected ? 600 : 500,
|
|
255
|
+
flex: '0 0 auto',
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
/** The query hit inside a row label. */
|
|
259
|
+
export const markSx: CSSObject = {
|
|
260
|
+
background: '#fff2a8',
|
|
261
|
+
color: 'inherit',
|
|
262
|
+
borderRadius: '2px',
|
|
263
|
+
padding: '0 1px',
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
export const chevronButtonSx = (theme: Theme, open: boolean): CSSObject => ({
|
|
267
|
+
width: METRICS.chevronButton,
|
|
268
|
+
height: METRICS.chevronButton,
|
|
269
|
+
flex: '0 0 auto',
|
|
270
|
+
border: 0,
|
|
271
|
+
background: 'transparent',
|
|
272
|
+
borderRadius: '6px',
|
|
273
|
+
display: 'grid',
|
|
274
|
+
placeItems: 'center',
|
|
275
|
+
cursor: 'pointer',
|
|
276
|
+
color: theme.palette.text.secondary,
|
|
277
|
+
padding: 0,
|
|
278
|
+
'&:hover': { background: theme.palette.action.hover, color: theme.palette.text.primary },
|
|
279
|
+
'& svg': { width: 12, height: 12, transition: 'transform .15s', transform: open ? 'rotate(90deg)' : 'none' },
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
/** The tri-state checkbox: unchecked, checked, or the partial bar. */
|
|
283
|
+
export const checkboxSx = (theme: Theme, state: 'off' | 'partial' | 'on'): CSSObject => ({
|
|
284
|
+
width: METRICS.boxSize,
|
|
285
|
+
height: METRICS.boxSize,
|
|
286
|
+
flex: '0 0 auto',
|
|
287
|
+
border: `1.5px solid ${state === 'off' ? theme.palette.divider : theme.palette.primary.main}`,
|
|
288
|
+
borderRadius: '5px',
|
|
289
|
+
background: state === 'off' ? theme.palette.background.paper : theme.palette.primary.main,
|
|
290
|
+
display: 'grid',
|
|
291
|
+
placeItems: 'center',
|
|
292
|
+
transition: 'background .1s, border-color .1s',
|
|
293
|
+
'& svg': { width: 12, height: 12, color: theme.palette.primary.contrastText },
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
/** The partial bar drawn inside a half-selected checkbox. */
|
|
297
|
+
export const checkboxBarSx = (theme: Theme): CSSObject => ({
|
|
298
|
+
width: 9,
|
|
299
|
+
height: 2,
|
|
300
|
+
background: theme.palette.primary.contrastText,
|
|
301
|
+
borderRadius: '1px',
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
export const radioSx = (theme: Theme, on: boolean): CSSObject => ({
|
|
305
|
+
width: METRICS.boxSize,
|
|
306
|
+
height: METRICS.boxSize,
|
|
307
|
+
flex: '0 0 auto',
|
|
308
|
+
borderRadius: '50%',
|
|
309
|
+
background: theme.palette.background.paper,
|
|
310
|
+
border: on
|
|
311
|
+
? `5.5px solid ${theme.palette.primary.main}`
|
|
312
|
+
: `1.5px solid ${theme.palette.divider}`,
|
|
313
|
+
boxSizing: 'border-box',
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
export const sectionHeadSx = (theme: Theme): CSSObject => ({
|
|
317
|
+
fontWeight: 600,
|
|
318
|
+
fontSize: 10,
|
|
319
|
+
lineHeight: 1,
|
|
320
|
+
letterSpacing: '.08em',
|
|
321
|
+
textTransform: 'uppercase',
|
|
322
|
+
color: theme.palette.text.secondary,
|
|
323
|
+
padding: '9px 8px 6px',
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
export const footerSx = (theme: Theme): CSSObject => ({
|
|
327
|
+
display: 'flex',
|
|
328
|
+
alignItems: 'center',
|
|
329
|
+
justifyContent: 'space-between',
|
|
330
|
+
gap: '8px',
|
|
331
|
+
padding: '9px 10px',
|
|
332
|
+
borderTop: `1px solid ${theme.palette.divider}`,
|
|
333
|
+
background: theme.palette.action.hover,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
export const emptySx = (theme: Theme): CSSObject => ({
|
|
337
|
+
padding: '26px 18px',
|
|
338
|
+
textAlign: 'center',
|
|
339
|
+
color: theme.palette.text.secondary,
|
|
340
|
+
'& strong': {
|
|
341
|
+
display: 'block',
|
|
342
|
+
color: theme.palette.text.primary,
|
|
343
|
+
fontSize: 13.5,
|
|
344
|
+
marginBottom: '4px',
|
|
345
|
+
},
|
|
346
|
+
'& p': { margin: '0 0 12px', fontSize: 12.5, lineHeight: 1.5 },
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
/** Skeleton row used while the catalogue loads. */
|
|
350
|
+
export const skeletonSx: CSSObject = {
|
|
351
|
+
height: METRICS.rowHeight,
|
|
352
|
+
margin: '4px 6px',
|
|
353
|
+
borderRadius: `${METRICS.rowRadius}px`,
|
|
354
|
+
};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
4
|
+
import { useMediaQuery, useTheme } from '@mui/material';
|
|
5
|
+
|
|
6
|
+
import { summarizeSelection } from './category-tree';
|
|
7
|
+
import { CategoryPanel } from './CategoryPanel';
|
|
8
|
+
import { CategoryPanelSurface } from './CategoryPanelSurface';
|
|
9
|
+
import { CategoryTrigger } from './CategoryTrigger';
|
|
10
|
+
import { METRICS } from './CategorySelect.styles';
|
|
11
|
+
import type { CategoryGroup, CategorySelectProps } from './CategorySelect.types';
|
|
12
|
+
import { useCategoryCommit } from './useCategoryCommit';
|
|
13
|
+
import { useCategoryKeyHandler } from './useCategoryKeyHandler';
|
|
14
|
+
import { useCategorySelect } from './useCategorySelect';
|
|
15
|
+
import { FormControl, FormLabel, FormMessage } from '../Form';
|
|
16
|
+
|
|
17
|
+
/** The applied selection, always as an array — the two modes differ in shape. */
|
|
18
|
+
function appliedIds(props: CategorySelectProps): string[] {
|
|
19
|
+
if (props.mode === 'single') return props.value ? [props.value] : [];
|
|
20
|
+
return props.value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** True when the draft differs from what is already applied. */
|
|
24
|
+
function draftChanged(draft: ReadonlySet<string>, applied: readonly string[]): boolean {
|
|
25
|
+
return draft.size !== applied.length || applied.some((id) => !draft.has(id));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** "Pai › Filha" for a chosen id, so the trigger carries its context. */
|
|
29
|
+
function singleLabel(groups: CategoryGroup[], value: string | null): string | undefined {
|
|
30
|
+
if (!value) return undefined;
|
|
31
|
+
const asCategory = groups.find((group) => group.category.id === value);
|
|
32
|
+
if (asCategory) return asCategory.category.name;
|
|
33
|
+
const owner = groups.find((group) => group.subcategories.some((sub) => sub.id === value));
|
|
34
|
+
const sub = owner?.subcategories.find((candidate) => candidate.id === value);
|
|
35
|
+
return sub && owner ? `${owner.category.name} › ${sub.name}` : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The optional props, resolved once so the component body stays branch-free. */
|
|
39
|
+
interface ResolvedConfig {
|
|
40
|
+
single: boolean;
|
|
41
|
+
placeholder: string;
|
|
42
|
+
disabled: boolean;
|
|
43
|
+
loading: boolean;
|
|
44
|
+
fullWidth: boolean;
|
|
45
|
+
showCounts: boolean;
|
|
46
|
+
allowParentSelection: boolean;
|
|
47
|
+
dataTestId: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveConfig(props: CategorySelectProps): ResolvedConfig {
|
|
51
|
+
const single = props.mode === 'single';
|
|
52
|
+
return {
|
|
53
|
+
single,
|
|
54
|
+
placeholder: props.placeholder ?? (single ? 'Mover para…' : 'Categoria'),
|
|
55
|
+
disabled: props.disabled ?? false,
|
|
56
|
+
loading: props.loading ?? false,
|
|
57
|
+
fullWidth: props.fullWidth ?? false,
|
|
58
|
+
showCounts: props.showCounts ?? false,
|
|
59
|
+
allowParentSelection: props.allowParentSelection ?? false,
|
|
60
|
+
dataTestId: props.dataTestId ?? 'category-select',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Focus the search box on open, and keep the keyboard cursor in view. */
|
|
65
|
+
function usePanelFocus(
|
|
66
|
+
open: boolean,
|
|
67
|
+
activeIndex: number,
|
|
68
|
+
searchInputRef: React.RefObject<HTMLInputElement | null>,
|
|
69
|
+
listRef: React.RefObject<HTMLDivElement | null>,
|
|
70
|
+
): void {
|
|
71
|
+
// The first thing an admin does with ten categories is type; focusing here
|
|
72
|
+
// saves the extra click every single time the panel opens.
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!open) return undefined;
|
|
75
|
+
const frame = requestAnimationFrame(() => searchInputRef.current?.focus());
|
|
76
|
+
return () => cancelAnimationFrame(frame);
|
|
77
|
+
}, [open, searchInputRef]);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (activeIndex < 0) return;
|
|
81
|
+
const rows = listRef.current?.querySelectorAll('[role="option"], [role="button"]');
|
|
82
|
+
rows?.[activeIndex]?.scrollIntoView({ block: 'nearest' });
|
|
83
|
+
}, [activeIndex, listRef]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A hierarchical category picker: categories as the frame, subcategories as the
|
|
88
|
+
* thing you choose.
|
|
89
|
+
*
|
|
90
|
+
* Two modes off one component. `multi` is the FILTER — ticks accumulate in a
|
|
91
|
+
* draft and reach `onChange` only on Apply, so the list behind the panel does not
|
|
92
|
+
* reload on every click. `single` is the "move to…" picker, where choosing a row
|
|
93
|
+
* commits immediately because there is nothing to batch.
|
|
94
|
+
*
|
|
95
|
+
* The tree opens fully expanded: nothing a search could match is hidden behind a
|
|
96
|
+
* disclosure. Selected categories pin above the list so they survive scrolling
|
|
97
|
+
* and searching, search is accent-insensitive (`agua` finds `Águas`), and the
|
|
98
|
+
* whole tree is keyboard-drivable — ↑↓ to move, → to open, ← to close, Space to
|
|
99
|
+
* mark, Enter to apply, Esc to cancel.
|
|
100
|
+
*
|
|
101
|
+
* Under {@link METRICS.sheetBreakpoint} the panel becomes a bottom sheet, which
|
|
102
|
+
* is the only way a 290px-tall list stays usable one-handed.
|
|
103
|
+
*/
|
|
104
|
+
export function CategorySelect(props: CategorySelectProps): React.JSX.Element {
|
|
105
|
+
const { options, label, error, onCreateCategory } = props;
|
|
106
|
+
const {
|
|
107
|
+
single,
|
|
108
|
+
placeholder,
|
|
109
|
+
disabled,
|
|
110
|
+
loading,
|
|
111
|
+
fullWidth,
|
|
112
|
+
showCounts,
|
|
113
|
+
allowParentSelection,
|
|
114
|
+
dataTestId,
|
|
115
|
+
} = resolveConfig(props);
|
|
116
|
+
|
|
117
|
+
const theme = useTheme();
|
|
118
|
+
const sheet = useMediaQuery(theme.breakpoints.down(METRICS.sheetBreakpoint));
|
|
119
|
+
const applied = appliedIds(props);
|
|
120
|
+
|
|
121
|
+
const state = useCategorySelect({ options, selected: applied });
|
|
122
|
+
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
|
123
|
+
const listRef = useRef<HTMLDivElement | null>(null);
|
|
124
|
+
usePanelFocus(state.open, state.activeIndex, searchInputRef, listRef);
|
|
125
|
+
|
|
126
|
+
const chips = useMemo(
|
|
127
|
+
() => summarizeSelection(state.allGroups, state.draft),
|
|
128
|
+
[state.allGroups, state.draft],
|
|
129
|
+
);
|
|
130
|
+
const rowIds = useMemo(() => state.rows.map((row) => row.id), [state.rows]);
|
|
131
|
+
|
|
132
|
+
const actions = useCategoryCommit({ props, state, single, allowParentSelection });
|
|
133
|
+
const onKeyDown = useCategoryKeyHandler({ state, single, allowParentSelection, actions });
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<FormControl fullWidth={fullWidth} error={Boolean(error)}>
|
|
137
|
+
{label && <FormLabel error={Boolean(error)}>{label}</FormLabel>}
|
|
138
|
+
<CategoryTrigger
|
|
139
|
+
placeholder={placeholder}
|
|
140
|
+
selectionLabel={
|
|
141
|
+
props.mode === 'single' ? singleLabel(state.allGroups, props.value) : undefined
|
|
142
|
+
}
|
|
143
|
+
count={single ? undefined : applied.length}
|
|
144
|
+
open={state.open}
|
|
145
|
+
disabled={disabled}
|
|
146
|
+
fullWidth={fullWidth}
|
|
147
|
+
onOpen={state.openPanel}
|
|
148
|
+
onClear={applied.length > 0 ? actions.clearSelection : undefined}
|
|
149
|
+
triggerRef={state.triggerRef}
|
|
150
|
+
dataTestId={dataTestId}
|
|
151
|
+
/>
|
|
152
|
+
<CategoryPanelSurface
|
|
153
|
+
open={state.open}
|
|
154
|
+
sheet={sheet}
|
|
155
|
+
anchorEl={state.triggerRef.current}
|
|
156
|
+
onClose={actions.cancel}
|
|
157
|
+
>
|
|
158
|
+
<CategoryPanel
|
|
159
|
+
state={state}
|
|
160
|
+
chips={chips}
|
|
161
|
+
rowIds={rowIds}
|
|
162
|
+
changed={draftChanged(state.draft, applied)}
|
|
163
|
+
single={single}
|
|
164
|
+
sheet={sheet}
|
|
165
|
+
loading={loading}
|
|
166
|
+
showCounts={showCounts}
|
|
167
|
+
allowParentSelection={allowParentSelection}
|
|
168
|
+
ariaLabel={label ?? placeholder}
|
|
169
|
+
onKeyDown={onKeyDown}
|
|
170
|
+
onActivateCategory={actions.activateCategory}
|
|
171
|
+
onActivateSubcategory={actions.activateSubcategory}
|
|
172
|
+
onApply={actions.commit}
|
|
173
|
+
onCancel={actions.cancel}
|
|
174
|
+
onCreateCategory={onCreateCategory}
|
|
175
|
+
searchInputRef={searchInputRef}
|
|
176
|
+
listRef={listRef}
|
|
177
|
+
dataTestId={dataTestId}
|
|
178
|
+
/>
|
|
179
|
+
</CategoryPanelSurface>
|
|
180
|
+
{error && <FormMessage error dataTestId={`${dataTestId}-message`}>{error}</FormMessage>}
|
|
181
|
+
</FormControl>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { SizeValue } from '../../../tokens/scales';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One category as the caller already stores it: a FLAT row carrying its parent.
|
|
5
|
+
*
|
|
6
|
+
* Flat-with-`parentId` rather than a nested `children` array because that is the
|
|
7
|
+
* shape the API and the consuming admin lists already hold; nesting it at every
|
|
8
|
+
* call site would be work the component can do once, memoised, itself.
|
|
9
|
+
*/
|
|
10
|
+
export interface CategorySelectOption {
|
|
11
|
+
/** Stable identifier — what {@link CategorySelectProps.value} refers to. */
|
|
12
|
+
id: string;
|
|
13
|
+
/** The category's own name. Never pre-indented: the ROW draws the nesting. */
|
|
14
|
+
name: string;
|
|
15
|
+
/** Parent category id, or `null`/absent for a top-level category. */
|
|
16
|
+
parentId?: string | null;
|
|
17
|
+
/**
|
|
18
|
+
* Item count for this category, shown as a muted trailing number when
|
|
19
|
+
* {@link CategorySelectBaseProps.showCounts} is on.
|
|
20
|
+
*/
|
|
21
|
+
count?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A top-level category together with the subcategories nested under it. */
|
|
25
|
+
export interface CategoryGroup {
|
|
26
|
+
category: CategorySelectOption;
|
|
27
|
+
subcategories: CategorySelectOption[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One entry in the "selected" summary: a whole category, or a single leaf. */
|
|
31
|
+
export interface CategorySelectionChip {
|
|
32
|
+
/** The id to remove when the chip's × is pressed. */
|
|
33
|
+
id: string;
|
|
34
|
+
/** Display label — the category name, or the subcategory name. */
|
|
35
|
+
label: string;
|
|
36
|
+
/** Set when the chip stands for EVERY subcategory of a category. */
|
|
37
|
+
whole: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Props shared by both selection modes. */
|
|
41
|
+
export interface CategorySelectBaseProps {
|
|
42
|
+
/** The categories to choose from, flat, in display order. */
|
|
43
|
+
options: CategorySelectOption[];
|
|
44
|
+
/** Field label rendered above the trigger. */
|
|
45
|
+
label?: string;
|
|
46
|
+
/** Text on the trigger while nothing is selected. */
|
|
47
|
+
placeholder?: string;
|
|
48
|
+
/** Validation message; also flags the trigger as errored. */
|
|
49
|
+
error?: string;
|
|
50
|
+
/** Disables the trigger. */
|
|
51
|
+
disabled?: boolean;
|
|
52
|
+
/** Draws skeleton rows in place of the list (catalogue still loading). */
|
|
53
|
+
loading?: boolean;
|
|
54
|
+
/** Stretch to the container width (default: `false` — the trigger hugs). */
|
|
55
|
+
fullWidth?: boolean;
|
|
56
|
+
/** Control height, matching {@link Input}'s scale (default: `md`). */
|
|
57
|
+
size?: SizeValue;
|
|
58
|
+
/** Show each category's `count` as a trailing muted number (default: `false`). */
|
|
59
|
+
showCounts?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Lets a top-level category be chosen in its own right. Off by default: the
|
|
62
|
+
* prototype's leaf-only default makes the category a HEADING and the
|
|
63
|
+
* subcategory the thing you pick, which is what removes the "did I select the
|
|
64
|
+
* parent or the group?" ambiguity.
|
|
65
|
+
*/
|
|
66
|
+
allowParentSelection?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Category creation, offered two ways when provided: as the empty-catalogue
|
|
69
|
+
* call to action, and as a `Criar "<termo>"` row when a search matches
|
|
70
|
+
* nothing — called with the typed text in that case.
|
|
71
|
+
*
|
|
72
|
+
* The second is what lets this replace a creatable combobox without losing
|
|
73
|
+
* create-as-you-type: an admin who searches for a category that does not
|
|
74
|
+
* exist yet is exactly the admin who wants to add it.
|
|
75
|
+
*/
|
|
76
|
+
onCreateCategory?: (name?: string) => void;
|
|
77
|
+
/** Base test id; the trigger is `<dataTestId>-trigger`, the panel `-panel`. */
|
|
78
|
+
dataTestId?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Multi-select mode: the filter. Edits accumulate in a DRAFT and only reach
|
|
83
|
+
* `onChange` when Apply is pressed, so the filtered list behind the panel does
|
|
84
|
+
* not reload on every tick.
|
|
85
|
+
*/
|
|
86
|
+
export interface CategoryMultiSelectProps extends CategorySelectBaseProps {
|
|
87
|
+
mode?: 'multi';
|
|
88
|
+
/** The APPLIED selection — subcategory ids. */
|
|
89
|
+
value: string[];
|
|
90
|
+
/** Fired with the new applied selection when Apply is pressed. */
|
|
91
|
+
onChange: (next: string[]) => void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Single-select mode: the "move to…" picker. Choosing a row commits at once. */
|
|
95
|
+
export interface CategorySingleSelectProps extends CategorySelectBaseProps {
|
|
96
|
+
mode: 'single';
|
|
97
|
+
/** The chosen category or subcategory id, or `null`. */
|
|
98
|
+
value: string | null;
|
|
99
|
+
/** Fired with the chosen id, or `null` when cleared. */
|
|
100
|
+
onChange: (next: string | null) => void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type CategorySelectProps = CategoryMultiSelectProps | CategorySingleSelectProps;
|