@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/ui",
|
|
3
|
-
"version": "4.0
|
|
3
|
+
"version": "4.1.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",
|
|
@@ -2,7 +2,7 @@ import Error from '@mui/icons-material/Error';
|
|
|
2
2
|
import Info from '@mui/icons-material/Info';
|
|
3
3
|
import CheckCircle from '@mui/icons-material/CheckCircle';
|
|
4
4
|
import Warning from '@mui/icons-material/Warning';
|
|
5
|
-
import { alpha, keyframes } from '@mui/material';
|
|
5
|
+
import { alpha, darken, keyframes, lighten } from '@mui/material';
|
|
6
6
|
import type { CSSObject, Theme } from '@mui/material/styles';
|
|
7
7
|
import React from 'react';
|
|
8
8
|
|
|
@@ -117,58 +117,165 @@ export const getVariantIcon = (variant: string) => {
|
|
|
117
117
|
// which is narrower than MUI's PaletteColor.
|
|
118
118
|
export type AlertPalette = { main: string; light?: string; dark?: string };
|
|
119
119
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
120
|
+
/** The four semantic variants, which differ only in which palette they read. */
|
|
121
|
+
const SEMANTIC_VARIANTS = new Set(['info', 'success', 'warning', 'danger']);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The room the message is given — padding, the stack's rhythm, and where the
|
|
125
|
+
* icon and the close button sit relative to it.
|
|
126
|
+
*
|
|
127
|
+
* Extracted for the same reason {@link alertVariantStyles} and
|
|
128
|
+
* {@link alertEmphasisStyles} were: `styled()`'s callback is one function, and
|
|
129
|
+
* every block written inline is counted against its length by the complexity
|
|
130
|
+
* gate. Keeping the layout here means the callback assembles four named pieces
|
|
131
|
+
* instead of carrying all of them.
|
|
132
|
+
*/
|
|
133
|
+
export const alertLayoutStyles = (
|
|
124
134
|
theme: Theme,
|
|
125
|
-
customVariant: string | undefined,
|
|
126
135
|
colorPalette: AlertPalette,
|
|
136
|
+
animate: boolean | undefined,
|
|
127
137
|
): CSSObject => ({
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
138
|
+
// ROOM TO READ.
|
|
139
|
+
//
|
|
140
|
+
// MUI's `6px 16px` is sized for one line of text and nothing else. This Alert
|
|
141
|
+
// routinely carries three things stacked — a sentence, a smaller explanation,
|
|
142
|
+
// then a control — and at 6px they touched top and bottom and ran together
|
|
143
|
+
// vertically, so the whole card read as one dense block instead of as a
|
|
144
|
+
// message with parts. The generosity is what makes the structure legible; it
|
|
145
|
+
// is not decoration.
|
|
146
|
+
//
|
|
147
|
+
// Padding stays overridable by an `sx` (a full-bleed strip legitimately wants
|
|
148
|
+
// it tight), because this is a default rather than a rule.
|
|
149
|
+
padding: theme.spacing(1.75, 2),
|
|
136
150
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
151
|
+
'.MuiAlert-message': {
|
|
152
|
+
display: 'flex',
|
|
153
|
+
flexDirection: 'column',
|
|
154
|
+
// 0.5 put a title, its explanation and a button 4px apart, which reads as a
|
|
155
|
+
// spacing bug rather than as a group. 1 separates the lines; the extra
|
|
156
|
+
// margin below goes to whatever CONTROL sits at the end, because the gap
|
|
157
|
+
// between prose and a thing you press has to be bigger than the gap between
|
|
158
|
+
// two lines of prose or the button looks like part of the text.
|
|
159
|
+
gap: theme.spacing(1),
|
|
160
|
+
fontSize: '0.95rem',
|
|
161
|
+
lineHeight: 1.5,
|
|
162
|
+
// No padding of its own — the root's is now doing that job, and MUI's
|
|
163
|
+
// default `8px 0` on top of it would double the vertical space.
|
|
164
|
+
padding: 0,
|
|
165
|
+
// A CONTROL ON A TINTED PANEL NEEDS AN EDGE.
|
|
166
|
+
//
|
|
167
|
+
// `ghost` and `text` paint no background and no border — bare labels, which
|
|
168
|
+
// works on white where the surrounding page is obviously not clickable.
|
|
169
|
+
// Inside a coloured Alert it stops working: the label is one more coloured
|
|
170
|
+
// phrase among several, and a "Cancelar" beside two lines of prose reads as
|
|
171
|
+
// part of the prose.
|
|
172
|
+
//
|
|
173
|
+
// Only the BORDER is set, never the background or the colour, and that is
|
|
174
|
+
// what makes it safe to apply to every button rather than just the bare
|
|
175
|
+
// ones: a `solid` keeps its fill and its light label and gains a hairline
|
|
176
|
+
// in the same hue, while a `ghost` gains the whole affordance.
|
|
177
|
+
'.MuiButton-root': {
|
|
178
|
+
marginTop: theme.spacing(0.5),
|
|
179
|
+
border: `1px solid ${alpha(colorPalette.main, 0.45)}` },
|
|
143
180
|
},
|
|
144
|
-
}),
|
|
145
181
|
|
|
146
|
-
...(customVariant === 'warning' && {
|
|
147
|
-
backgroundColor: alpha(colorPalette.main, 0.1),
|
|
148
|
-
color: colorPalette.main,
|
|
149
|
-
border: `1px solid ${alpha(colorPalette.main, 0.2)}`,
|
|
150
182
|
'.MuiAlert-icon': {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
183
|
+
transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
184
|
+
alignItems: 'center',
|
|
185
|
+
// Clear of the words rather than nearly touching them, and aligned to the
|
|
186
|
+
// FIRST line instead of centred on the whole block — an icon floating
|
|
187
|
+
// halfway down a three-line message points at nothing.
|
|
188
|
+
alignSelf: 'flex-start',
|
|
189
|
+
marginRight: theme.spacing(1.75),
|
|
190
|
+
paddingTop: theme.spacing(0.25),
|
|
191
|
+
animation: animate ? `${iconRotate} 0.6s ease-out` : 'none' },
|
|
154
192
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
193
|
+
// The close button, kept off the text it sits beside.
|
|
194
|
+
'.MuiAlert-action': {
|
|
195
|
+
alignItems: 'flex-start',
|
|
196
|
+
paddingLeft: theme.spacing(2) },
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A semantic Alert's surface and ink.
|
|
201
|
+
*
|
|
202
|
+
* ## Why this is not `alpha(main, 0.1)`
|
|
203
|
+
*
|
|
204
|
+
* It used to be, four times over, with `color: main` for the text. Both halves
|
|
205
|
+
* were wrong, and independently so.
|
|
206
|
+
*
|
|
207
|
+
* The fill was TRANSLUCENT. A 10% wash only reads as a banner when something
|
|
208
|
+
* opaque is behind it, so the component silently depended on where a consumer
|
|
209
|
+
* put it. Placed over content — a floating notice, an invite anchored above a
|
|
210
|
+
* catalogue — the page came straight through and the sentence interleaved with
|
|
211
|
+
* whatever it was over. That is not a faint banner; it is an unreadable one,
|
|
212
|
+
* and nothing in the component said so.
|
|
213
|
+
*
|
|
214
|
+
* The ink was the SEVERITY HUE at body size. `#0288d1`, this library's info
|
|
215
|
+
* blue, is 3.03:1 on white — under the 4.5:1 WCAG AA floor before any wash is
|
|
216
|
+
* involved. So the text failed on a plain white page too, where the fill was
|
|
217
|
+
* behaving exactly as intended.
|
|
218
|
+
*
|
|
219
|
+
* ## Why `lighten`/`darken` rather than a hand-picked pair
|
|
220
|
+
*
|
|
221
|
+
* These are the same transforms MUI's own `standard` Alert uses, at the same
|
|
222
|
+
* ratios — the recipe this component overrode. Reusing it means an OPAQUE tint
|
|
223
|
+
* with a dark ink of the same hue, contrast that holds on any surface because
|
|
224
|
+
* it no longer depends on one, and a pairing already proven across MUI's
|
|
225
|
+
* palette rather than eyeballed here. Both modes are handled: on a dark theme
|
|
226
|
+
* the tint darkens and the ink lightens, which an alpha wash could never do
|
|
227
|
+
* because it carries the seed's own lightness through unchanged.
|
|
228
|
+
*
|
|
229
|
+
* The border keeps an alpha — it is a hairline over the tint it belongs to, so
|
|
230
|
+
* translucency there costs nothing and lets the edge follow the fill.
|
|
231
|
+
*/
|
|
232
|
+
const semanticSurface = (theme: Theme, colorPalette: AlertPalette): CSSObject => {
|
|
233
|
+
const dark = theme.palette.mode === 'dark';
|
|
234
|
+
return {
|
|
235
|
+
backgroundColor: dark ? darken(colorPalette.main, 0.8) : lighten(colorPalette.main, 0.9),
|
|
236
|
+
color: dark ? lighten(colorPalette.main, 0.6) : darken(colorPalette.main, 0.6),
|
|
237
|
+
border: `1px solid ${alpha(colorPalette.main, 0.35)}`,
|
|
238
|
+
'.MuiAlert-icon': {
|
|
239
|
+
color: colorPalette.main,
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// The six visual variants. Each block is spread in only when it matches, which
|
|
245
|
+
// is how the original inline version read; keeping that shape here means the
|
|
246
|
+
// styled() callback carries one branch instead of six. The four semantics were
|
|
247
|
+
// four IDENTICAL copies of the same block — they now share one, so the next
|
|
248
|
+
// change to a banner's surface cannot land on three of them.
|
|
249
|
+
export const alertVariantStyles = (
|
|
250
|
+
theme: Theme,
|
|
251
|
+
customVariant: string | undefined,
|
|
252
|
+
colorPalette: AlertPalette,
|
|
253
|
+
): CSSObject => ({
|
|
254
|
+
...(customVariant !== undefined &&
|
|
255
|
+
SEMANTIC_VARIANTS.has(customVariant) &&
|
|
256
|
+
semanticSurface(theme, colorPalette)),
|
|
163
257
|
|
|
164
258
|
...(customVariant === 'glass' && {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
259
|
+
// 85%, not 10%. This is the variant that is MEANT to be see-through, and it
|
|
260
|
+
// was the same mistake in a costume: at 0.1 the blur had almost nothing to
|
|
261
|
+
// sit on, so `glass` was not frosted, it was a window. A frosted pane has to
|
|
262
|
+
// be mostly pane — the backdrop should be legible AS texture behind the
|
|
263
|
+
// text, never as competition with it.
|
|
264
|
+
backgroundColor: alpha(theme.palette.background.paper, 0.85),
|
|
265
|
+
backdropFilter: 'blur(20px) saturate(180%)',
|
|
266
|
+
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
|
267
|
+
border: `1px solid ${alpha(theme.palette.divider, 0.4)}`,
|
|
168
268
|
color: theme.palette.text.primary,
|
|
169
269
|
'.MuiAlert-icon': {
|
|
170
270
|
color: theme.palette.primary.main,
|
|
171
271
|
},
|
|
272
|
+
// Safari before 18 and Firefox with the filter disabled paint the declared
|
|
273
|
+
// alpha with nothing blurred behind it — the see-through banner this is
|
|
274
|
+
// fixing. There is no degraded frost to fall back to, so it falls back to an
|
|
275
|
+
// opaque pane: less pretty, still readable.
|
|
276
|
+
'@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)))': {
|
|
277
|
+
backgroundColor: theme.palette.background.paper,
|
|
278
|
+
},
|
|
172
279
|
}),
|
|
173
280
|
|
|
174
281
|
...(customVariant === 'gradient' && {
|
|
@@ -11,11 +11,11 @@ import React from 'react';
|
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
13
|
alertEmphasisStyles,
|
|
14
|
+
alertLayoutStyles,
|
|
14
15
|
alertVariantStyles,
|
|
15
16
|
fadeInScale,
|
|
16
17
|
getColorFromTheme,
|
|
17
|
-
getVariantIcon
|
|
18
|
-
iconRotate } from './Alert.styles';
|
|
18
|
+
getVariantIcon } from './Alert.styles';
|
|
19
19
|
import type { AlertColor } from '@mui/material';
|
|
20
20
|
import type { AlertProps } from './Alert.types';
|
|
21
21
|
|
|
@@ -39,18 +39,7 @@ const StyledAlert = styled(MuiAlert, {
|
|
|
39
39
|
animation: animate ? `${fadeInScale} 0.3s ease-out` : 'none',
|
|
40
40
|
willChange: 'transform, opacity',
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
'.MuiAlert-message': {
|
|
44
|
-
display: 'flex',
|
|
45
|
-
flexDirection: 'column',
|
|
46
|
-
gap: theme.spacing(0.5),
|
|
47
|
-
fontSize: '0.95rem',
|
|
48
|
-
lineHeight: 1.5 },
|
|
49
|
-
|
|
50
|
-
'.MuiAlert-icon': {
|
|
51
|
-
transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
52
|
-
alignItems: 'center',
|
|
53
|
-
animation: animate ? `${iconRotate} 0.6s ease-out` : 'none' },
|
|
42
|
+
...alertLayoutStyles(theme, colorPalette, animate),
|
|
54
43
|
|
|
55
44
|
// Hover effects
|
|
56
45
|
'&:hover': {
|
|
@@ -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
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { alpha, keyframes } from '@mui/material';
|
|
1
|
+
import { alpha, darken, keyframes, lighten } from '@mui/material';
|
|
2
2
|
import type { CSSObject, Theme } from '@mui/material';
|
|
3
3
|
|
|
4
4
|
// Define pulse animation globally
|
|
@@ -146,6 +146,25 @@ const gradientFor = (theme: Theme, color: string, palette: ColorPalette): string
|
|
|
146
146
|
return `linear-gradient(135deg, ${from} 0%, ${to} 100%)`;
|
|
147
147
|
};
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* The label colour for the two variants that paint no background of their own.
|
|
151
|
+
*
|
|
152
|
+
* `ghost` and `text` used `palette.main` directly, which is the same mistake
|
|
153
|
+
* the Alert made with its body text: a palette's `main` is chosen to sit UNDER
|
|
154
|
+
* white — it is the fill of a solid button — and it is not chosen to be read AS
|
|
155
|
+
* text. `#6366F1`, the default primary, is 3.6:1 on white and worse on any
|
|
156
|
+
* tinted surface, so a "Cancelar" or an "Agora não" placed on a coloured panel
|
|
157
|
+
* turned into a smudge. That is precisely where these two variants belong: they
|
|
158
|
+
* exist to be the quiet option NEXT to a solid one, so they are the buttons
|
|
159
|
+
* most likely to be sitting on something other than plain paper.
|
|
160
|
+
*
|
|
161
|
+
* One step darker (lighter, on a dark theme) keeps the hue the caller asked for
|
|
162
|
+
* and buys the contrast back. `main` still drives the hover wash and every
|
|
163
|
+
* bordered variant, so nothing that was already legible moves.
|
|
164
|
+
*/
|
|
165
|
+
const quietInk = (theme: Theme, palette: ColorPalette): string =>
|
|
166
|
+
theme.palette.mode === 'dark' ? lighten(palette.main, 0.35) : darken(palette.main, 0.3);
|
|
167
|
+
|
|
149
168
|
const VARIANT_STYLES: Record<
|
|
150
169
|
string,
|
|
151
170
|
(theme: Theme, palette: ColorPalette, color: string) => CSSObject
|
|
@@ -168,23 +187,23 @@ const VARIANT_STYLES: Record<
|
|
|
168
187
|
borderColor: palette.dark,
|
|
169
188
|
},
|
|
170
189
|
}),
|
|
171
|
-
ghost: (
|
|
190
|
+
ghost: (theme, palette) => ({
|
|
172
191
|
backgroundColor: 'transparent',
|
|
173
|
-
color: palette
|
|
192
|
+
color: quietInk(theme, palette),
|
|
174
193
|
'&:hover': {
|
|
175
194
|
backgroundColor: alpha(palette.main, 0.1),
|
|
176
195
|
},
|
|
177
196
|
}),
|
|
178
|
-
text: (
|
|
197
|
+
text: (theme, palette) => ({
|
|
179
198
|
backgroundColor: 'transparent',
|
|
180
|
-
color: palette
|
|
199
|
+
color: quietInk(theme, palette),
|
|
181
200
|
border: `none`,
|
|
182
201
|
'&:hover': {
|
|
183
202
|
backgroundColor: alpha(palette.main, 0.1),
|
|
184
203
|
},
|
|
185
204
|
'&.active': {
|
|
186
205
|
backgroundColor: alpha(palette.main, 0.1),
|
|
187
|
-
color: palette
|
|
206
|
+
color: quietInk(theme, palette),
|
|
188
207
|
},
|
|
189
208
|
}),
|
|
190
209
|
glass: (theme, palette) => ({
|
|
@@ -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
|
+
}
|