@12-apps/ui 1.5.1 → 1.6.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 +5 -5
- 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
|
@@ -10,59 +10,53 @@
|
|
|
10
10
|
* the panel is a column of stacked fields, this is a row of pills, and the only
|
|
11
11
|
* thing they share is the prop shape they are both driven by.
|
|
12
12
|
*/
|
|
13
|
-
import
|
|
14
|
-
import {
|
|
15
|
-
import { useRef, useState } from "react";
|
|
13
|
+
import { Box, Button, Chip, Typography } from "@mui/material";
|
|
14
|
+
import { useState } from "react";
|
|
16
15
|
|
|
17
16
|
import { MultiSelectDropdown } from "../../layout/ContentToolbar";
|
|
18
17
|
|
|
19
18
|
import type { GridFilterPanelProps } from "./data-views-filter-panel";
|
|
20
|
-
import {
|
|
19
|
+
import { MoreFilters } from "./data-views-more-filters";
|
|
20
|
+
import type { OverflowField, OverflowSplit } from "./data-views-overflow";
|
|
21
|
+
import { CollapsedSearch, InlineKeyword } from "./data-views-search";
|
|
22
|
+
import { RangePill } from "./data-views-range-pill";
|
|
23
|
+
import { isRangeSet, rangeChipLabel } from "./data-views-range-values";
|
|
24
|
+
import type { RangeValue } from "./data-views-types";
|
|
21
25
|
|
|
22
26
|
|
|
23
27
|
|
|
24
28
|
/* ── Inline (second-line) filter bar ─────────────────────────────────────── */
|
|
25
29
|
|
|
26
|
-
/**
|
|
27
|
-
function
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
/** The search, in whichever of its two forms the measurement asked for. */
|
|
31
|
+
function SearchSlot({
|
|
32
|
+
search,
|
|
33
|
+
onSearchChange,
|
|
34
|
+
collapsed,
|
|
35
|
+
expanded,
|
|
36
|
+
onOpen,
|
|
37
|
+
onEscape,
|
|
38
|
+
testIdPrefix,
|
|
31
39
|
}: {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
search: string;
|
|
41
|
+
onSearchChange: (value: string) => void;
|
|
42
|
+
collapsed: boolean;
|
|
43
|
+
expanded: boolean;
|
|
44
|
+
onOpen: () => void;
|
|
45
|
+
onEscape: () => void;
|
|
46
|
+
testIdPrefix: string;
|
|
35
47
|
}): React.JSX.Element {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (draft !== value) setDraft(value);
|
|
48
|
+
if (collapsed) {
|
|
49
|
+
return (
|
|
50
|
+
<CollapsedSearch active={search !== ""} onOpen={onOpen} testId={`${testIdPrefix}-search-all`} />
|
|
51
|
+
);
|
|
41
52
|
}
|
|
42
|
-
const commit = (): void => {
|
|
43
|
-
if (draft !== value) onChange(draft);
|
|
44
|
-
};
|
|
45
53
|
return (
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (event.key !== "Enter") return;
|
|
53
|
-
event.preventDefault();
|
|
54
|
-
commit();
|
|
55
|
-
}}
|
|
56
|
-
placeholder="Buscar…"
|
|
57
|
-
inputProps={{ "aria-label": "Buscar em todas as colunas", "data-testid": testId }}
|
|
58
|
-
InputProps={{
|
|
59
|
-
startAdornment: (
|
|
60
|
-
<InputAdornment position="start">
|
|
61
|
-
<SearchIcon sx={{ fontSize: 16, color: "text.secondary" }} />
|
|
62
|
-
</InputAdornment>
|
|
63
|
-
),
|
|
64
|
-
}}
|
|
65
|
-
sx={{ minWidth: 220 }}
|
|
54
|
+
<InlineKeyword
|
|
55
|
+
value={search}
|
|
56
|
+
onChange={onSearchChange}
|
|
57
|
+
testId={`${testIdPrefix}-search-all`}
|
|
58
|
+
autoFocus={expanded}
|
|
59
|
+
onEscape={onEscape}
|
|
66
60
|
/>
|
|
67
61
|
);
|
|
68
62
|
}
|
|
@@ -81,7 +75,10 @@ type InlineFilterBarProps<T extends Record<string, unknown>> = Pick<
|
|
|
81
75
|
| "onChangeRange"
|
|
82
76
|
| "onClearField"
|
|
83
77
|
| "onClearAll"
|
|
84
|
-
|
|
78
|
+
> & {
|
|
79
|
+
/** The measured degradation state, computed once in `GridShell`. */
|
|
80
|
+
split: OverflowSplit<T>;
|
|
81
|
+
};
|
|
85
82
|
|
|
86
83
|
/** One removable active-filter chip: a search term, a pill value or a window. */
|
|
87
84
|
interface ActiveChip {
|
|
@@ -147,7 +144,151 @@ function activeChips<T extends Record<string, unknown>>({
|
|
|
147
144
|
* and — when anything is applied — a row of removable "active filter" chips. Range
|
|
148
145
|
* filters are not shown here (the slide-in panel keeps those).
|
|
149
146
|
*/
|
|
150
|
-
|
|
147
|
+
/**
|
|
148
|
+
* One filter control on the bar: a multi-select pill or a min/max range. The
|
|
149
|
+
* two are the same thing from the bar's point of view — a labelled control that
|
|
150
|
+
* writes into the view state — so which one to build is decided here rather
|
|
151
|
+
* than in the bar's own render.
|
|
152
|
+
*/
|
|
153
|
+
function InlineControl<T extends Record<string, unknown>>({
|
|
154
|
+
field,
|
|
155
|
+
pills,
|
|
156
|
+
ranges,
|
|
157
|
+
onTogglePill,
|
|
158
|
+
onChangeRange,
|
|
159
|
+
onClearField,
|
|
160
|
+
onOpenChange,
|
|
161
|
+
testIdPrefix,
|
|
162
|
+
}: {
|
|
163
|
+
field: OverflowField<T>;
|
|
164
|
+
onOpenChange?: (open: boolean) => void;
|
|
165
|
+
pills: Record<string, string[]>;
|
|
166
|
+
ranges: Record<string, RangeValue>;
|
|
167
|
+
onTogglePill: (fieldId: string, value: string, checked: boolean) => void;
|
|
168
|
+
onChangeRange: (fieldId: string, range: RangeValue) => void;
|
|
169
|
+
onClearField: (fieldId: string) => void;
|
|
170
|
+
testIdPrefix: string;
|
|
171
|
+
}): React.JSX.Element | null {
|
|
172
|
+
if (field.group === "pill" && field.pill) {
|
|
173
|
+
return (
|
|
174
|
+
<MultiSelectDropdown
|
|
175
|
+
label={field.pill.label}
|
|
176
|
+
options={field.pill.options}
|
|
177
|
+
selected={new Set(pills[field.id] ?? [])}
|
|
178
|
+
onToggle={(value, checked) => onTogglePill(field.id, value, checked)}
|
|
179
|
+
onClear={() => onClearField(field.id)}
|
|
180
|
+
allLabel="Todas"
|
|
181
|
+
searchable={field.pill.searchEnabled ? true : undefined}
|
|
182
|
+
searchPlaceholder="Buscar…"
|
|
183
|
+
noResultsLabel="Nenhum resultado"
|
|
184
|
+
layout="pill"
|
|
185
|
+
onOpenChange={onOpenChange}
|
|
186
|
+
data-testid={`${testIdPrefix}-filter-${field.id}`}
|
|
187
|
+
/>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (field.group === "range" && field.range) {
|
|
191
|
+
return (
|
|
192
|
+
<RangePill
|
|
193
|
+
field={field.range}
|
|
194
|
+
value={ranges[field.id] ?? {}}
|
|
195
|
+
onChange={(range) => onChangeRange(field.id, range)}
|
|
196
|
+
onOpenChange={onOpenChange}
|
|
197
|
+
testIdPrefix={testIdPrefix}
|
|
198
|
+
/>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The filter CONTROLS alone — search, the fields that fit, and "Mais N" for the
|
|
206
|
+
* ones that don't — with no row chrome of its own, because it is rendered ON
|
|
207
|
+
* the toolbar line rather than under it (`ContentToolbar`'s `leadingControls`).
|
|
208
|
+
*
|
|
209
|
+
* The measurement driving the collapse lives on the toolbar ROW, not here —
|
|
210
|
+
* see the note on `split` below.
|
|
211
|
+
*/
|
|
212
|
+
export function InlineFilterControls<T extends Record<string, unknown>>({
|
|
213
|
+
testIdPrefix,
|
|
214
|
+
pills,
|
|
215
|
+
ranges,
|
|
216
|
+
search,
|
|
217
|
+
onSearchChange,
|
|
218
|
+
onTogglePill,
|
|
219
|
+
onChangeRange,
|
|
220
|
+
onClearField,
|
|
221
|
+
split,
|
|
222
|
+
onControlOpenChange,
|
|
223
|
+
}: Omit<InlineFilterBarProps<T>, "fields" | "rangeFields" | "onClearAll"> & {
|
|
224
|
+
/** Reports a control opening/closing so the shell can freeze the measurement. */
|
|
225
|
+
onControlOpenChange?: (open: boolean) => void;
|
|
226
|
+
}): React.JSX.Element {
|
|
227
|
+
// MEASURED, not breakpointed — and measured ONCE, in the shell, because the
|
|
228
|
+
// toolbar around it needs the same answer to drop its labels. The measured
|
|
229
|
+
// element is the whole toolbar ROW (see `GridToolbar`), not this cluster:
|
|
230
|
+
// `RESERVED` prices the counter and Exibir/Exportar, so measuring only the
|
|
231
|
+
// controls would charge for that furniture a second time and collapse the
|
|
232
|
+
// ladder while the row still had hundreds of free pixels.
|
|
233
|
+
const { inline, overflow, searchCollapsed } = split;
|
|
234
|
+
// Expanded by the operator: a collapsed search that was CLICKED stays open
|
|
235
|
+
// until Escape, so typing is never interrupted by a re-measure.
|
|
236
|
+
const [searchOpen, setSearchOpen] = useState(false);
|
|
237
|
+
const showBox = !searchCollapsed || searchOpen;
|
|
238
|
+
return (
|
|
239
|
+
<Box
|
|
240
|
+
data-testid={`${testIdPrefix}-inline-filters`}
|
|
241
|
+
sx={{
|
|
242
|
+
display: "flex",
|
|
243
|
+
flexWrap: "nowrap",
|
|
244
|
+
alignItems: "center",
|
|
245
|
+
gap: 1,
|
|
246
|
+
minWidth: 0,
|
|
247
|
+
// No squeezing: if the estimate over-packs, the row must overflow (and
|
|
248
|
+
// the next measure sheds a control), not shrink labels to an ellipsis.
|
|
249
|
+
"& > *": { flexShrink: 0 },
|
|
250
|
+
}}
|
|
251
|
+
>
|
|
252
|
+
<SearchSlot
|
|
253
|
+
search={search}
|
|
254
|
+
onSearchChange={onSearchChange}
|
|
255
|
+
collapsed={!showBox}
|
|
256
|
+
expanded={searchOpen}
|
|
257
|
+
onOpen={() => setSearchOpen(true)}
|
|
258
|
+
onEscape={() => setSearchOpen(false)}
|
|
259
|
+
testIdPrefix={testIdPrefix}
|
|
260
|
+
/>
|
|
261
|
+
{inline.map((field) => (
|
|
262
|
+
<InlineControl
|
|
263
|
+
key={field.id}
|
|
264
|
+
field={field}
|
|
265
|
+
onOpenChange={onControlOpenChange}
|
|
266
|
+
pills={pills}
|
|
267
|
+
ranges={ranges}
|
|
268
|
+
onTogglePill={onTogglePill}
|
|
269
|
+
onChangeRange={onChangeRange}
|
|
270
|
+
onClearField={onClearField}
|
|
271
|
+
testIdPrefix={testIdPrefix}
|
|
272
|
+
/>
|
|
273
|
+
))}
|
|
274
|
+
<MoreFilters
|
|
275
|
+
fields={overflow}
|
|
276
|
+
pills={pills}
|
|
277
|
+
ranges={ranges}
|
|
278
|
+
onTogglePill={onTogglePill}
|
|
279
|
+
onChangeRange={onChangeRange}
|
|
280
|
+
testIdPrefix={testIdPrefix}
|
|
281
|
+
/>
|
|
282
|
+
</Box>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The applied-filter chips, on their own line under the toolbar. Split from the
|
|
288
|
+
* controls because the two no longer share a row: the controls sit on the
|
|
289
|
+
* toolbar, and this renders nothing at all until something is applied.
|
|
290
|
+
*/
|
|
291
|
+
export function InlineFilterChips<T extends Record<string, unknown>>({
|
|
151
292
|
testIdPrefix,
|
|
152
293
|
search,
|
|
153
294
|
fields,
|
|
@@ -157,9 +298,8 @@ export function InlineFilterBar<T extends Record<string, unknown>>({
|
|
|
157
298
|
onSearchChange,
|
|
158
299
|
onTogglePill,
|
|
159
300
|
onChangeRange,
|
|
160
|
-
onClearField,
|
|
161
301
|
onClearAll,
|
|
162
|
-
}: InlineFilterBarProps<T>): React.JSX.Element {
|
|
302
|
+
}: Omit<InlineFilterBarProps<T>, "split" | "onClearField">): React.JSX.Element | null {
|
|
163
303
|
const chips = activeChips({
|
|
164
304
|
search,
|
|
165
305
|
fields,
|
|
@@ -170,44 +310,10 @@ export function InlineFilterBar<T extends Record<string, unknown>>({
|
|
|
170
310
|
onTogglePill,
|
|
171
311
|
onChangeRange,
|
|
172
312
|
});
|
|
313
|
+
if (chips.length === 0) return null;
|
|
173
314
|
return (
|
|
174
|
-
<Box
|
|
175
|
-
|
|
176
|
-
sx={{ display: "flex", flexDirection: "column", gap: 1.5, py: 1.5 }}
|
|
177
|
-
>
|
|
178
|
-
<Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1.5 }}>
|
|
179
|
-
<InlineKeyword value={search} onChange={onSearchChange} testId={`${testIdPrefix}-search-all`} />
|
|
180
|
-
{fields.map((field) => (
|
|
181
|
-
<MultiSelectDropdown
|
|
182
|
-
key={field.id}
|
|
183
|
-
label={field.label}
|
|
184
|
-
options={field.options}
|
|
185
|
-
selected={new Set(pills[field.id] ?? [])}
|
|
186
|
-
onToggle={(value, checked) => onTogglePill(field.id, value, checked)}
|
|
187
|
-
onClear={() => onClearField(field.id)}
|
|
188
|
-
allLabel="Todas"
|
|
189
|
-
searchable={field.searchEnabled ? true : undefined}
|
|
190
|
-
searchPlaceholder="Buscar…"
|
|
191
|
-
noResultsLabel="Nenhum resultado"
|
|
192
|
-
layout="pill"
|
|
193
|
-
data-testid={`${testIdPrefix}-filter-${field.id}`}
|
|
194
|
-
/>
|
|
195
|
-
))}
|
|
196
|
-
{rangeFields.map((field) => (
|
|
197
|
-
<RangePill
|
|
198
|
-
key={field.id}
|
|
199
|
-
field={field}
|
|
200
|
-
value={ranges[field.id] ?? {}}
|
|
201
|
-
onChange={(range) => onChangeRange(field.id, range)}
|
|
202
|
-
testIdPrefix={testIdPrefix}
|
|
203
|
-
/>
|
|
204
|
-
))}
|
|
205
|
-
</Box>
|
|
206
|
-
<ActiveChipRow
|
|
207
|
-
chips={chips}
|
|
208
|
-
testIdPrefix={testIdPrefix}
|
|
209
|
-
onClearAll={onClearAll}
|
|
210
|
-
/>
|
|
315
|
+
<Box sx={{ py: 1.5 }}>
|
|
316
|
+
<ActiveChipRow chips={chips} testIdPrefix={testIdPrefix} onClearAll={onClearAll} />
|
|
211
317
|
</Box>
|
|
212
318
|
);
|
|
213
319
|
}
|
|
@@ -1,30 +1,36 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
|
3
|
+
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
4
4
|
|
|
5
5
|
import GridViewOutlinedIcon from "@mui/icons-material/GridViewOutlined";
|
|
6
|
-
import KeyboardArrowDownRoundedIcon from "@mui/icons-material/KeyboardArrowDownRounded";
|
|
7
|
-
import ViewListOutlinedIcon from "@mui/icons-material/ViewListOutlined";
|
|
8
6
|
|
|
9
|
-
import { Button } from "../../form/Button";
|
|
10
7
|
import { Slider } from "../../form/Slider";
|
|
11
8
|
import { Box } from "../../../mui/Box";
|
|
12
|
-
|
|
9
|
+
|
|
10
|
+
import { DATA_VIEWS_LAYOUTS, type DataViewsLayout } from "./data-views-types";
|
|
13
11
|
|
|
14
12
|
/**
|
|
15
|
-
* The DataViews layout (view type): the dense "Tabela" grid,
|
|
16
|
-
* cards
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
13
|
+
* The DataViews layout (view type): the dense "Tabela" grid, the "Grade" of
|
|
14
|
+
* cards, the "Lista" of full-width rows, or the "Quadro" board of state columns.
|
|
15
|
+
* Shared across the toolbar (the toggle) and the body (the selector) via context
|
|
16
|
+
* so a single source of truth drives what renders — each layout only appears
|
|
17
|
+
* when the table opts into it (`renderCard` for the grade, `renderListRow` for
|
|
18
|
+
* the lista, `board` + `renderCard` for the quadro).
|
|
20
19
|
*/
|
|
21
|
-
export type DataViewsLayout
|
|
20
|
+
export type { DataViewsLayout };
|
|
22
21
|
|
|
23
22
|
interface DataViewsLayoutValue {
|
|
24
23
|
layout: DataViewsLayout;
|
|
25
24
|
setLayout: (layout: DataViewsLayout) => void;
|
|
25
|
+
/** How much air each record gets — see {@link DataViewsDensity}. */
|
|
26
|
+
density: DataViewsDensity;
|
|
27
|
+
setDensity: (density: DataViewsDensity) => void;
|
|
26
28
|
/** Whether the cards layout is available (a `renderCard` was supplied). */
|
|
27
29
|
canUseCards: boolean;
|
|
30
|
+
/** Whether the list layout is available (a `renderListRow` was supplied). */
|
|
31
|
+
canUseList: boolean;
|
|
32
|
+
/** Whether the board layout is available (a `board` config AND a `renderCard`). */
|
|
33
|
+
canUseBoard: boolean;
|
|
28
34
|
/** Card-size zoom (0–100); drives the card grid's min column width. */
|
|
29
35
|
zoom: number;
|
|
30
36
|
setZoom: (zoom: number) => void;
|
|
@@ -39,6 +45,49 @@ const BASE_CARD_WIDTH = 180;
|
|
|
39
45
|
/** The size multiplier the zoom slider spans (0.75× to 2×). */
|
|
40
46
|
const CARD_SCALE_RANGE = { min: 0.75, max: 2 } as const;
|
|
41
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Where the user's layout preference is remembered. ONE key for every DataViews
|
|
50
|
+
* table in the app, on purpose: picking "Grade" on Produtos is a statement about
|
|
51
|
+
* how this operator likes to read lists, not about that one screen. Every other
|
|
52
|
+
* screen that CAN render cards then opens in cards, and one that cannot falls
|
|
53
|
+
* back without forgetting the preference.
|
|
54
|
+
*/
|
|
55
|
+
const LAYOUT_STORAGE_KEY = "dataviews:layout";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The density preference, remembered app-wide for the same reason as the layout:
|
|
59
|
+
* an operator who wants tight rows wants them on every list, not on one.
|
|
60
|
+
*/
|
|
61
|
+
const DENSITY_STORAGE_KEY = "dataviews:density";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* How much room each record gets. It means different things per layout, and
|
|
65
|
+
* that is the point — one control, phrased in the terms of what is on screen:
|
|
66
|
+
*
|
|
67
|
+
* - table / list → row height ("Baixa / Média / Alta")
|
|
68
|
+
* - cards → how many fit per row ("Muitos / Médio / Poucos")
|
|
69
|
+
*
|
|
70
|
+
* The board is unaffected: its cards are already sized by the zoom slider.
|
|
71
|
+
*/
|
|
72
|
+
export type DataViewsDensity = "compact" | "cozy" | "comfortable";
|
|
73
|
+
|
|
74
|
+
/** Every density this build knows — the guard's single source. */
|
|
75
|
+
const DENSITIES: readonly DataViewsDensity[] = ["compact", "cozy", "comfortable"];
|
|
76
|
+
|
|
77
|
+
/** Vertical padding (theme spacing units) per density, for a table or list row. */
|
|
78
|
+
export const DENSITY_ROW_PADDING: Record<DataViewsDensity, number> = {
|
|
79
|
+
compact: 0.25,
|
|
80
|
+
cozy: 0.75,
|
|
81
|
+
comfortable: 1.5,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Card grid columns per density — how many cards land on one row. */
|
|
85
|
+
export const DENSITY_CARD_COLUMNS: Record<DataViewsDensity, number> = {
|
|
86
|
+
compact: 5,
|
|
87
|
+
cozy: 4,
|
|
88
|
+
comfortable: 3,
|
|
89
|
+
};
|
|
90
|
+
|
|
42
91
|
/**
|
|
43
92
|
* Map the 0–100 zoom to the card SIZE multiplier. This is the single knob: the
|
|
44
93
|
* card's width is {@link BASE_CARD_WIDTH} × scale and its padding + typography
|
|
@@ -56,25 +105,159 @@ export function cardMinWidthForZoom(zoom: number): number {
|
|
|
56
105
|
return Math.round(BASE_CARD_WIDTH * cardScaleForZoom(zoom));
|
|
57
106
|
}
|
|
58
107
|
|
|
108
|
+
/** Which layouts this table can actually render. */
|
|
109
|
+
interface LayoutAvailability {
|
|
110
|
+
canUseCards: boolean;
|
|
111
|
+
canUseList: boolean;
|
|
112
|
+
canUseBoard: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Is this a layout string this build knows? Guards whatever is in storage. */
|
|
116
|
+
function isLayout(value: unknown): value is DataViewsLayout {
|
|
117
|
+
return DATA_VIEWS_LAYOUTS.includes(value as DataViewsLayout);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Is this a density this build knows? Guards whatever is in storage. */
|
|
121
|
+
function isDensity(value: unknown): value is DataViewsDensity {
|
|
122
|
+
return DENSITIES.includes(value as DataViewsDensity);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The remembered density, or undefined (no window, no value, junk). */
|
|
126
|
+
function readStoredDensity(): DataViewsDensity | undefined {
|
|
127
|
+
try {
|
|
128
|
+
if (typeof window === "undefined") return undefined;
|
|
129
|
+
const stored = window.localStorage.getItem(DENSITY_STORAGE_KEY);
|
|
130
|
+
return isDensity(stored) ? stored : undefined;
|
|
131
|
+
} catch {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
59
136
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* `
|
|
137
|
+
* Pin a layout to one this table can render. "Tabela" is always available, so it
|
|
138
|
+
* is the floor: a page with no `renderCard` never renders a cards body, one with
|
|
139
|
+
* no `renderListRow` never renders a list, and one with no `board` config never
|
|
140
|
+
* renders a board — whatever is stored, saved in a view, or requested.
|
|
141
|
+
*/
|
|
142
|
+
function pinLayout(layout: DataViewsLayout, available: LayoutAvailability): DataViewsLayout {
|
|
143
|
+
if (layout === "cards") return available.canUseCards ? "cards" : "table";
|
|
144
|
+
if (layout === "list") return available.canUseList ? "list" : "table";
|
|
145
|
+
if (layout === "board") return available.canUseBoard ? "board" : "table";
|
|
146
|
+
return "table";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The stored cross-screen preference, or undefined (no window, no value, junk). */
|
|
150
|
+
function readStoredLayout(): DataViewsLayout | undefined {
|
|
151
|
+
// Guarded rather than assumed: this module is also imported in Node (tests,
|
|
152
|
+
// any SSR host), and a `localStorage` access can throw outright under a
|
|
153
|
+
// restrictive privacy setting — a thrown read must not take the grid with it.
|
|
154
|
+
try {
|
|
155
|
+
if (typeof window === "undefined") return undefined;
|
|
156
|
+
const stored = window.localStorage.getItem(LAYOUT_STORAGE_KEY);
|
|
157
|
+
return isLayout(stored) ? stored : undefined;
|
|
158
|
+
} catch {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Remember a preference for every other screen. A failed write is not an error. */
|
|
164
|
+
function writeStored(key: string, value: string): void {
|
|
165
|
+
try {
|
|
166
|
+
if (typeof window === "undefined") return;
|
|
167
|
+
window.localStorage.setItem(key, value);
|
|
168
|
+
} catch {
|
|
169
|
+
// Private mode / quota / disabled storage: the preference simply does not
|
|
170
|
+
// survive the session. Nothing about the current view depends on it.
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface LayoutProviderProps extends Partial<LayoutAvailability> {
|
|
175
|
+
/** Which layout to show first when the user has expressed no preference. */
|
|
176
|
+
defaultLayout?: DataViewsLayout;
|
|
177
|
+
/**
|
|
178
|
+
* The layout an APPLIED SAVED VIEW pins, when it stored one. It wins over the
|
|
179
|
+
* remembered preference for as long as that view is applied — the view is a
|
|
180
|
+
* snapshot someone chose to keep — but it does NOT overwrite the preference:
|
|
181
|
+
* only the toggle does that, because only the toggle is the user saying so.
|
|
182
|
+
*/
|
|
183
|
+
viewLayout?: DataViewsLayout;
|
|
184
|
+
/**
|
|
185
|
+
* Ignore — and stop writing — the remembered cross-screen preference, making
|
|
186
|
+
* `defaultLayout` authoritative for this table.
|
|
187
|
+
*
|
|
188
|
+
* For a table that exists to SHOW a particular layout rather than to be
|
|
189
|
+
* browsed: a Storybook story called "Board", a docs example, a screenshot
|
|
190
|
+
* harness. Without it the remembered preference wins, so opening "Board"
|
|
191
|
+
* after having clicked Tabela anywhere renders a table — the demonstration
|
|
192
|
+
* silently showing the wrong thing, and a shared link doing it for everyone
|
|
193
|
+
* whose preference differs.
|
|
194
|
+
*
|
|
195
|
+
* A real screen leaves this off: remembering how someone likes to read
|
|
196
|
+
* lists is the point.
|
|
197
|
+
*/
|
|
198
|
+
ignoreStoredLayout?: boolean;
|
|
199
|
+
children: ReactNode;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Provides the view-type state, seeded from (in order) the applied saved view's
|
|
204
|
+
* layout, the user's remembered cross-screen preference, then `defaultLayout` —
|
|
205
|
+
* each pinned to what this table can actually render.
|
|
63
206
|
*/
|
|
64
207
|
export function DataViewsLayoutProvider({
|
|
65
|
-
canUseCards,
|
|
208
|
+
canUseCards = false,
|
|
209
|
+
canUseList = false,
|
|
210
|
+
canUseBoard = false,
|
|
66
211
|
defaultLayout = "table",
|
|
212
|
+
viewLayout,
|
|
213
|
+
ignoreStoredLayout = false,
|
|
67
214
|
children,
|
|
68
|
-
}: {
|
|
69
|
-
canUseCards
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
215
|
+
}: LayoutProviderProps): React.JSX.Element {
|
|
216
|
+
const availability = { canUseCards, canUseList, canUseBoard };
|
|
217
|
+
const [layout, setLayoutState] = useState<DataViewsLayout>(() =>
|
|
218
|
+
pinLayout(
|
|
219
|
+
viewLayout ?? (ignoreStoredLayout ? undefined : readStoredLayout()) ?? defaultLayout,
|
|
220
|
+
availability,
|
|
221
|
+
),
|
|
222
|
+
);
|
|
223
|
+
const [density, setDensityState] = useState<DataViewsDensity>(
|
|
224
|
+
() => (ignoreStoredLayout ? undefined : readStoredDensity()) ?? "cozy",
|
|
225
|
+
);
|
|
74
226
|
const [zoom, setZoom] = useState(DEFAULT_ZOOM);
|
|
227
|
+
// A newly applied view carrying a layout switches to it (its reference-change
|
|
228
|
+
// is the signal, exactly as `appliedState` works for the filters).
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (viewLayout) setLayoutState(pinLayout(viewLayout, availability));
|
|
231
|
+
// Availability is derived from props that do not change for a mounted table.
|
|
232
|
+
}, [viewLayout]);
|
|
233
|
+
// Pin again on read: `canUseCards`/`canUseBoard` can flip for a mounted table
|
|
234
|
+
// (a page that supplies `renderCard` only once its card data has loaded), and
|
|
235
|
+
// a stale "cards" would otherwise render a cards body with no renderer.
|
|
236
|
+
const effective = pinLayout(layout, availability);
|
|
237
|
+
|
|
75
238
|
const value = useMemo<DataViewsLayoutValue>(
|
|
76
|
-
() => ({
|
|
77
|
-
|
|
239
|
+
() => ({
|
|
240
|
+
layout: effective,
|
|
241
|
+
setLayout: (next: DataViewsLayout) => {
|
|
242
|
+
const pinned = pinLayout(next, availability);
|
|
243
|
+
setLayoutState(pinned);
|
|
244
|
+
// Remember the user's own choice across every screen — see LAYOUT_STORAGE_KEY.
|
|
245
|
+
// A table pinned to a layout does not write: it would teach the
|
|
246
|
+
// preference a value the operator never chose.
|
|
247
|
+
if (!ignoreStoredLayout) writeStored(LAYOUT_STORAGE_KEY, pinned);
|
|
248
|
+
},
|
|
249
|
+
density,
|
|
250
|
+
setDensity: (next: DataViewsDensity) => {
|
|
251
|
+
setDensityState(next);
|
|
252
|
+
if (!ignoreStoredLayout) writeStored(DENSITY_STORAGE_KEY, next);
|
|
253
|
+
},
|
|
254
|
+
canUseCards,
|
|
255
|
+
canUseList,
|
|
256
|
+
canUseBoard,
|
|
257
|
+
zoom,
|
|
258
|
+
setZoom,
|
|
259
|
+
}),
|
|
260
|
+
[effective, density, canUseCards, canUseList, canUseBoard, zoom],
|
|
78
261
|
);
|
|
79
262
|
return <DataViewsLayoutContext.Provider value={value}>{children}</DataViewsLayoutContext.Provider>;
|
|
80
263
|
}
|
|
@@ -88,65 +271,18 @@ export function useDataViewsLayout(): DataViewsLayoutValue {
|
|
|
88
271
|
return ctx;
|
|
89
272
|
}
|
|
90
273
|
|
|
91
|
-
/** The layout options, in menu order. */
|
|
92
|
-
const GRADE_OPTION = { value: "cards" as const, label: "Grade", Icon: GridViewOutlinedIcon };
|
|
93
|
-
const TABELA_OPTION = { value: "table" as const, label: "Tabela", Icon: ViewListOutlinedIcon };
|
|
94
|
-
const LAYOUT_OPTIONS = [GRADE_OPTION, TABELA_OPTION];
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* The layout selector for the toolbar — a muted dropdown icon-button showing the
|
|
98
|
-
* active view's icon + a chevron, opening a menu of Grade/Tabela with a check on
|
|
99
|
-
* the active one. Mirrors the other toolbar dropdowns (Columns / Sort By) rather
|
|
100
|
-
* than a pair of buttons. Renders nothing when the table has no cards layout, so
|
|
101
|
-
* pages without a `renderCard` are unaffected.
|
|
102
|
-
*/
|
|
103
|
-
export function DataViewsLayoutToggle({ testIdPrefix }: { testIdPrefix: string }): React.JSX.Element | null {
|
|
104
|
-
const { layout, setLayout, canUseCards } = useDataViewsLayout();
|
|
105
|
-
if (!canUseCards) return null;
|
|
106
|
-
|
|
107
|
-
const active = layout === "cards" ? GRADE_OPTION : TABELA_OPTION;
|
|
108
|
-
const ActiveIcon = active.Icon;
|
|
109
|
-
|
|
110
|
-
return (
|
|
111
|
-
<DropdownMenu
|
|
112
|
-
size="sm"
|
|
113
|
-
items={LAYOUT_OPTIONS.map((option) => ({
|
|
114
|
-
id: option.value,
|
|
115
|
-
// A check on the active option; the icon stays leading. `color` highlights
|
|
116
|
-
// the active row. NOTE: DropdownMenu drops onClick for items with a custom
|
|
117
|
-
// `component`, so keep to label/icon/onClick here.
|
|
118
|
-
label: option.value === layout ? `${option.label} ✓` : option.label,
|
|
119
|
-
icon: <option.Icon fontSize="small" />,
|
|
120
|
-
color: option.value === layout ? ("primary" as const) : undefined,
|
|
121
|
-
onClick: () => setLayout(option.value),
|
|
122
|
-
}))}
|
|
123
|
-
trigger={
|
|
124
|
-
<Button
|
|
125
|
-
variant="text"
|
|
126
|
-
size="sm"
|
|
127
|
-
color="secondary"
|
|
128
|
-
aria-label={`Exibição: ${active.label}`}
|
|
129
|
-
dataTestId={`${testIdPrefix}-layout-toggle`}
|
|
130
|
-
>
|
|
131
|
-
<Box component="span" sx={{ display: "inline-flex", alignItems: "center", gap: 0.25 }}>
|
|
132
|
-
<ActiveIcon fontSize="small" />
|
|
133
|
-
<KeyboardArrowDownRoundedIcon fontSize="small" />
|
|
134
|
-
</Box>
|
|
135
|
-
</Button>
|
|
136
|
-
}
|
|
137
|
-
/>
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
274
|
/**
|
|
142
|
-
* Card-size slider for the toolbar — shown
|
|
143
|
-
* mirroring the tabwoah view-selector's zoom.
|
|
144
|
-
*
|
|
145
|
-
*
|
|
275
|
+
* Card-size slider for the toolbar — shown in the CARD-BASED layouts ("Grade"
|
|
276
|
+
* and "Quadro"), mirroring the tabwoah view-selector's zoom. The board reuses
|
|
277
|
+
* the same `renderCard`, so the same knob scales both.
|
|
278
|
+
*
|
|
279
|
+
* Deliberately ABSENT in "Lista" and "Tabela" (FUT-733): a list row is
|
|
280
|
+
* full-width and sized by its content, so a card-size multiplier has nothing to
|
|
281
|
+
* act on — a slider that moved and changed nothing would be worse than none.
|
|
146
282
|
*/
|
|
147
283
|
export function DataViewsZoomSlider({ testIdPrefix }: { testIdPrefix: string }): React.JSX.Element | null {
|
|
148
|
-
const { layout,
|
|
149
|
-
if (
|
|
284
|
+
const { layout, zoom, setZoom } = useDataViewsLayout();
|
|
285
|
+
if (layout !== "cards" && layout !== "board") return null;
|
|
150
286
|
return (
|
|
151
287
|
<Box
|
|
152
288
|
sx={{
|