@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.
Files changed (30) hide show
  1. package/package.json +2 -1
  2. package/src/components/data-display/Alert/Alert.styles.tsx +146 -39
  3. package/src/components/data-display/Alert/Alert.tsx +3 -14
  4. package/src/components/data-display/DataViews/data-views-category-pill.tsx +98 -0
  5. package/src/components/data-display/DataViews/data-views-filter-panel.tsx +11 -14
  6. package/src/components/data-display/DataViews/data-views-inline-bar.tsx +8 -13
  7. package/src/components/data-display/DataViews/data-views-types.ts +10 -2
  8. package/src/components/form/Button/Button.styles.ts +25 -6
  9. package/src/components/form/CategorySelect/CategoryIcons.tsx +52 -0
  10. package/src/components/form/CategorySelect/CategoryPanel.tsx +155 -0
  11. package/src/components/form/CategorySelect/CategoryPanelFoot.tsx +94 -0
  12. package/src/components/form/CategorySelect/CategoryPanelHead.tsx +167 -0
  13. package/src/components/form/CategorySelect/CategoryPanelList.tsx +207 -0
  14. package/src/components/form/CategorySelect/CategoryPanelSurface.tsx +56 -0
  15. package/src/components/form/CategorySelect/CategoryQuickActions.tsx +69 -0
  16. package/src/components/form/CategorySelect/CategoryRows.tsx +211 -0
  17. package/src/components/form/CategorySelect/CategorySelect.md +134 -0
  18. package/src/components/form/CategorySelect/CategorySelect.styles.ts +354 -0
  19. package/src/components/form/CategorySelect/CategorySelect.tsx +183 -0
  20. package/src/components/form/CategorySelect/CategorySelect.types.ts +103 -0
  21. package/src/components/form/CategorySelect/CategoryTrigger.tsx +109 -0
  22. package/src/components/form/CategorySelect/category-keyboard.ts +136 -0
  23. package/src/components/form/CategorySelect/category-tree.ts +210 -0
  24. package/src/components/form/CategorySelect/index.ts +19 -0
  25. package/src/components/form/CategorySelect/useCategoryCommit.ts +101 -0
  26. package/src/components/form/CategorySelect/useCategoryKeyHandler.ts +66 -0
  27. package/src/components/form/CategorySelect/useCategorySelect.ts +172 -0
  28. package/src/components/form/total-form/Fields/CategoryField.tsx +57 -0
  29. package/src/components/form/total-form/Fields/index.ts +2 -0
  30. 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
+ }