@12-apps/ui 1.5.2 → 1.6.1

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 (44) hide show
  1. package/package.json +4 -4
  2. package/src/components/data-display/DataViews/DataViewsBoard.tsx +327 -0
  3. package/src/components/data-display/DataViews/DataViewsGrid.tsx +80 -77
  4. package/src/components/data-display/DataViews/DataViewsTableBase.tsx +178 -230
  5. package/src/components/data-display/DataViews/DeleteViewDialog.tsx +83 -0
  6. package/src/components/data-display/DataViews/SaveViewModal.tsx +140 -52
  7. package/src/components/data-display/DataViews/data-views-columns-tab.tsx +240 -0
  8. package/src/components/data-display/DataViews/data-views-columns.ts +129 -0
  9. package/src/components/data-display/DataViews/data-views-dirty.ts +62 -0
  10. package/src/components/data-display/DataViews/data-views-display-panel.tsx +328 -0
  11. package/src/components/data-display/DataViews/data-views-display-tabs.tsx +328 -0
  12. package/src/components/data-display/DataViews/data-views-empty.tsx +91 -0
  13. package/src/components/data-display/DataViews/data-views-export.tsx +260 -0
  14. package/src/components/data-display/DataViews/data-views-glyphs.tsx +103 -0
  15. package/src/components/data-display/DataViews/data-views-grid-bodies.tsx +340 -0
  16. package/src/components/data-display/DataViews/data-views-grid-parts.tsx +220 -242
  17. package/src/components/data-display/DataViews/data-views-inline-bar.tsx +187 -81
  18. package/src/components/data-display/DataViews/data-views-layout-context.tsx +215 -79
  19. package/src/components/data-display/DataViews/data-views-more-filters.tsx +300 -0
  20. package/src/components/data-display/DataViews/data-views-overflow.ts +302 -0
  21. package/src/components/data-display/DataViews/data-views-preview.ts +11 -3
  22. package/src/components/data-display/DataViews/data-views-range-pill.tsx +162 -92
  23. package/src/components/data-display/DataViews/data-views-range-values.ts +69 -0
  24. package/src/components/data-display/DataViews/data-views-scopes.tsx +356 -0
  25. package/src/components/data-display/DataViews/data-views-search.tsx +156 -0
  26. package/src/components/data-display/DataViews/data-views-select-all-strip.tsx +105 -0
  27. package/src/components/data-display/DataViews/data-views-selection.ts +50 -0
  28. package/src/components/data-display/DataViews/data-views-server-query.ts +75 -0
  29. package/src/components/data-display/DataViews/data-views-shell-toolbar.tsx +87 -0
  30. package/src/components/data-display/DataViews/data-views-table-parts.tsx +5 -0
  31. package/src/components/data-display/DataViews/data-views-toolbar.tsx +63 -48
  32. package/src/components/data-display/DataViews/data-views-types.ts +88 -9
  33. package/src/components/data-display/DataViews/data-views-view-nav.tsx +378 -0
  34. package/src/components/data-display/DataViews/index.ts +14 -1
  35. package/src/components/data-display/DataViews/use-data-views-state.ts +158 -134
  36. package/src/components/data-display/DataViews/use-saved-views-controller.ts +171 -0
  37. package/src/components/layout/ContentToolbar/ContentToolbar.tsx +86 -30
  38. package/src/components/layout/ContentToolbar/ContentToolbar.types.ts +25 -0
  39. package/src/components/layout/ContentToolbar/MultiSelectDropdown.tsx +6 -1
  40. package/src/components/utility/InstallPrompt/InstallPrompt.earlyCapture.ts +117 -0
  41. package/src/components/utility/InstallPrompt/InstallPrompt.md +52 -4
  42. package/src/components/utility/InstallPrompt/InstallPrompt.types.ts +19 -0
  43. package/src/components/utility/InstallPrompt/index.ts +8 -0
  44. package/src/components/utility/InstallPrompt/usePwaInstall.ts +39 -11
@@ -0,0 +1,300 @@
1
+ "use client";
2
+
3
+ import KeyboardArrowDownRoundedIcon from "@mui/icons-material/KeyboardArrowDownRounded";
4
+ import TuneRoundedIcon from "@mui/icons-material/TuneRounded";
5
+ import { Checkbox, Popover } from "@mui/material";
6
+ import { useState } from "react";
7
+
8
+ import { MultiSelectDropdown } from "../../layout/ContentToolbar";
9
+ import { Button } from "../../form/Button";
10
+ import { Box } from "../../../mui/Box";
11
+ import { Text } from "../../typography/Text";
12
+
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
+
17
+ /**
18
+ * "MAIS" — the filter overflow.
19
+ *
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`.
30
+ */
31
+
32
+ interface MoreFiltersProps<T extends Record<string, unknown>> {
33
+ 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
+ }
40
+
41
+ /**
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.
46
+ */
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
+ function MoreTrigger({
171
+ count,
172
+ onOpen,
173
+ testIdPrefix,
174
+ }: {
175
+ count: number;
176
+ onOpen: (anchor: HTMLElement) => void;
177
+ testIdPrefix: string;
178
+ }): React.JSX.Element {
179
+ return (
180
+ <Button
181
+ variant="outline"
182
+ size="sm"
183
+ color="neutral"
184
+ onClick={(event) => onOpen(event.currentTarget as HTMLElement)}
185
+ dataTestId={`${testIdPrefix}-more-filters`}
186
+ aria-label={`Mais filtros: ${count} sem espaço na barra`}
187
+ >
188
+ <Box component="span" sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}>
189
+ <TuneRoundedIcon fontSize="small" />
190
+ <Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}>
191
+ Mais
192
+ </Box>
193
+ <Box
194
+ component="span"
195
+ sx={{ px: 0.75, borderRadius: 5, bgcolor: "action.selected", fontSize: "0.6875rem" }}
196
+ >
197
+ {count}
198
+ </Box>
199
+ <KeyboardArrowDownRoundedIcon fontSize="small" />
200
+ </Box>
201
+ </Button>
202
+ );
203
+ }
204
+
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
+ /** The panel's heading: what these are, and why they are in here. */
245
+ function MoreHeading(): React.JSX.Element {
246
+ return (
247
+ <Box
248
+ sx={{
249
+ display: "flex",
250
+ alignItems: "center",
251
+ gap: 1,
252
+ px: 1.5,
253
+ py: 1,
254
+ borderBottom: 1,
255
+ borderColor: "divider",
256
+ }}
257
+ >
258
+ <Text variant="caption" as="span">
259
+ <Box component="span" sx={{ textTransform: "uppercase", letterSpacing: 0.5, color: "text.disabled" }}>
260
+ Mais filtros
261
+ </Box>
262
+ </Text>
263
+ <Text variant="caption" as="span">
264
+ <Box component="span" sx={{ ml: "auto", color: "text.disabled" }}>
265
+ sem espaço na barra
266
+ </Box>
267
+ </Text>
268
+ </Box>
269
+ );
270
+ }
271
+
272
+ /** The overflow trigger + panel. Renders nothing when everything fits. */
273
+ export function MoreFilters<T extends Record<string, unknown>>({
274
+ fields,
275
+ ...rest
276
+ }: MoreFiltersProps<T>): React.JSX.Element | null {
277
+ const [anchor, setAnchor] = useState<HTMLElement | null>(null);
278
+ if (fields.length === 0) return null;
279
+ return (
280
+ <>
281
+ <MoreTrigger count={fields.length} onOpen={setAnchor} testIdPrefix={rest.testIdPrefix} />
282
+ <Popover
283
+ open={Boolean(anchor)}
284
+ anchorEl={anchor}
285
+ onClose={() => setAnchor(null)}
286
+ anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
287
+ slotProps={{ paper: { sx: { width: 300, maxWidth: "calc(100vw - 32px)" } } }}
288
+ >
289
+ <Box data-testid={`${rest.testIdPrefix}-more-panel`}>
290
+ <MoreHeading />
291
+ <Box sx={{ maxHeight: 320, overflowY: "auto", p: 1.5 }}>
292
+ {fields.map((field) => (
293
+ <MoreGroup key={field.id} field={field} {...rest} />
294
+ ))}
295
+ </Box>
296
+ </Box>
297
+ </Popover>
298
+ </>
299
+ );
300
+ }
@@ -0,0 +1,302 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+
5
+ import type { FilterFieldConfig, RangeFieldConfig, RangeValue } from "./data-views-types";
6
+
7
+ /**
8
+ * PROGRESSIVE COLLAPSE — which filter controls fit on the toolbar, measured.
9
+ *
10
+ * Two rules, and both come from watching the all-or-nothing version fail:
11
+ *
12
+ * 1. Controls shed ONE AT A TIME into an overflow, never all at once. With four
13
+ * filters and room for three, hiding all four throws away the room that was
14
+ * there.
15
+ * 2. An APPLIED filter never hides. Applied controls take the visible slots
16
+ * first, so the overflow badge counts hidden FIELDS and never active
17
+ * filters — a filter you cannot see is a filter you forget you set, and then
18
+ * the list is "wrong" for reasons nothing on screen explains.
19
+ *
20
+ * It MEASURES rather than picking a breakpoint, which is what
21
+ * `FILTERS-CONTRACT.md` asks for: pages declare different numbers of filters
22
+ * with different label lengths, and the same page collapses at a different
23
+ * width in another language. A shared breakpoint is wrong for at least one
24
+ * table by construction.
25
+ *
26
+ * Collapsing is presentation only — nothing here emits a query, drops a filter
27
+ * or changes a rendered row.
28
+ */
29
+
30
+ /** A filter control on the bar: a multi-select pill or a min/max range. */
31
+ export interface OverflowField<T extends Record<string, unknown>> {
32
+ id: string;
33
+ label: string;
34
+ /** Which control it is — the caller renders the right one. */
35
+ group: "pill" | "range";
36
+ pill?: FilterFieldConfig<T>;
37
+ range?: RangeFieldConfig<T>;
38
+ }
39
+
40
+ /** What the caller renders inline, and what goes behind "Mais". */
41
+ export interface OverflowSplit<T extends Record<string, unknown>> {
42
+ inline: OverflowField<T>[];
43
+ overflow: OverflowField<T>[];
44
+ /**
45
+ * Step 2 — Exibir/Exportar drop their text labels and become icons.
46
+ * Cheap: both keep their icon, their tooltip and their position.
47
+ */
48
+ compactControls: boolean;
49
+ /**
50
+ * Step 4 — the search collapses to a magnifier that expands on click.
51
+ * The most expensive step, so it is taken last: the search is the one
52
+ * control on the bar that cannot be guessed from an icon's position.
53
+ */
54
+ searchCollapsed: boolean;
55
+ /** Attach to the toolbar row being measured. */
56
+ barRef: React.RefObject<HTMLDivElement | null>;
57
+ }
58
+
59
+ /**
60
+ * Rough rendered width of a control, in px.
61
+ *
62
+ * An ESTIMATE from the label text rather than a real measurement, deliberately:
63
+ * measuring the true width needs every control mounted first, and mounting them
64
+ * to decide whether to mount them is the layout thrash this hook exists to
65
+ * avoid. The estimate only has to rank controls and find the cut point — being
66
+ * a few px out moves the cut by at most one control, and the overflow catches it.
67
+ */
68
+ function estimateWidth(text: string, extra: number): number {
69
+ /**
70
+ * ~7.6px per character at the toolbar's font size, plus 76px of chrome:
71
+ * horizontal padding, the border, and the dropdown chevron. The chrome used
72
+ * to be 52, which under-priced every control by roughly a chevron's width —
73
+ * six of them then "fitted" a row they overflowed, and because they are flex
74
+ * children the row paid for it by squeezing labels to "D…" and "V…" rather
75
+ * than by shedding a control into the overflow.
76
+ *
77
+ * Deliberately generous: over-pricing sheds one control too early, which the
78
+ * "Mais" button absorbs invisibly. Under-pricing breaks the line.
79
+ */
80
+ return Math.round(text.length * 7.6) + 76 + extra;
81
+ }
82
+
83
+ /** Is a range bounded at either end? */
84
+ function isRangeSet(range: RangeValue | undefined): boolean {
85
+ return Boolean(range && (range.min !== undefined || range.max !== undefined));
86
+ }
87
+
88
+ /** The label a pill actually renders, which is what decides its width. */
89
+ function pillText<T extends Record<string, unknown>>(field: OverflowField<T>, values: string[]): string {
90
+ if (values.length === 0) return field.label;
91
+ if (values.length === 1) {
92
+ const option = field.pill?.options.find((entry) => entry.value === values[0]);
93
+ return `${field.label}: ${option?.label ?? values[0]}`;
94
+ }
95
+ return `${field.label}: ${values.length}`;
96
+ }
97
+
98
+ /** Every declared control, pills first, in the order the bar renders them. */
99
+ export function toOverflowFields<T extends Record<string, unknown>>(
100
+ fields: FilterFieldConfig<T>[],
101
+ rangeFields: RangeFieldConfig<T>[],
102
+ ): OverflowField<T>[] {
103
+ return [
104
+ ...fields.map((pill) => ({ id: pill.id, label: pill.label, group: "pill" as const, pill })),
105
+ ...rangeFields.map((range) => ({ id: range.id, label: range.label, group: "range" as const, range })),
106
+ ];
107
+ }
108
+
109
+ /** Fixed toolbar furniture the filters have to fit around, in px. */
110
+ const RESERVED = {
111
+ /** The search box never shrinks below this. */
112
+ search: 200,
113
+ /** The "N de N" counter. */
114
+ counter: 96,
115
+ /** Exibir + Exportar WITH their text labels. */
116
+ right: 216,
117
+ /** The same two controls as icons only — step 2 of the ladder. */
118
+ rightCompact: 96,
119
+ /** The "Mais" button, only charged when there IS an overflow. */
120
+ overflowButton: 104,
121
+ /** Gaps + the row's own padding. */
122
+ chrome: 64,
123
+ } as const;
124
+
125
+ /** The gap between two controls. */
126
+ const GAP = 8;
127
+
128
+ /** Observe the bar's width. Returns 0 until the first measurement lands. */
129
+ function useBarWidth(): { barRef: React.RefObject<HTMLDivElement | null>; width: number } {
130
+ const barRef = useRef<HTMLDivElement | null>(null);
131
+ const [width, setWidth] = useState(0);
132
+ useEffect(() => {
133
+ const element = barRef.current;
134
+ // jsdom has no ResizeObserver, and neither does an SSR pass — with no
135
+ // measurement the hook keeps every control inline, which is the honest
136
+ // fallback: nothing is hidden behind a menu nobody can see.
137
+ if (!element || typeof ResizeObserver === "undefined") return undefined;
138
+ const observer = new ResizeObserver((entries) => {
139
+ const entry = entries[0];
140
+ if (entry) setWidth(entry.contentRect.width);
141
+ });
142
+ observer.observe(element);
143
+ return () => observer.disconnect();
144
+ }, []);
145
+ return { barRef, width };
146
+ }
147
+
148
+ /** Is this control carrying a value the operator set? */
149
+ function isActiveField<T extends Record<string, unknown>>(
150
+ field: OverflowField<T>,
151
+ pills: Record<string, string[]>,
152
+ ranges: Record<string, RangeValue>,
153
+ ): boolean {
154
+ return field.group === "pill"
155
+ ? (pills[field.id]?.length ?? 0) > 0
156
+ : isRangeSet(ranges[field.id]);
157
+ }
158
+
159
+ /** Estimated rendered width of one control at its CURRENT value. */
160
+ function fieldWidth<T extends Record<string, unknown>>(
161
+ field: OverflowField<T>,
162
+ pills: Record<string, string[]>,
163
+ ranges: Record<string, RangeValue>,
164
+ ): number {
165
+ if (field.group === "pill") return estimateWidth(pillText(field, pills[field.id] ?? []), 0);
166
+ // A bounded range renders two number inputs beside its label.
167
+ return estimateWidth(field.label, isRangeSet(ranges[field.id]) ? 100 : 0);
168
+ }
169
+
170
+ /** What one split costs on the bar, so the ladder below can price the next step. */
171
+ type Split<T extends Record<string, unknown>> = {
172
+ inline: OverflowField<T>[];
173
+ overflow: OverflowField<T>[];
174
+ /** Width the surviving inline controls occupy, incl. the "Mais" button. */
175
+ used: number;
176
+ };
177
+
178
+ /** Step 1 — shed filter controls into the overflow. Pure. */
179
+ function splitFilters<T extends Record<string, unknown>>(
180
+ all: OverflowField<T>[],
181
+ pills: Record<string, string[]>,
182
+ ranges: Record<string, RangeValue>,
183
+ width: number,
184
+ ): Split<T> {
185
+ const none: OverflowField<T>[] = [];
186
+ const widthOf = (field: OverflowField<T>): number => fieldWidth(field, pills, ranges);
187
+ const cost = (fields: OverflowField<T>[]): number =>
188
+ fields.reduce((sum, field) => sum + widthOf(field) + GAP, 0);
189
+ const active = all.filter((field) => isActiveField(field, pills, ranges));
190
+ const idle = all.filter((field) => !isActiveField(field, pills, ranges));
191
+
192
+ // AN APPLIED FILTER NEVER HIDES — it is not merely ranked first, it is
193
+ // exempt. Applying a value LENGTHENS a control ("Método: Pix"), so ranking
194
+ // alone still loses the one filter the operator just set whenever it no
195
+ // longer fits on its own. The bar wraps; a filter you cannot see does not
196
+ // announce itself at all.
197
+ let available =
198
+ width - RESERVED.search - RESERVED.counter - RESERVED.right - RESERVED.chrome - cost(active);
199
+ if (idle.length === 0 || cost(idle) <= available) {
200
+ return { inline: all, overflow: none, used: cost(all) };
201
+ }
202
+
203
+ // There IS an overflow, so its button now costs room too.
204
+ available -= RESERVED.overflowButton;
205
+ const keep = new Set(active.map((field) => field.id));
206
+ for (const field of idle) {
207
+ const next = widthOf(field) + GAP;
208
+ if (next > available) break;
209
+ available -= next;
210
+ keep.add(field.id);
211
+ }
212
+ // Rendered in the DECLARED order, not the applied-first one: which controls
213
+ // are visible may change with the width, but the ones that stay must not
214
+ // reshuffle under the operator's cursor.
215
+ const inline = all.filter((field) => keep.has(field.id));
216
+ return {
217
+ inline,
218
+ overflow: all.filter((field) => !keep.has(field.id)),
219
+ used: cost(inline) + RESERVED.overflowButton,
220
+ };
221
+ }
222
+
223
+ /**
224
+ * THE DEGRADATION LADDER — cheapest loss first, and each rung taken only if
225
+ * the one before it did not free enough:
226
+ *
227
+ * 1. filter controls move into the "Mais" overflow (`splitFilters`)
228
+ * 2. Exibir / Exportar drop their text labels (`compactControls`)
229
+ * 3. the search shrinks toward its minimum (CSS: it is `flex: 1`)
230
+ * 4. the search itself collapses to an icon (`searchCollapsed`)
231
+ *
232
+ * Step 3 needs no flag — the search is a flex child, so it gives up width on
233
+ * its own until it hits `RESERVED.search`. The flags mark the two steps that
234
+ * change what is RENDERED rather than how wide it is.
235
+ *
236
+ * Measured against what is CURRENTLY on the bar, never a breakpoint: a
237
+ * two-filter page and a five-filter page would otherwise collapse at the same
238
+ * width, and only one of them would be right.
239
+ */
240
+ function computeSplit<T extends Record<string, unknown>>(
241
+ all: OverflowField<T>[],
242
+ pills: Record<string, string[]>,
243
+ ranges: Record<string, RangeValue>,
244
+ width: number,
245
+ ): Omit<OverflowSplit<T>, "barRef"> {
246
+ // Unmeasured (SSR, or jsdom without a ResizeObserver) ⇒ degrade nothing.
247
+ if (width === 0) {
248
+ return {
249
+ inline: all,
250
+ overflow: [],
251
+ compactControls: false,
252
+ searchCollapsed: false,
253
+ };
254
+ }
255
+ const { inline, overflow, used } = splitFilters(all, pills, ranges, width);
256
+ const base = width - used - RESERVED.chrome;
257
+
258
+ // Would the search still make its minimum with the labels on?
259
+ const compactControls = base - RESERVED.counter - RESERVED.right < RESERVED.search;
260
+ const rightCost = compactControls ? RESERVED.rightCompact : RESERVED.right;
261
+ // …and with them off?
262
+ const searchCollapsed = base - RESERVED.counter - rightCost < RESERVED.search;
263
+
264
+ return { inline, overflow, compactControls, searchCollapsed };
265
+ }
266
+
267
+ /**
268
+ * Split the declared filter controls into what fits and what goes behind
269
+ * "Mais", measured against the live toolbar width.
270
+ *
271
+ * Cached on a SIGNATURE rather than by `useMemo` over the inputs: hosts declare
272
+ * their fields inline, so `all` is a fresh array on every render while the
273
+ * configuration is unchanged, and a reference-keyed memo would never hit.
274
+ */
275
+ export function useFilterOverflow<T extends Record<string, unknown>>(
276
+ all: OverflowField<T>[],
277
+ pills: Record<string, string[]>,
278
+ ranges: Record<string, RangeValue>,
279
+ /**
280
+ * A filter control is open — hold the current answer until it closes.
281
+ *
282
+ * Applying a filter changes that control's LABEL ("Valor" becomes "Valor: ≥
283
+ * R$ 1"), which changes its measured width, which re-runs the whole split
284
+ * while its popover is open on top of it. Measured: setting a minimum moved
285
+ * the trigger 192px left, took the popover with it, and pushed two other
286
+ * controls into a newly-appeared "Mais 2" — so reaching for the second bound
287
+ * meant chasing the control across the row. Nobody sets a range that way.
288
+ *
289
+ * Frozen only for the duration: closing re-measures against the new labels,
290
+ * so the row still ends up correct, it just does not rearrange under a hand
291
+ * that is mid-edit.
292
+ */
293
+ frozen = false,
294
+ ): OverflowSplit<T> {
295
+ const { barRef, width } = useBarWidth();
296
+ const signature = JSON.stringify({ ids: all.map((field) => field.id), pills, ranges, width });
297
+ const cache = useRef<{ signature: string; split: ReturnType<typeof computeSplit<T>> } | null>(null);
298
+ if (cache.current === null || (!frozen && cache.current.signature !== signature)) {
299
+ cache.current = { signature, split: computeSplit(all, pills, ranges, width) };
300
+ }
301
+ return { ...cache.current.split, barRef };
302
+ }
@@ -11,6 +11,14 @@ import type {
11
11
  */
12
12
  interface ViewStateDescription {
13
13
  filters: string[];
14
+ /**
15
+ * The columns this view HIDES, not the ones it shows.
16
+ *
17
+ * A view that hides three of eleven columns listed eight names, which is a
18
+ * wall of text stating the default. What the operator needs to check before
19
+ * saving is what this view CHANGES — and for columns, the change is the
20
+ * hiding.
21
+ */
14
22
  columns: string[];
15
23
  sort: string[];
16
24
  }
@@ -41,13 +49,13 @@ export function describeViewState<T extends Record<string, unknown>>(
41
49
  }
42
50
 
43
51
  const hideable = columns.filter((col) => col.hideable !== false);
44
- const columnsShown = hideable
45
- .filter((col) => state.visibleColumns.includes(col.id))
52
+ const columnsHidden = hideable
53
+ .filter((col) => !state.visibleColumns.includes(col.id))
46
54
  .map((col) => columnLabel(columns, col.id));
47
55
 
48
56
  const sort = state.sortBy
49
57
  .filter((entry) => entry.dir)
50
58
  .map((entry) => `${columnLabel(columns, entry.id)} (${entry.dir === "asc" ? "crescente" : "decrescente"})`);
51
59
 
52
- return { filters, columns: columnsShown, sort };
60
+ return { filters, columns: columnsHidden, sort };
53
61
  }