@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.
- package/package.json +4 -4
- package/src/components/data-display/DataViews/DataViewsBoard.tsx +327 -0
- package/src/components/data-display/DataViews/DataViewsGrid.tsx +80 -77
- package/src/components/data-display/DataViews/DataViewsTableBase.tsx +178 -230
- package/src/components/data-display/DataViews/DeleteViewDialog.tsx +83 -0
- package/src/components/data-display/DataViews/SaveViewModal.tsx +140 -52
- package/src/components/data-display/DataViews/data-views-columns-tab.tsx +240 -0
- package/src/components/data-display/DataViews/data-views-columns.ts +129 -0
- package/src/components/data-display/DataViews/data-views-dirty.ts +62 -0
- package/src/components/data-display/DataViews/data-views-display-panel.tsx +328 -0
- package/src/components/data-display/DataViews/data-views-display-tabs.tsx +328 -0
- package/src/components/data-display/DataViews/data-views-empty.tsx +91 -0
- package/src/components/data-display/DataViews/data-views-export.tsx +260 -0
- package/src/components/data-display/DataViews/data-views-glyphs.tsx +103 -0
- package/src/components/data-display/DataViews/data-views-grid-bodies.tsx +340 -0
- package/src/components/data-display/DataViews/data-views-grid-parts.tsx +220 -242
- package/src/components/data-display/DataViews/data-views-inline-bar.tsx +187 -81
- package/src/components/data-display/DataViews/data-views-layout-context.tsx +215 -79
- package/src/components/data-display/DataViews/data-views-more-filters.tsx +300 -0
- package/src/components/data-display/DataViews/data-views-overflow.ts +302 -0
- package/src/components/data-display/DataViews/data-views-preview.ts +11 -3
- package/src/components/data-display/DataViews/data-views-range-pill.tsx +162 -92
- package/src/components/data-display/DataViews/data-views-range-values.ts +69 -0
- package/src/components/data-display/DataViews/data-views-scopes.tsx +356 -0
- package/src/components/data-display/DataViews/data-views-search.tsx +156 -0
- package/src/components/data-display/DataViews/data-views-select-all-strip.tsx +105 -0
- package/src/components/data-display/DataViews/data-views-selection.ts +50 -0
- package/src/components/data-display/DataViews/data-views-server-query.ts +75 -0
- package/src/components/data-display/DataViews/data-views-shell-toolbar.tsx +87 -0
- package/src/components/data-display/DataViews/data-views-table-parts.tsx +5 -0
- package/src/components/data-display/DataViews/data-views-toolbar.tsx +63 -48
- package/src/components/data-display/DataViews/data-views-types.ts +88 -9
- package/src/components/data-display/DataViews/data-views-view-nav.tsx +378 -0
- package/src/components/data-display/DataViews/index.ts +14 -1
- package/src/components/data-display/DataViews/use-data-views-state.ts +158 -134
- package/src/components/data-display/DataViews/use-saved-views-controller.ts +171 -0
- package/src/components/layout/ContentToolbar/ContentToolbar.tsx +86 -30
- package/src/components/layout/ContentToolbar/ContentToolbar.types.ts +25 -0
- package/src/components/layout/ContentToolbar/MultiSelectDropdown.tsx +6 -1
- package/src/components/utility/InstallPrompt/InstallPrompt.earlyCapture.ts +117 -0
- package/src/components/utility/InstallPrompt/InstallPrompt.md +52 -4
- package/src/components/utility/InstallPrompt/InstallPrompt.types.ts +19 -0
- package/src/components/utility/InstallPrompt/index.ts +8 -0
- 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
|
|
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:
|
|
60
|
+
return { filters, columns: columnsHidden, sort };
|
|
53
61
|
}
|