@12-apps/ui 1.6.3 → 1.8.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 (39) hide show
  1. package/package.json +4 -4
  2. package/src/components/data-display/DataViews/DataViewsTableBase.tsx +1 -1
  3. package/src/components/data-display/DataViews/RESPONSIVE-BEHAVIOUR.md +183 -0
  4. package/src/components/data-display/DataViews/base-card.tsx +93 -23
  5. package/src/components/data-display/DataViews/base-list-card-slots.tsx +256 -0
  6. package/src/components/data-display/DataViews/base-list-card.tsx +352 -0
  7. package/src/components/data-display/DataViews/card-surface.ts +297 -0
  8. package/src/components/data-display/DataViews/data-views-bar-controls.tsx +119 -0
  9. package/src/components/data-display/DataViews/data-views-display-panel.tsx +48 -22
  10. package/src/components/data-display/DataViews/data-views-display-tabs.tsx +64 -31
  11. package/src/components/data-display/DataViews/data-views-drag.tsx +135 -0
  12. package/src/components/data-display/DataViews/data-views-export.tsx +2 -1
  13. package/src/components/data-display/DataViews/data-views-glyphs.tsx +16 -0
  14. package/src/components/data-display/DataViews/data-views-grid-bodies.tsx +5 -1
  15. package/src/components/data-display/DataViews/data-views-grid-parts.tsx +20 -14
  16. package/src/components/data-display/DataViews/data-views-inline-bar.tsx +134 -155
  17. package/src/components/data-display/DataViews/data-views-layout-context.tsx +21 -0
  18. package/src/components/data-display/DataViews/data-views-more-fields.tsx +291 -0
  19. package/src/components/data-display/DataViews/data-views-more-filters.tsx +151 -199
  20. package/src/components/data-display/DataViews/data-views-overflow-costs.ts +100 -0
  21. package/src/components/data-display/DataViews/data-views-overflow.ts +89 -73
  22. package/src/components/data-display/DataViews/data-views-preset-chips.tsx +100 -0
  23. package/src/components/data-display/DataViews/data-views-range-pill.tsx +27 -11
  24. package/src/components/data-display/DataViews/data-views-range-presets.ts +121 -0
  25. package/src/components/data-display/DataViews/data-views-range-types.ts +94 -0
  26. package/src/components/data-display/DataViews/data-views-scope-tabs.tsx +57 -0
  27. package/src/components/data-display/DataViews/data-views-scopes.tsx +2 -2
  28. package/src/components/data-display/DataViews/data-views-search.tsx +19 -1
  29. package/src/components/data-display/DataViews/data-views-shell-toolbar.tsx +4 -0
  30. package/src/components/data-display/DataViews/data-views-toolbar.tsx +31 -21
  31. package/src/components/data-display/DataViews/data-views-types.ts +14 -47
  32. package/src/components/data-display/DataViews/index.ts +8 -0
  33. package/src/components/data-display/DataViews/list-card-rails.tsx +141 -0
  34. package/src/components/data-display/DataViews/pedidos.server.ts +118 -0
  35. package/src/components/data-display/DataViews/use-data-views-state.ts +1 -1
  36. package/src/components/data-display/DescriptionItem/DescriptionItem.tsx +26 -3
  37. package/src/components/layout/ContentToolbar/ContentToolbar.tsx +16 -1
  38. package/src/components/layout/ContentToolbar/MultiSelectDropdown.tsx +9 -1
  39. package/src/components/layout/ContentToolbar/MultiSelectTriggers.tsx +39 -1
@@ -2,180 +2,58 @@
2
2
 
3
3
  import KeyboardArrowDownRoundedIcon from "@mui/icons-material/KeyboardArrowDownRounded";
4
4
  import TuneRoundedIcon from "@mui/icons-material/TuneRounded";
5
- import { Checkbox, Popover } from "@mui/material";
5
+ import { Popover } from "@mui/material";
6
6
  import { useState } from "react";
7
7
 
8
- import { MultiSelectDropdown } from "../../layout/ContentToolbar";
9
8
  import { Button } from "../../form/Button";
10
9
  import { Box } from "../../../mui/Box";
11
10
  import { Text } from "../../typography/Text";
12
11
 
12
+ import { fieldClearing, MoreGroup, type MoreFieldProps } from "./data-views-more-fields";
13
13
  import type { OverflowField } from "./data-views-overflow";
14
- import { isRangeSet } from "./data-views-range-values";
15
- import type { RangeValue } from "./data-views-types";
16
14
 
17
15
  /**
18
- * "MAIS" — the filter overflow.
16
+ * "MAIS" — the filter overflow: its trigger, its panel and its footer. What
17
+ * goes INSIDE it is `data-views-more-fields`.
19
18
  *
20
- * It holds ONLY the fields that had no room on the toolbar, which is why its
21
- * badge counts hidden FIELDS rather than active filters: an applied filter
22
- * keeps its inline control (see `useFilterOverflow`), because a filter you
23
- * cannot see is a filter you forget you set, and then the list is "wrong" for
24
- * a reason nothing on screen explains.
25
- *
26
- * A field with one or two options renders FLAT — inside a panel there is no
27
- * space to win back, and two labelled checkboxes are faster than a dropdown
28
- * needing a second click. Past that it becomes a {@link MultiSelectDropdown}:
29
- * see `INLINE_OPTION_LIMIT`.
19
+ * It holds the fields that had no room on the toolbar. An applied filter is
20
+ * ranked ahead of an idle one for the visible slots but is NOT exempt (see
21
+ * `useFilterOverflow`), so an applied filter can be in here — on a phone, most
22
+ * of them are. That is why the trigger changes tone and counts the applied
23
+ * ones: a filter you cannot see is a filter you forget you set, and the badge
24
+ * is what stops that.
30
25
  */
31
26
 
32
- interface MoreFiltersProps<T extends Record<string, unknown>> {
27
+ interface MoreFiltersProps<T extends Record<string, unknown>> extends MoreFieldProps {
33
28
  fields: OverflowField<T>[];
34
- pills: Record<string, string[]>;
35
- ranges: Record<string, RangeValue>;
36
- onTogglePill: (fieldId: string, value: string, checked: boolean) => void;
37
- onChangeRange: (fieldId: string, range: RangeValue) => void;
38
- testIdPrefix: string;
39
29
  }
40
30
 
41
31
  /**
42
- * Above this many options a field is a DROPDOWN rather than a flat row of
43
- * checkboxes. Two options cost two lines and read faster laid out; a "Cliente"
44
- * with a dozen turns the panel into a scrolling wall and buries every field
45
- * under it, which is exactly what the overflow was meant to avoid.
32
+ * The trigger, badged with how many fields had no room on the bar — and told
33
+ * apart when some of them are APPLIED.
34
+ *
35
+ * An applied filter can land in here now (it is ranked first, not exempt), so
36
+ * this button is the only thing on screen still saying it exists. A neutral
37
+ * badge reading "3" would make "three filters you have not used" and "three
38
+ * filters narrowing this list" look identical, which is the whole failure the
39
+ * old exemption was written to avoid. So an overflow holding applied filters
40
+ * takes the applied tone and counts them in its own badge.
46
41
  */
47
- const INLINE_OPTION_LIMIT = 2;
48
-
49
- /** One overflowed pill: its options as checkboxes, all visible at once. */
50
- function OverflowPill<T extends Record<string, unknown>>({
51
- field,
52
- values,
53
- onToggle,
54
- onClear,
55
- testIdPrefix,
56
- }: {
57
- field: OverflowField<T>;
58
- values: string[];
59
- onToggle: (value: string, checked: boolean) => void;
60
- onClear: () => void;
61
- testIdPrefix: string;
62
- }): React.JSX.Element {
63
- const options = field.pill?.options ?? [];
64
- if (options.length > INLINE_OPTION_LIMIT) {
65
- return (
66
- <MultiSelectDropdown
67
- label={field.pill?.label ?? field.label}
68
- options={options}
69
- selected={new Set(values)}
70
- onToggle={onToggle}
71
- onClear={onClear}
72
- allLabel="Todas"
73
- searchable={options.length > 6 ? true : undefined}
74
- searchPlaceholder="Buscar…"
75
- noResultsLabel="Nenhum resultado"
76
- layout="pill"
77
- data-testid={`${testIdPrefix}-more-${field.id}`}
78
- />
79
- );
80
- }
81
- return (
82
- <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75 }}>
83
- {(field.pill?.options ?? []).map((option) => {
84
- const checked = values.includes(option.value);
85
- return (
86
- <Box
87
- key={option.value}
88
- component="label"
89
- data-testid={`${testIdPrefix}-more-${field.id}-${option.value}`}
90
- sx={{
91
- display: "inline-flex",
92
- alignItems: "center",
93
- gap: 0.25,
94
- pr: 1,
95
- border: 1,
96
- borderStyle: "solid",
97
- borderColor: checked ? "primary.main" : "divider",
98
- bgcolor: checked ? "action.selected" : "transparent",
99
- borderRadius: 5,
100
- cursor: "pointer",
101
- fontSize: "0.8125rem",
102
- color: checked ? "primary.main" : "text.secondary",
103
- }}
104
- >
105
- <Checkbox
106
- size="small"
107
- checked={checked}
108
- onChange={(event) => onToggle(option.value, event.target.checked)}
109
- inputProps={{ "aria-label": `${field.label}: ${option.label}` }}
110
- />
111
- {option.label}
112
- </Box>
113
- );
114
- })}
115
- </Box>
116
- );
117
- }
118
-
119
- /** One overflowed range: its two bounds, as the same inputs the pill uses. */
120
- function OverflowRange<T extends Record<string, unknown>>({
121
- field,
122
- value,
123
- onChange,
124
- testIdPrefix,
125
- }: {
126
- field: OverflowField<T>;
127
- value: RangeValue;
128
- onChange: (range: RangeValue) => void;
129
- testIdPrefix: string;
130
- }): React.JSX.Element {
131
- const day = field.range?.kind === "day";
132
- const set = (bound: "min" | "max", raw: string): void => {
133
- const next: RangeValue = { ...value };
134
- if (raw === "") delete next[bound];
135
- else next[bound] = day ? raw : Number(raw);
136
- onChange(next);
137
- };
138
- return (
139
- <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
140
- {(["min", "max"] as const).map((bound, index) => (
141
- <Box key={bound} sx={{ display: "contents" }}>
142
- {index === 1 && <Box component="span" sx={{ color: "text.disabled" }}>–</Box>}
143
- <Box
144
- component="input"
145
- type={day ? "date" : "number"}
146
- placeholder={bound === "min" ? "de" : "até"}
147
- aria-label={`${field.label} ${bound === "min" ? "de" : "até"}`}
148
- data-testid={`${testIdPrefix}-more-${field.id}-${bound}`}
149
- value={value[bound] ?? ""}
150
- onChange={(event: React.ChangeEvent<HTMLInputElement>) => set(bound, event.target.value)}
151
- sx={{
152
- width: "100%",
153
- px: 1,
154
- py: 0.75,
155
- border: 1,
156
- borderStyle: "solid",
157
- borderColor: "divider",
158
- borderRadius: 1,
159
- font: "inherit",
160
- fontSize: "0.8125rem",
161
- }}
162
- />
163
- </Box>
164
- ))}
165
- </Box>
166
- );
167
- }
168
-
169
- /** The trigger, badged with how many fields had no room on the bar. */
170
42
  function MoreTrigger({
171
43
  count,
44
+ appliedCount,
45
+ compact,
172
46
  onOpen,
173
47
  testIdPrefix,
174
48
  }: {
175
49
  count: number;
50
+ appliedCount: number;
51
+ /** Icon only — the same rung that strips Exibir/Exportar. */
52
+ compact: boolean;
176
53
  onOpen: (anchor: HTMLElement) => void;
177
54
  testIdPrefix: string;
178
55
  }): React.JSX.Element {
56
+ const applied = appliedCount > 0;
179
57
  return (
180
58
  <Button
181
59
  variant="outline"
@@ -183,64 +61,47 @@ function MoreTrigger({
183
61
  color="neutral"
184
62
  onClick={(event) => onOpen(event.currentTarget as HTMLElement)}
185
63
  dataTestId={`${testIdPrefix}-more-filters`}
186
- aria-label={`Mais filtros: ${count} sem espaço na barra`}
64
+ aria-label={
65
+ applied
66
+ ? `Mais filtros: ${count} sem espaço na barra, ${appliedCount} aplicado(s)`
67
+ : `Mais filtros: ${count} sem espaço na barra`
68
+ }
187
69
  >
188
- <Box component="span" sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}>
70
+ <Box
71
+ component="span"
72
+ sx={{
73
+ display: "inline-flex",
74
+ alignItems: "center",
75
+ gap: 0.5,
76
+ color: applied ? "primary.main" : undefined,
77
+ }}
78
+ >
189
79
  <TuneRoundedIcon fontSize="small" />
190
- <Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}>
191
- Mais
192
- </Box>
80
+ {!compact && (
81
+ <Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}>
82
+ Mais
83
+ </Box>
84
+ )}
193
85
  <Box
194
86
  component="span"
195
- sx={{ px: 0.75, borderRadius: 5, bgcolor: "action.selected", fontSize: "0.6875rem" }}
87
+ data-testid={`${testIdPrefix}-more-badge`}
88
+ sx={{
89
+ px: 0.75,
90
+ borderRadius: 5,
91
+ bgcolor: applied ? "primary.main" : "action.selected",
92
+ color: applied ? "primary.contrastText" : undefined,
93
+ fontSize: "0.6875rem",
94
+ fontWeight: applied ? 700 : undefined,
95
+ }}
196
96
  >
197
- {count}
97
+ {applied ? appliedCount : count}
198
98
  </Box>
199
- <KeyboardArrowDownRoundedIcon fontSize="small" />
99
+ {!compact && <KeyboardArrowDownRoundedIcon fontSize="small" />}
200
100
  </Box>
201
101
  </Button>
202
102
  );
203
103
  }
204
104
 
205
- /** One labelled group in the panel: the field's name, then its control. */
206
- function MoreGroup<T extends Record<string, unknown>>({
207
- field,
208
- pills,
209
- ranges,
210
- onTogglePill,
211
- onChangeRange,
212
- testIdPrefix,
213
- }: {
214
- field: OverflowField<T>;
215
- } & Omit<MoreFiltersProps<T>, "fields">): React.JSX.Element {
216
- return (
217
- <Box sx={{ mb: 1.5, "&:last-of-type": { mb: 0 } }}>
218
- <Text variant="caption" as="p">
219
- <Box component="span" sx={{ display: "block", mb: 0.5, color: "text.secondary" }}>
220
- {field.label}
221
- {field.group === "range" && isRangeSet(ranges[field.id] ?? {}) ? " •" : ""}
222
- </Box>
223
- </Text>
224
- {field.group === "pill" ? (
225
- <OverflowPill
226
- field={field}
227
- values={pills[field.id] ?? []}
228
- onToggle={(value, checked) => onTogglePill(field.id, value, checked)}
229
- onClear={() => (pills[field.id] ?? []).forEach((v) => onTogglePill(field.id, v, false))}
230
- testIdPrefix={testIdPrefix}
231
- />
232
- ) : (
233
- <OverflowRange
234
- field={field}
235
- value={ranges[field.id] ?? {}}
236
- onChange={(range) => onChangeRange(field.id, range)}
237
- testIdPrefix={testIdPrefix}
238
- />
239
- )}
240
- </Box>
241
- );
242
- }
243
-
244
105
  /** The panel's heading: what these are, and why they are in here. */
245
106
  function MoreHeading(): React.JSX.Element {
246
107
  return (
@@ -269,30 +130,121 @@ function MoreHeading(): React.JSX.Element {
269
130
  );
270
131
  }
271
132
 
133
+ /**
134
+ * The panel's own way back to the unfiltered list.
135
+ *
136
+ * The bar's "Limpar" is the primary one, but it is a control like any other
137
+ * and the ladder can take its label — and on the narrowest rungs this panel is
138
+ * where most of the fields ended up anyway. Clearing everything from inside it
139
+ * saves closing it to reach a button three pixels wide.
140
+ */
141
+ function MoreFooter({
142
+ onClearAll,
143
+ testIdPrefix,
144
+ }: {
145
+ onClearAll: () => void;
146
+ testIdPrefix: string;
147
+ }): React.JSX.Element {
148
+ return (
149
+ <Box sx={{ px: 1.5, py: 1, borderTop: 1, borderColor: "divider" }}>
150
+ <Box
151
+ component="button"
152
+ type="button"
153
+ onClick={onClearAll}
154
+ data-testid={`${testIdPrefix}-more-clear-all`}
155
+ sx={{
156
+ width: "100%",
157
+ border: 0,
158
+ p: 0,
159
+ bgcolor: "transparent",
160
+ cursor: "pointer",
161
+ font: "inherit",
162
+ fontSize: "0.8125rem",
163
+ textAlign: "left",
164
+ color: "primary.main",
165
+ "&:hover": { textDecoration: "underline" },
166
+ }}
167
+ >
168
+ Limpar todos os filtros
169
+ </Box>
170
+ </Box>
171
+ );
172
+ }
173
+
272
174
  /** The overflow trigger + panel. Renders nothing when everything fits. */
273
175
  export function MoreFilters<T extends Record<string, unknown>>({
274
176
  fields,
177
+ onOpenChange,
178
+ onClearAll,
179
+ anyApplied,
180
+ compact = false,
275
181
  ...rest
276
- }: MoreFiltersProps<T>): React.JSX.Element | null {
182
+ }: MoreFiltersProps<T> & {
183
+ /** Icon only — the same rung that strips Exibir/Exportar. */
184
+ compact?: boolean;
185
+ /** Clears every filter, not just the ones in here. */
186
+ onClearAll: () => void;
187
+ /** Whether anything is applied anywhere — the footer is pointless otherwise. */
188
+ anyApplied: boolean;
189
+ /**
190
+ * So the shell can FREEZE the measured split while this panel is open.
191
+ *
192
+ * Applying a filter in here makes its field active, and an active field
193
+ * takes an inline slot first — so the field being edited was promoted OUT of
194
+ * this panel mid-keystroke, taking the focused input with it. Typing "12"
195
+ * got as far as "1" and then had nowhere to go.
196
+ */
197
+ onOpenChange?: (open: boolean) => void;
198
+ }): React.JSX.Element | null {
277
199
  const [anchor, setAnchor] = useState<HTMLElement | null>(null);
278
200
  if (fields.length === 0) return null;
279
201
  return (
280
202
  <>
281
- <MoreTrigger count={fields.length} onOpen={setAnchor} testIdPrefix={rest.testIdPrefix} />
203
+ <MoreTrigger
204
+ count={fields.length}
205
+ appliedCount={
206
+ fields.filter(
207
+ (field) =>
208
+ fieldClearing({
209
+ field,
210
+ pills: rest.pills,
211
+ ranges: rest.ranges,
212
+ onTogglePill: rest.onTogglePill,
213
+ onChangeRange: rest.onChangeRange,
214
+ }).applied,
215
+ ).length
216
+ }
217
+ compact={compact}
218
+ onOpen={(element) => {
219
+ setAnchor(element);
220
+ onOpenChange?.(true);
221
+ }}
222
+ testIdPrefix={rest.testIdPrefix}
223
+ />
282
224
  <Popover
283
225
  open={Boolean(anchor)}
284
226
  anchorEl={anchor}
285
- onClose={() => setAnchor(null)}
227
+ onClose={() => {
228
+ setAnchor(null);
229
+ onOpenChange?.(false);
230
+ }}
286
231
  anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
287
232
  slotProps={{ paper: { sx: { width: 300, maxWidth: "calc(100vw - 32px)" } } }}
288
233
  >
289
234
  <Box data-testid={`${rest.testIdPrefix}-more-panel`}>
290
235
  <MoreHeading />
291
- <Box sx={{ maxHeight: 320, overflowY: "auto", p: 1.5 }}>
236
+ {/* overflowX hidden, not auto: this panel is a fixed 300px and its
237
+ contents are told to shrink into it. A horizontal scrollbar here
238
+ means something refused to, which is a bug to fix rather than a
239
+ scrollbar to live with. */}
240
+ <Box sx={{ maxHeight: 320, overflowY: "auto", overflowX: "hidden", p: 1.5, minWidth: 0 }}>
292
241
  {fields.map((field) => (
293
242
  <MoreGroup key={field.id} field={field} {...rest} />
294
243
  ))}
295
244
  </Box>
245
+ {anyApplied && (
246
+ <MoreFooter onClearAll={onClearAll} testIdPrefix={rest.testIdPrefix} />
247
+ )}
296
248
  </Box>
297
249
  </Popover>
298
250
  </>
@@ -0,0 +1,100 @@
1
+ /**
2
+ * WHAT EACH THING ON THE TOOLBAR COSTS, IN PIXELS.
3
+ *
4
+ * The pricing half of the collapse ladder, split from `data-views-overflow` at
5
+ * the file-size gate. Pure arithmetic and constants: no React, no measurement,
6
+ * no decisions. `data-views-overflow` measures the row and spends what this
7
+ * says it has.
8
+ *
9
+ * Every number here is either MEASURED in a browser (and says so) or
10
+ * deliberately generous. Over-pricing sheds one control too early, which the
11
+ * "Mais" button absorbs invisibly; under-pricing breaks the line.
12
+ */
13
+ import type { OverflowField } from "./data-views-overflow";
14
+ import type { RangeValue } from "./data-views-types";
15
+
16
+ /**
17
+ * Rough rendered width of a control, in px.
18
+ *
19
+ * An ESTIMATE from the label text rather than a real measurement, deliberately:
20
+ * measuring the true width needs every control mounted first, and mounting them
21
+ * to decide whether to mount them is the layout thrash this hook exists to
22
+ * avoid. The estimate only has to rank controls and find the cut point — being
23
+ * a few px out moves the cut by at most one control, and the overflow catches it.
24
+ */
25
+ export function estimateWidth(text: string, extra: number): number {
26
+ /**
27
+ * ~7.6px per character at the toolbar's font size, plus 76px of chrome:
28
+ * horizontal padding, the border, and the dropdown chevron. The chrome used
29
+ * to be 52, which under-priced every control by roughly a chevron's width —
30
+ * six of them then "fitted" a row they overflowed, and because they are flex
31
+ * children the row paid for it by squeezing labels to "D…" and "V…" rather
32
+ * than by shedding a control into the overflow.
33
+ *
34
+ * Deliberately generous: over-pricing sheds one control too early, which the
35
+ * "Mais" button absorbs invisibly. Under-pricing breaks the line.
36
+ */
37
+ return Math.round(text.length * 7.6) + 76 + extra;
38
+ }
39
+
40
+ /** Is a range bounded at either end? */
41
+ export function isRangeSet(range: RangeValue | undefined): boolean {
42
+ return Boolean(range && (range.min !== undefined || range.max !== undefined));
43
+ }
44
+
45
+ /** The label a pill actually renders, which is what decides its width. */
46
+ export function pillText<T extends Record<string, unknown>>(field: OverflowField<T>, values: string[]): string {
47
+ if (values.length === 0) return field.label;
48
+ if (values.length === 1) {
49
+ const option = field.pill?.options.find((entry) => entry.value === values[0]);
50
+ return `${field.label}: ${option?.label ?? values[0]}`;
51
+ }
52
+ return `${field.label}: ${values.length}`;
53
+ }
54
+
55
+ /** Fixed toolbar furniture the filters have to fit around, in px. */
56
+ export const RESERVED = {
57
+ /** The search box never shrinks below this. */
58
+ search: 200,
59
+ /** The "N de N" counter. */
60
+ counter: 96,
61
+ /** Exibir + Exportar WITH their text labels. */
62
+ right: 216,
63
+ /**
64
+ * The same two controls as icons only — step 2 of the ladder.
65
+ *
66
+ * MEASURED at 133px for the pair (66 each: a Button's minimum tap target is
67
+ * a good deal wider than the 20px glyph inside it), so the old 96 under-priced
68
+ * them by a third and the row went on believing it had room it did not.
69
+ */
70
+ rightCompact: 140,
71
+ /** What step 4 leaves behind: the search as a bare magnifier. */
72
+ searchIcon: 44,
73
+ /**
74
+ * The narrowest keyword box still worth typing into — below it the operator
75
+ * cannot read back what they wrote, and the box takes over the cluster
76
+ * instead of sharing it (see `searchTakeover`).
77
+ *
78
+ * 120 rather than 140 because this is compared against the ESTIMATE, which
79
+ * over-prices on purpose (see `estimateWidth`). At 140 a large phone took the
80
+ * whole cluster to produce a box barely wider than the one it could have had
81
+ * while keeping its filters — paying for nothing.
82
+ */
83
+ usableSearch: 120,
84
+ /** The "Mais" button, only charged when there IS an overflow. */
85
+ overflowButton: 104,
86
+ /**
87
+ * "Limpar", only charged when there IS something to clear.
88
+ *
89
+ * It appears exactly when the row is at its widest — every applied filter
90
+ * has a longer label than the idle one it replaced — so leaving it unpriced
91
+ * would break the line at the one moment the operator most needs it.
92
+ * Measured at 64 as an icon; 72 with slack.
93
+ */
94
+ clearAll: 72,
95
+ /** Gaps + the row's own padding. */
96
+ chrome: 64,
97
+ } as const;
98
+
99
+ /** The gap between two controls. */
100
+ export const GAP = 8;