@12-apps/ui 4.0.2 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/components/data-display/DataViews/data-views-category-pill.tsx +98 -0
- package/src/components/data-display/DataViews/data-views-filter-panel.tsx +11 -14
- package/src/components/data-display/DataViews/data-views-inline-bar.tsx +8 -13
- package/src/components/data-display/DataViews/data-views-types.ts +10 -2
- package/src/components/form/CategorySelect/CategoryIcons.tsx +52 -0
- package/src/components/form/CategorySelect/CategoryPanel.tsx +155 -0
- package/src/components/form/CategorySelect/CategoryPanelFoot.tsx +94 -0
- package/src/components/form/CategorySelect/CategoryPanelHead.tsx +167 -0
- package/src/components/form/CategorySelect/CategoryPanelList.tsx +207 -0
- package/src/components/form/CategorySelect/CategoryPanelSurface.tsx +56 -0
- package/src/components/form/CategorySelect/CategoryQuickActions.tsx +69 -0
- package/src/components/form/CategorySelect/CategoryRows.tsx +211 -0
- package/src/components/form/CategorySelect/CategorySelect.md +134 -0
- package/src/components/form/CategorySelect/CategorySelect.styles.ts +354 -0
- package/src/components/form/CategorySelect/CategorySelect.tsx +183 -0
- package/src/components/form/CategorySelect/CategorySelect.types.ts +103 -0
- package/src/components/form/CategorySelect/CategoryTrigger.tsx +109 -0
- package/src/components/form/CategorySelect/category-keyboard.ts +136 -0
- package/src/components/form/CategorySelect/category-tree.ts +210 -0
- package/src/components/form/CategorySelect/index.ts +19 -0
- package/src/components/form/CategorySelect/useCategoryCommit.ts +101 -0
- package/src/components/form/CategorySelect/useCategoryKeyHandler.ts +66 -0
- package/src/components/form/CategorySelect/useCategorySelect.ts +172 -0
- package/src/components/form/total-form/Fields/CategoryField.tsx +57 -0
- package/src/components/form/total-form/Fields/index.ts +2 -0
- package/src/components/form/total-form/index.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/ui",
|
|
3
|
-
"version": "
|
|
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
|
-
|
|
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
|
-
<
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
selected={
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
<
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
selected={
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|