@12-apps/ui 4.0.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/package.json +2 -1
  2. package/src/components/data-display/DataViews/data-views-category-pill.tsx +98 -0
  3. package/src/components/data-display/DataViews/data-views-filter-panel.tsx +11 -14
  4. package/src/components/data-display/DataViews/data-views-inline-bar.tsx +8 -13
  5. package/src/components/data-display/DataViews/data-views-types.ts +10 -2
  6. package/src/components/form/CategorySelect/CategoryIcons.tsx +52 -0
  7. package/src/components/form/CategorySelect/CategoryPanel.tsx +155 -0
  8. package/src/components/form/CategorySelect/CategoryPanelFoot.tsx +94 -0
  9. package/src/components/form/CategorySelect/CategoryPanelHead.tsx +167 -0
  10. package/src/components/form/CategorySelect/CategoryPanelList.tsx +207 -0
  11. package/src/components/form/CategorySelect/CategoryPanelSurface.tsx +56 -0
  12. package/src/components/form/CategorySelect/CategoryQuickActions.tsx +69 -0
  13. package/src/components/form/CategorySelect/CategoryRows.tsx +211 -0
  14. package/src/components/form/CategorySelect/CategorySelect.md +134 -0
  15. package/src/components/form/CategorySelect/CategorySelect.styles.ts +354 -0
  16. package/src/components/form/CategorySelect/CategorySelect.tsx +183 -0
  17. package/src/components/form/CategorySelect/CategorySelect.types.ts +103 -0
  18. package/src/components/form/CategorySelect/CategoryTrigger.tsx +109 -0
  19. package/src/components/form/CategorySelect/category-keyboard.ts +136 -0
  20. package/src/components/form/CategorySelect/category-tree.ts +210 -0
  21. package/src/components/form/CategorySelect/index.ts +19 -0
  22. package/src/components/form/CategorySelect/useCategoryCommit.ts +101 -0
  23. package/src/components/form/CategorySelect/useCategoryKeyHandler.ts +66 -0
  24. package/src/components/form/CategorySelect/useCategorySelect.ts +172 -0
  25. package/src/components/form/total-form/Fields/CategoryField.tsx +57 -0
  26. package/src/components/form/total-form/Fields/index.ts +2 -0
  27. package/src/components/form/total-form/index.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/ui",
3
- "version": "4.0.2",
3
+ "version": "5.0.0",
4
4
  "license": "MIT",
5
5
  "packageManager": "pnpm@9.0.0",
6
6
  "type": "module",
@@ -78,6 +78,7 @@
78
78
  "./form/Autocomplete": "./src/components/form/Autocomplete/index.ts",
79
79
  "./form/Button": "./src/components/form/Button/index.ts",
80
80
  "./form/Calendar": "./src/components/form/Calendar/index.ts",
81
+ "./form/CategorySelect": "./src/components/form/CategorySelect/index.ts",
81
82
  "./form/CepField": "./src/components/form/CepField/index.ts",
82
83
  "./form/Checkbox": "./src/components/form/Checkbox/index.ts",
83
84
  "./form/CodeEditor": "./src/components/form/CodeEditor/index.ts",
@@ -0,0 +1,98 @@
1
+ "use client";
2
+
3
+ import { CategorySelect } from "../../form/CategorySelect";
4
+ import { MultiSelectDropdown } from "../../layout/ContentToolbar";
5
+
6
+ import type { FilterFieldConfig, FilterOption } from "./data-views-types";
7
+
8
+ /**
9
+ * Diff a whole-array selection back into per-value toggles.
10
+ *
11
+ * The filter state's only channel is `onTogglePill(field, value, checked)`, but
12
+ * a tree hands back the entire selection at once. Diffing here keeps that one
13
+ * channel the single way a pill ever changes, rather than giving the
14
+ * hierarchical control a private path into the state.
15
+ */
16
+ export function emitPillDiff(
17
+ fieldId: string,
18
+ before: readonly string[],
19
+ after: readonly string[],
20
+ onTogglePill: (fieldId: string, value: string, checked: boolean) => void,
21
+ ): void {
22
+ const had = new Set(before);
23
+ const wants = new Set(after);
24
+ after.forEach((value) => {
25
+ if (!had.has(value)) onTogglePill(fieldId, value, true);
26
+ });
27
+ before.forEach((value) => {
28
+ if (!wants.has(value)) onTogglePill(fieldId, value, false);
29
+ });
30
+ }
31
+
32
+ /** A filter option as the category tree wants it. */
33
+ const toCategoryOption = (option: FilterOption) => ({
34
+ id: option.value,
35
+ name: option.label,
36
+ parentId: option.parentId ?? null,
37
+ });
38
+
39
+ interface PillControlProps<T extends Record<string, unknown>> {
40
+ fieldId: string;
41
+ pill: FilterFieldConfig<T>;
42
+ selected: string[];
43
+ onTogglePill: (fieldId: string, value: string, checked: boolean) => void;
44
+ onClearField: (fieldId: string) => void;
45
+ onOpenChange?: (open: boolean) => void;
46
+ testIdPrefix: string;
47
+ /** `stacked` in the filter panel, `pill` on the inline bar. */
48
+ layout?: "stacked" | "pill";
49
+ }
50
+
51
+ /**
52
+ * One filter pill: the hierarchical category tree, or the flat multi-select.
53
+ *
54
+ * A category set nests, and flattening it into one list is what made "Massas"
55
+ * (mercearia) indistinguishable from "Massas" (pratos principais). `control:
56
+ * "category"` opts a field into the tree, which shows each hit under its parent.
57
+ */
58
+ export function PillControl<T extends Record<string, unknown>>({
59
+ fieldId,
60
+ pill,
61
+ selected,
62
+ onTogglePill,
63
+ onClearField,
64
+ onOpenChange,
65
+ testIdPrefix,
66
+ layout = "pill",
67
+ }: PillControlProps<T>): React.JSX.Element {
68
+ const testId = `${testIdPrefix}-filter-${fieldId}`;
69
+ if (pill.control === "category") {
70
+ return (
71
+ <CategorySelect
72
+ placeholder={pill.label}
73
+ label={layout === "stacked" ? pill.label : undefined}
74
+ fullWidth={layout === "stacked"}
75
+ options={pill.options.map(toCategoryOption)}
76
+ value={selected}
77
+ onChange={(next) => emitPillDiff(fieldId, selected, next, onTogglePill)}
78
+ dataTestId={testId}
79
+ />
80
+ );
81
+ }
82
+ return (
83
+ <MultiSelectDropdown
84
+ label={pill.label}
85
+ options={pill.options}
86
+ selected={new Set(selected)}
87
+ onToggle={(value, checked) => onTogglePill(fieldId, value, checked)}
88
+ onClear={() => onClearField(fieldId)}
89
+ allLabel="Todas"
90
+ searchable={pill.searchEnabled ? true : undefined}
91
+ searchPlaceholder="Buscar…"
92
+ noResultsLabel="Nenhum resultado"
93
+ layout={layout}
94
+ onOpenChange={onOpenChange}
95
+ data-testid={testId}
96
+ />
97
+ );
98
+ }
@@ -3,9 +3,9 @@
3
3
  import { Box, Link } from "@mui/material";
4
4
 
5
5
  import { StackedModal, StackedModalContent } from "../../feedback/StackedModal";
6
- import { MultiSelectDropdown } from "../../layout/ContentToolbar";
7
6
  import { TableFilter } from "../../layout/TableFilter";
8
7
 
8
+ import { PillControl } from "./data-views-category-pill";
9
9
  import { PanelRangeField } from "./data-views-range-pill";
10
10
  import type {
11
11
  FilterFieldConfig,
@@ -45,21 +45,18 @@ function PillField<T extends Record<string, unknown>>({
45
45
  }): React.JSX.Element {
46
46
  const testId = `${testIdPrefix}-filter-${field.id}`;
47
47
  // `searchEnabled` forces the searchable multi-select; a plain `multiselect`
48
- // keeps the auto "search only when the list is long" rule.
49
- if (field.searchEnabled || field.control === "multiselect") {
48
+ // keeps the auto "search only when the list is long" rule. `category` is the
49
+ // hierarchical tree — see PillControl.
50
+ if (field.searchEnabled || field.control === "multiselect" || field.control === "category") {
50
51
  return (
51
- <MultiSelectDropdown
52
- label={field.label}
53
- options={field.options}
54
- selected={new Set(selected)}
55
- onToggle={(value, checked) => onTogglePill(field.id, value, checked)}
56
- onClear={() => onClearField(field.id)}
57
- allLabel="Todas"
58
- searchable={field.searchEnabled ? true : undefined}
59
- searchPlaceholder="Buscar…"
60
- noResultsLabel="Nenhum resultado"
52
+ <PillControl
53
+ fieldId={field.id}
54
+ pill={field}
55
+ selected={selected}
56
+ onTogglePill={onTogglePill}
57
+ onClearField={onClearField}
58
+ testIdPrefix={testIdPrefix}
61
59
  layout="stacked"
62
- data-testid={testId}
63
60
  />
64
61
  );
65
62
  }
@@ -18,13 +18,13 @@
18
18
  import { Box } from "@mui/material";
19
19
  import { useState } from "react";
20
20
 
21
- import { MultiSelectDropdown } from "../../layout/ContentToolbar";
22
21
 
23
22
  import { ClearAllControl, CloseSearchControl } from "./data-views-bar-controls";
24
23
  import type { GridFilterPanelProps } from "./data-views-filter-panel";
25
24
  import { MoreFilters } from "./data-views-more-filters";
26
25
  import type { OverflowField, OverflowSplit } from "./data-views-overflow";
27
26
  import { CollapsedSearch, InlineKeyword } from "./data-views-search";
27
+ import { PillControl } from "./data-views-category-pill";
28
28
  import { RangePill } from "./data-views-range-pill";
29
29
  import type { RangeValue } from "./data-views-types";
30
30
 
@@ -116,19 +116,14 @@ function InlineControl<T extends Record<string, unknown>>({
116
116
  }): React.JSX.Element | null {
117
117
  if (field.group === "pill" && field.pill) {
118
118
  return (
119
- <MultiSelectDropdown
120
- label={field.pill.label}
121
- options={field.pill.options}
122
- selected={new Set(pills[field.id] ?? [])}
123
- onToggle={(value, checked) => onTogglePill(field.id, value, checked)}
124
- onClear={() => onClearField(field.id)}
125
- allLabel="Todas"
126
- searchable={field.pill.searchEnabled ? true : undefined}
127
- searchPlaceholder="Buscar…"
128
- noResultsLabel="Nenhum resultado"
129
- layout="pill"
119
+ <PillControl
120
+ fieldId={field.id}
121
+ pill={field.pill}
122
+ selected={pills[field.id] ?? []}
123
+ onTogglePill={onTogglePill}
124
+ onClearField={onClearField}
130
125
  onOpenChange={onOpenChange}
131
- data-testid={`${testIdPrefix}-filter-${field.id}`}
126
+ testIdPrefix={testIdPrefix}
132
127
  />
133
128
  );
134
129
  }
@@ -87,6 +87,11 @@ export const CARD_ASPECT_RATIOS: Record<CardAspectRatio, number> = {
87
87
  export interface FilterOption {
88
88
  value: string;
89
89
  label: string;
90
+ /**
91
+ * Parent option's `value`, for a hierarchical field (`control: "category"`).
92
+ * Ignored by the flat controls, so one option list serves both.
93
+ */
94
+ parentId?: string | null;
90
95
  }
91
96
 
92
97
  /** A typed, multi-select filter pill's configuration for a table. */
@@ -98,9 +103,12 @@ export interface FilterFieldConfig<T extends Record<string, unknown>> {
98
103
  /**
99
104
  * How the field renders in the filter panel. `checkboxes` (default) is best for
100
105
  * a few fixed options; `multiselect` is a compact dropdown for long option
101
- * lists (e.g. many categories).
106
+ * lists; `category` is the hierarchical picker, for an option set that nests
107
+ * via {@link FilterOption.parentId} — it renders parents as headings and
108
+ * their children as the selectable rows, rather than flattening the tree into
109
+ * one indistinguishable list.
102
110
  */
103
- control?: "checkboxes" | "multiselect";
111
+ control?: "checkboxes" | "multiselect" | "category";
104
112
  /**
105
113
  * Forces this filter to render as a searchable multi-select dropdown with the
106
114
  * search box always shown, regardless of how many options are currently
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The prototype's four glyphs, inlined.
3
+ *
4
+ * Inline SVG rather than `@mui/icons-material` because these are drawn at the
5
+ * design's own weights (2.4 for the chevrons, 3.2 for the tick) and sizes; the
6
+ * Material set is a 24px grid at a fixed weight and would not land on the same
7
+ * pixels.
8
+ */
9
+
10
+ const STROKE = {
11
+ fill: 'none',
12
+ stroke: 'currentColor',
13
+ strokeLinecap: 'round',
14
+ strokeLinejoin: 'round',
15
+ } as const;
16
+
17
+ /** The tick inside a checked box. */
18
+ export function CheckGlyph(): React.JSX.Element {
19
+ return (
20
+ <svg viewBox="0 0 24 24" strokeWidth={3.2} aria-hidden="true" {...STROKE}>
21
+ <path d="M20 6 9 17l-5-5" />
22
+ </svg>
23
+ );
24
+ }
25
+
26
+ /** The disclosure chevron on a category row (points right; rotates when open). */
27
+ export function DisclosureGlyph(): React.JSX.Element {
28
+ return (
29
+ <svg viewBox="0 0 24 24" strokeWidth={2.4} aria-hidden="true" {...STROKE}>
30
+ <path d="m9 5 7 7-7 7" />
31
+ </svg>
32
+ );
33
+ }
34
+
35
+ /** The trigger's caret (points down; rotates when the panel is open). */
36
+ export function CaretGlyph({ style }: { style?: React.CSSProperties }): React.JSX.Element {
37
+ return (
38
+ <svg viewBox="0 0 24 24" strokeWidth={2.4} style={style} aria-hidden="true" {...STROKE}>
39
+ <path d="m6 9 6 6 6-6" />
40
+ </svg>
41
+ );
42
+ }
43
+
44
+ /** The magnifier in the panel's search field. */
45
+ export function SearchGlyph({ style }: { style?: React.CSSProperties }): React.JSX.Element {
46
+ return (
47
+ <svg viewBox="0 0 24 24" strokeWidth={2} style={style} aria-hidden="true" {...STROKE}>
48
+ <circle cx="11" cy="11" r="7" />
49
+ <path d="m20 20-3.5-3.5" />
50
+ </svg>
51
+ );
52
+ }
@@ -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
+ }