@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
@@ -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 SearchIcon from '@mui/icons-material/Search';
14
- import { Box, Button, Chip, InputAdornment, TextField, Typography } from "@mui/material";
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 { isRangeSet, RangePill, rangeChipLabel } from "./data-views-range-pill";
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
- /** Compact keyword box for the inline bar: commits on Enter/blur (like the panel). */
27
- function InlineKeyword({
28
- value,
29
- onChange,
30
- testId,
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
- value: string;
33
- onChange: (value: string) => void;
34
- testId?: string;
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
- const [draft, setDraft] = useState(value);
37
- const prev = useRef(value);
38
- if (prev.current !== value) {
39
- prev.current = value;
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
- <TextField
47
- size="small"
48
- value={draft}
49
- onChange={(event) => setDraft(event.target.value)}
50
- onBlur={commit}
51
- onKeyDown={(event) => {
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
- export function InlineFilterBar<T extends Record<string, unknown>>({
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
- data-testid={`${testIdPrefix}-inline-filters`}
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
- import { DropdownMenu } from "../../navigation/DropdownMenu";
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, or the "Grade" of
16
- * cards. Shared across the toolbar (the toggle) and the body (the selector) via
17
- * context so a single source of truth drives what renders — the toggle only
18
- * appears when the table opts into cards by supplying a `renderCard` (each entity
19
- * implements its own card). Reusable feature of the DataViews machinery.
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 = "cards" | "table";
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
- * Provides the view-type state. When cards aren't available the layout is pinned
61
- * to "table" regardless, so a consumer never renders a cards body without a
62
- * `renderCard`.
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: boolean;
70
- defaultLayout?: DataViewsLayout;
71
- children: ReactNode;
72
- }): React.JSX.Element {
73
- const [layout, setLayout] = useState<DataViewsLayout>(canUseCards ? defaultLayout : "table");
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
- () => ({ layout: canUseCards ? layout : "table", setLayout, canUseCards, zoom, setZoom }),
77
- [layout, canUseCards, zoom],
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 ONLY in the cards ("Grade") layout,
143
- * mirroring the tabwoah view-selector's zoom. Adjusts the card grid's min column
144
- * width so the shopper can pack more/larger cards. Renders nothing in the table
145
- * layout or when cards aren't available.
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, canUseCards, zoom, setZoom } = useDataViewsLayout();
149
- if (!canUseCards || layout !== "cards") return null;
284
+ const { layout, zoom, setZoom } = useDataViewsLayout();
285
+ if (layout !== "cards" && layout !== "board") return null;
150
286
  return (
151
287
  <Box
152
288
  sx={{