@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,155 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Box } from '@mui/material';
|
|
4
|
+
|
|
5
|
+
import { removeChip } from './category-tree';
|
|
6
|
+
import { CategoryMultiFoot, CategorySingleFoot } from './CategoryPanelFoot';
|
|
7
|
+
import { CategoryPanelHead, CategoryPinnedTray } from './CategoryPanelHead';
|
|
8
|
+
import { CategoryPanelList } from './CategoryPanelList';
|
|
9
|
+
import { CategoryQuickActions } from './CategoryQuickActions';
|
|
10
|
+
import { panelSx, sheetSx } from './CategorySelect.styles';
|
|
11
|
+
import type { CategoryGroup, CategorySelectionChip } from './CategorySelect.types';
|
|
12
|
+
import type { CategorySelectState } from './useCategorySelect';
|
|
13
|
+
|
|
14
|
+
export interface CategoryPanelProps {
|
|
15
|
+
state: CategorySelectState;
|
|
16
|
+
chips: CategorySelectionChip[];
|
|
17
|
+
rowIds: string[];
|
|
18
|
+
/** True when the applied selection differs from the draft. */
|
|
19
|
+
changed: boolean;
|
|
20
|
+
single: boolean;
|
|
21
|
+
sheet: boolean;
|
|
22
|
+
loading: boolean;
|
|
23
|
+
showCounts: boolean;
|
|
24
|
+
allowParentSelection: boolean;
|
|
25
|
+
ariaLabel: string;
|
|
26
|
+
onKeyDown: (event: React.KeyboardEvent) => void;
|
|
27
|
+
onActivateCategory: (group: CategoryGroup) => void;
|
|
28
|
+
onActivateSubcategory: (id: string) => void;
|
|
29
|
+
onApply: () => void;
|
|
30
|
+
onCancel: () => void;
|
|
31
|
+
onCreateCategory?: () => void;
|
|
32
|
+
searchInputRef: React.RefObject<HTMLInputElement | null>;
|
|
33
|
+
listRef: React.RefObject<HTMLDivElement | null>;
|
|
34
|
+
dataTestId: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Search + pinned selection + tree + footer, in the panel's own chrome. */
|
|
38
|
+
export function CategoryPanel({
|
|
39
|
+
state,
|
|
40
|
+
chips,
|
|
41
|
+
rowIds,
|
|
42
|
+
changed,
|
|
43
|
+
single,
|
|
44
|
+
sheet,
|
|
45
|
+
loading,
|
|
46
|
+
showCounts,
|
|
47
|
+
allowParentSelection,
|
|
48
|
+
ariaLabel,
|
|
49
|
+
onKeyDown,
|
|
50
|
+
onActivateCategory,
|
|
51
|
+
onActivateSubcategory,
|
|
52
|
+
onApply,
|
|
53
|
+
onCancel,
|
|
54
|
+
onCreateCategory,
|
|
55
|
+
searchInputRef,
|
|
56
|
+
listRef,
|
|
57
|
+
dataTestId,
|
|
58
|
+
}: CategoryPanelProps): React.JSX.Element {
|
|
59
|
+
return (
|
|
60
|
+
<Box
|
|
61
|
+
onKeyDown={onKeyDown}
|
|
62
|
+
role="dialog"
|
|
63
|
+
aria-label={ariaLabel}
|
|
64
|
+
data-testid={`${dataTestId}-panel`}
|
|
65
|
+
sx={(theme) => ({ ...panelSx(theme), ...(sheet ? sheetSx(theme) : {}) })}
|
|
66
|
+
>
|
|
67
|
+
<CategoryPanelHead
|
|
68
|
+
query={state.query}
|
|
69
|
+
placeholder={single ? 'Buscar categoria…' : 'Buscar categoria ou subcategoria'}
|
|
70
|
+
sheet={sheet}
|
|
71
|
+
quickActions={
|
|
72
|
+
single ? undefined : <CategoryQuickActions state={state} dataTestId={dataTestId} />
|
|
73
|
+
}
|
|
74
|
+
onQueryChange={state.setQuery}
|
|
75
|
+
searchInputRef={searchInputRef}
|
|
76
|
+
dataTestId={dataTestId}
|
|
77
|
+
/>
|
|
78
|
+
{!single && !loading && (
|
|
79
|
+
<PinnedTray state={state} chips={chips} dataTestId={dataTestId} />
|
|
80
|
+
)}
|
|
81
|
+
<CategoryPanelList
|
|
82
|
+
groups={state.visibleGroups}
|
|
83
|
+
query={state.query}
|
|
84
|
+
draft={state.draft}
|
|
85
|
+
rowIds={rowIds}
|
|
86
|
+
activeIndex={state.activeIndex}
|
|
87
|
+
sheet={sheet}
|
|
88
|
+
single={single}
|
|
89
|
+
loading={loading}
|
|
90
|
+
showCounts={showCounts}
|
|
91
|
+
allowParentSelection={allowParentSelection}
|
|
92
|
+
isExpanded={state.isExpanded}
|
|
93
|
+
onToggleExpanded={state.toggleExpanded}
|
|
94
|
+
onActivateCategory={onActivateCategory}
|
|
95
|
+
onActivateSubcategory={onActivateSubcategory}
|
|
96
|
+
onClearQuery={() => state.setQuery('')}
|
|
97
|
+
onCreateCategory={onCreateCategory}
|
|
98
|
+
listRef={listRef}
|
|
99
|
+
dataTestId={dataTestId}
|
|
100
|
+
/>
|
|
101
|
+
<PanelFooter
|
|
102
|
+
state={state}
|
|
103
|
+
changed={changed}
|
|
104
|
+
single={single}
|
|
105
|
+
sheet={sheet}
|
|
106
|
+
onApply={onApply}
|
|
107
|
+
onCancel={onCancel}
|
|
108
|
+
dataTestId={dataTestId}
|
|
109
|
+
/>
|
|
110
|
+
</Box>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The selected-category chips, wired to remove from the draft. */
|
|
115
|
+
function PinnedTray({
|
|
116
|
+
state,
|
|
117
|
+
chips,
|
|
118
|
+
dataTestId,
|
|
119
|
+
}: Pick<CategoryPanelProps, 'state' | 'chips' | 'dataTestId'>): React.JSX.Element {
|
|
120
|
+
return (
|
|
121
|
+
<CategoryPinnedTray
|
|
122
|
+
chips={chips}
|
|
123
|
+
onRemove={(chipId) => state.setDraft(removeChip(state.allGroups, chipId, state.draft))}
|
|
124
|
+
dataTestId={dataTestId}
|
|
125
|
+
/>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Whichever footer the mode calls for. */
|
|
130
|
+
function PanelFooter({
|
|
131
|
+
state,
|
|
132
|
+
changed,
|
|
133
|
+
single,
|
|
134
|
+
sheet,
|
|
135
|
+
onApply,
|
|
136
|
+
onCancel,
|
|
137
|
+
dataTestId,
|
|
138
|
+
}: Pick<
|
|
139
|
+
CategoryPanelProps,
|
|
140
|
+
'state' | 'changed' | 'single' | 'sheet' | 'onApply' | 'onCancel' | 'dataTestId'
|
|
141
|
+
>): React.JSX.Element {
|
|
142
|
+
if (single) {
|
|
143
|
+
return <CategorySingleFoot sheet={sheet} onCancel={onCancel} dataTestId={dataTestId} />;
|
|
144
|
+
}
|
|
145
|
+
return (
|
|
146
|
+
<CategoryMultiFoot
|
|
147
|
+
count={state.draft.size}
|
|
148
|
+
changed={changed}
|
|
149
|
+
sheet={sheet}
|
|
150
|
+
onClear={() => state.setDraft(new Set())}
|
|
151
|
+
onApply={onApply}
|
|
152
|
+
dataTestId={dataTestId}
|
|
153
|
+
/>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Box, Button } from '@mui/material';
|
|
4
|
+
|
|
5
|
+
import { footerSx, METRICS } from './CategorySelect.styles';
|
|
6
|
+
|
|
7
|
+
const buttonSx = (sheet: boolean) =>
|
|
8
|
+
({
|
|
9
|
+
height: sheet ? METRICS.sheetFooterButton : METRICS.footerButton,
|
|
10
|
+
padding: '0 13px',
|
|
11
|
+
borderRadius: '8px',
|
|
12
|
+
fontSize: 12.5,
|
|
13
|
+
fontWeight: 600,
|
|
14
|
+
textTransform: 'none',
|
|
15
|
+
minWidth: 0,
|
|
16
|
+
}) as const;
|
|
17
|
+
|
|
18
|
+
interface MultiFootProps {
|
|
19
|
+
count: number;
|
|
20
|
+
/** False once the draft matches what is already applied — Apply becomes Close. */
|
|
21
|
+
changed: boolean;
|
|
22
|
+
sheet: boolean;
|
|
23
|
+
onClear: () => void;
|
|
24
|
+
onApply: () => void;
|
|
25
|
+
dataTestId: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Multi-select footer: the running count, Clear, and the Apply that publishes
|
|
30
|
+
* the draft. The primary button reads "Fechar" when nothing changed, so it never
|
|
31
|
+
* promises an update it would not make.
|
|
32
|
+
*/
|
|
33
|
+
export function CategoryMultiFoot({
|
|
34
|
+
count,
|
|
35
|
+
changed,
|
|
36
|
+
sheet,
|
|
37
|
+
onClear,
|
|
38
|
+
onApply,
|
|
39
|
+
dataTestId,
|
|
40
|
+
}: MultiFootProps): React.JSX.Element {
|
|
41
|
+
return (
|
|
42
|
+
<Box sx={footerSx} data-testid={`${dataTestId}-footer`}>
|
|
43
|
+
<Box component="span" sx={{ fontSize: 12, color: 'text.secondary' }}>
|
|
44
|
+
<strong>{count}</strong> selecionada{count === 1 ? '' : 's'}
|
|
45
|
+
</Box>
|
|
46
|
+
<Box sx={{ display: 'flex', gap: '6px' }}>
|
|
47
|
+
<Button
|
|
48
|
+
variant="outlined"
|
|
49
|
+
disabled={count === 0}
|
|
50
|
+
sx={buttonSx(sheet)}
|
|
51
|
+
onClick={onClear}
|
|
52
|
+
data-testid={`${dataTestId}-clear`}
|
|
53
|
+
>
|
|
54
|
+
Limpar
|
|
55
|
+
</Button>
|
|
56
|
+
<Button
|
|
57
|
+
variant="contained"
|
|
58
|
+
sx={buttonSx(sheet)}
|
|
59
|
+
onClick={onApply}
|
|
60
|
+
data-testid={`${dataTestId}-apply`}
|
|
61
|
+
>
|
|
62
|
+
{changed ? 'Aplicar' : 'Fechar'}
|
|
63
|
+
</Button>
|
|
64
|
+
</Box>
|
|
65
|
+
</Box>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Single-select footer: a hint and a way out. Picking a row commits directly. */
|
|
70
|
+
export function CategorySingleFoot({
|
|
71
|
+
sheet,
|
|
72
|
+
onCancel,
|
|
73
|
+
dataTestId,
|
|
74
|
+
}: {
|
|
75
|
+
sheet: boolean;
|
|
76
|
+
onCancel: () => void;
|
|
77
|
+
dataTestId: string;
|
|
78
|
+
}): React.JSX.Element {
|
|
79
|
+
return (
|
|
80
|
+
<Box sx={footerSx} data-testid={`${dataTestId}-footer`}>
|
|
81
|
+
<Box component="span" sx={{ fontSize: 12, color: 'text.secondary' }}>
|
|
82
|
+
Escolha uma categoria
|
|
83
|
+
</Box>
|
|
84
|
+
<Button
|
|
85
|
+
variant="outlined"
|
|
86
|
+
sx={buttonSx(sheet)}
|
|
87
|
+
onClick={onCancel}
|
|
88
|
+
data-testid={`${dataTestId}-cancel`}
|
|
89
|
+
>
|
|
90
|
+
Cancelar
|
|
91
|
+
</Button>
|
|
92
|
+
</Box>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Box, InputBase } from '@mui/material';
|
|
4
|
+
|
|
5
|
+
import { SearchGlyph } from './CategoryIcons';
|
|
6
|
+
import {
|
|
7
|
+
chipSx,
|
|
8
|
+
chipWrapSx,
|
|
9
|
+
panelHeadSx,
|
|
10
|
+
pinnedLabelSx,
|
|
11
|
+
pinnedSx,
|
|
12
|
+
} from './CategorySelect.styles';
|
|
13
|
+
import type { CategorySelectionChip } from './CategorySelect.types';
|
|
14
|
+
|
|
15
|
+
interface PanelHeadProps {
|
|
16
|
+
query: string;
|
|
17
|
+
placeholder: string;
|
|
18
|
+
sheet: boolean;
|
|
19
|
+
/** Quick actions belong to multi-select only; single-select has nothing to bulk-do. */
|
|
20
|
+
quickActions?: React.ReactNode;
|
|
21
|
+
onQueryChange: (next: string) => void;
|
|
22
|
+
searchInputRef: React.RefObject<HTMLInputElement | null>;
|
|
23
|
+
dataTestId: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const searchFieldSx = {
|
|
27
|
+
position: 'relative',
|
|
28
|
+
display: 'flex',
|
|
29
|
+
alignItems: 'center',
|
|
30
|
+
'& input': {
|
|
31
|
+
width: '100%',
|
|
32
|
+
height: 36,
|
|
33
|
+
padding: '0 30px 0 32px',
|
|
34
|
+
fontSize: 13,
|
|
35
|
+
border: '1px solid',
|
|
36
|
+
borderColor: 'divider',
|
|
37
|
+
borderRadius: '9px',
|
|
38
|
+
background: 'background.paper',
|
|
39
|
+
color: 'text.primary',
|
|
40
|
+
'&:focus': {
|
|
41
|
+
outline: 'none',
|
|
42
|
+
borderColor: 'primary.main',
|
|
43
|
+
boxShadow: (theme: { palette: { primary: { main: string } } }) =>
|
|
44
|
+
`0 0 0 3px ${theme.palette.primary.main}26`,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
const clearButtonSx = {
|
|
50
|
+
position: 'absolute',
|
|
51
|
+
right: 7,
|
|
52
|
+
width: 20,
|
|
53
|
+
height: 20,
|
|
54
|
+
border: 0,
|
|
55
|
+
background: 'action.selected',
|
|
56
|
+
color: 'text.secondary',
|
|
57
|
+
borderRadius: '50%',
|
|
58
|
+
display: 'grid',
|
|
59
|
+
placeItems: 'center',
|
|
60
|
+
cursor: 'pointer',
|
|
61
|
+
fontSize: 12,
|
|
62
|
+
padding: 0,
|
|
63
|
+
lineHeight: 1,
|
|
64
|
+
} as const;
|
|
65
|
+
|
|
66
|
+
/** Search field, its clear affordance, and the optional quick-action row. */
|
|
67
|
+
export function CategoryPanelHead({
|
|
68
|
+
query,
|
|
69
|
+
placeholder,
|
|
70
|
+
sheet,
|
|
71
|
+
quickActions,
|
|
72
|
+
onQueryChange,
|
|
73
|
+
searchInputRef,
|
|
74
|
+
dataTestId,
|
|
75
|
+
}: PanelHeadProps): React.JSX.Element {
|
|
76
|
+
return (
|
|
77
|
+
<Box sx={(theme) => panelHeadSx(theme, sheet)}>
|
|
78
|
+
<Box sx={searchFieldSx}>
|
|
79
|
+
<SearchGlyph
|
|
80
|
+
style={{
|
|
81
|
+
position: 'absolute',
|
|
82
|
+
left: 10,
|
|
83
|
+
width: 15,
|
|
84
|
+
height: 15,
|
|
85
|
+
opacity: 0.55,
|
|
86
|
+
pointerEvents: 'none',
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
<InputBase
|
|
90
|
+
inputRef={searchInputRef}
|
|
91
|
+
value={query}
|
|
92
|
+
placeholder={placeholder}
|
|
93
|
+
autoComplete="off"
|
|
94
|
+
inputProps={{ 'aria-label': placeholder, 'data-testid': `${dataTestId}-search` }}
|
|
95
|
+
onChange={(event) => onQueryChange(event.target.value)}
|
|
96
|
+
sx={{ width: '100%' }}
|
|
97
|
+
/>
|
|
98
|
+
{query.length > 0 && (
|
|
99
|
+
<Box
|
|
100
|
+
component="button"
|
|
101
|
+
type="button"
|
|
102
|
+
aria-label="Limpar busca"
|
|
103
|
+
data-testid={`${dataTestId}-search-clear`}
|
|
104
|
+
sx={clearButtonSx}
|
|
105
|
+
onClick={() => onQueryChange('')}
|
|
106
|
+
>
|
|
107
|
+
×
|
|
108
|
+
</Box>
|
|
109
|
+
)}
|
|
110
|
+
</Box>
|
|
111
|
+
{quickActions && (
|
|
112
|
+
<Box
|
|
113
|
+
sx={{
|
|
114
|
+
display: 'flex',
|
|
115
|
+
alignItems: 'center',
|
|
116
|
+
gap: '10px',
|
|
117
|
+
marginTop: '8px',
|
|
118
|
+
fontSize: 12,
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
{quickActions}
|
|
122
|
+
</Box>
|
|
123
|
+
)}
|
|
124
|
+
</Box>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface PinnedTrayProps {
|
|
129
|
+
chips: CategorySelectionChip[];
|
|
130
|
+
onRemove: (chipId: string) => void;
|
|
131
|
+
dataTestId: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The selected categories, pinned above the list.
|
|
136
|
+
*
|
|
137
|
+
* Pinned rather than left in place because a selection made at the bottom of a
|
|
138
|
+
* ten-category tree scrolls out of sight the moment you look for the next one —
|
|
139
|
+
* and a search hides it entirely. Here it survives both.
|
|
140
|
+
*/
|
|
141
|
+
export function CategoryPinnedTray({
|
|
142
|
+
chips,
|
|
143
|
+
onRemove,
|
|
144
|
+
dataTestId,
|
|
145
|
+
}: PinnedTrayProps): React.JSX.Element | null {
|
|
146
|
+
if (chips.length === 0) return null;
|
|
147
|
+
return (
|
|
148
|
+
<Box sx={pinnedSx} data-testid={`${dataTestId}-pinned`}>
|
|
149
|
+
<Box sx={pinnedLabelSx}>Selecionadas · {chips.length}</Box>
|
|
150
|
+
<Box sx={chipWrapSx}>
|
|
151
|
+
{chips.map((chip) => (
|
|
152
|
+
<Box component="span" key={chip.id} sx={chipSx}>
|
|
153
|
+
{chip.label}
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
aria-label={`Remover ${chip.label}`}
|
|
157
|
+
data-testid={`${dataTestId}-unpin-${chip.id}`}
|
|
158
|
+
onClick={() => onRemove(chip.id)}
|
|
159
|
+
>
|
|
160
|
+
×
|
|
161
|
+
</button>
|
|
162
|
+
</Box>
|
|
163
|
+
))}
|
|
164
|
+
</Box>
|
|
165
|
+
</Box>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -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
|
+
}
|