@12-apps/ui 1.5.2 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/package.json +4 -4
  2. package/src/components/data-display/DataViews/DataViewsBoard.tsx +327 -0
  3. package/src/components/data-display/DataViews/DataViewsGrid.tsx +80 -77
  4. package/src/components/data-display/DataViews/DataViewsTableBase.tsx +178 -230
  5. package/src/components/data-display/DataViews/DeleteViewDialog.tsx +83 -0
  6. package/src/components/data-display/DataViews/SaveViewModal.tsx +140 -52
  7. package/src/components/data-display/DataViews/data-views-columns-tab.tsx +240 -0
  8. package/src/components/data-display/DataViews/data-views-columns.ts +129 -0
  9. package/src/components/data-display/DataViews/data-views-dirty.ts +62 -0
  10. package/src/components/data-display/DataViews/data-views-display-panel.tsx +328 -0
  11. package/src/components/data-display/DataViews/data-views-display-tabs.tsx +328 -0
  12. package/src/components/data-display/DataViews/data-views-empty.tsx +91 -0
  13. package/src/components/data-display/DataViews/data-views-export.tsx +260 -0
  14. package/src/components/data-display/DataViews/data-views-glyphs.tsx +103 -0
  15. package/src/components/data-display/DataViews/data-views-grid-bodies.tsx +340 -0
  16. package/src/components/data-display/DataViews/data-views-grid-parts.tsx +220 -242
  17. package/src/components/data-display/DataViews/data-views-inline-bar.tsx +187 -81
  18. package/src/components/data-display/DataViews/data-views-layout-context.tsx +215 -79
  19. package/src/components/data-display/DataViews/data-views-more-filters.tsx +300 -0
  20. package/src/components/data-display/DataViews/data-views-overflow.ts +302 -0
  21. package/src/components/data-display/DataViews/data-views-preview.ts +11 -3
  22. package/src/components/data-display/DataViews/data-views-range-pill.tsx +162 -92
  23. package/src/components/data-display/DataViews/data-views-range-values.ts +69 -0
  24. package/src/components/data-display/DataViews/data-views-scopes.tsx +356 -0
  25. package/src/components/data-display/DataViews/data-views-search.tsx +156 -0
  26. package/src/components/data-display/DataViews/data-views-select-all-strip.tsx +105 -0
  27. package/src/components/data-display/DataViews/data-views-selection.ts +50 -0
  28. package/src/components/data-display/DataViews/data-views-server-query.ts +75 -0
  29. package/src/components/data-display/DataViews/data-views-shell-toolbar.tsx +87 -0
  30. package/src/components/data-display/DataViews/data-views-table-parts.tsx +5 -0
  31. package/src/components/data-display/DataViews/data-views-toolbar.tsx +63 -48
  32. package/src/components/data-display/DataViews/data-views-types.ts +88 -9
  33. package/src/components/data-display/DataViews/data-views-view-nav.tsx +378 -0
  34. package/src/components/data-display/DataViews/index.ts +14 -1
  35. package/src/components/data-display/DataViews/use-data-views-state.ts +158 -134
  36. package/src/components/data-display/DataViews/use-saved-views-controller.ts +171 -0
  37. package/src/components/layout/ContentToolbar/ContentToolbar.tsx +86 -30
  38. package/src/components/layout/ContentToolbar/ContentToolbar.types.ts +25 -0
  39. package/src/components/layout/ContentToolbar/MultiSelectDropdown.tsx +6 -1
  40. package/src/components/utility/InstallPrompt/InstallPrompt.earlyCapture.ts +117 -0
  41. package/src/components/utility/InstallPrompt/InstallPrompt.md +52 -4
  42. package/src/components/utility/InstallPrompt/InstallPrompt.types.ts +19 -0
  43. package/src/components/utility/InstallPrompt/index.ts +8 -0
  44. package/src/components/utility/InstallPrompt/usePwaInstall.ts +39 -11
@@ -0,0 +1,328 @@
1
+ "use client";
2
+
3
+ import KeyboardArrowDownRoundedIcon from "@mui/icons-material/KeyboardArrowDownRounded";
4
+ import TuneRoundedIcon from "@mui/icons-material/TuneRounded";
5
+ import { Popover } from "@mui/material";
6
+ import { useState } from "react";
7
+
8
+ import { Button } from "../../form/Button";
9
+ import { Box } from "../../../mui/Box";
10
+
11
+ import { ColumnsTab } from "./data-views-columns-tab";
12
+ import {
13
+ ViewActions,
14
+ ViewFooter,
15
+ ViewHeader,
16
+ ViewsList,
17
+ type ViewNavHandlers,
18
+ } from "./data-views-view-nav";
19
+ import type { SavedViewSummary } from "./data-views-types";
20
+ import { DisplayTab, SortTab } from "./data-views-display-tabs";
21
+ import type { DataViewsController } from "./use-data-views-state";
22
+
23
+ /**
24
+ * "EXIBIR" — one panel for every question about how the list is READ.
25
+ *
26
+ * Sort, columns and format used to be three separate toolbar dropdowns, which
27
+ * asked the operator to already know which one held what. They are one decision
28
+ * ("how do I want to read this?") so they are one control with three tabs.
29
+ *
30
+ * A view BRACKETS the panel: its name pinned at the top, the save actions
31
+ * pinned at the bottom, both visible on every tab — so a change made in Colunas
32
+ * is one glance away from the button that saves it. A separate "Visão" button
33
+ * in the toolbar would be the same concept twice, in two places, disagreeing
34
+ * about whether there are unsaved changes.
35
+ */
36
+
37
+ /** Which tab is showing. `columns` first: it is what an operator opens this for. */
38
+ type DisplayTabKey = "sort" | "columns" | "display";
39
+
40
+ const TABS: { key: DisplayTabKey; label: string }[] = [
41
+ { key: "sort", label: "Ordenar" },
42
+ { key: "columns", label: "Colunas" },
43
+ { key: "display", label: "Exibição" },
44
+ ];
45
+
46
+ /** The saved-view chrome the panel brackets its tabs with, when a host supplies it. */
47
+ export interface DisplayPanelView {
48
+ /** The applied view's name, or undefined for the built-in "Visão principal". */
49
+ activeViewName?: string;
50
+ /** True when the live state differs from the applied view (or from pristine). */
51
+ dirty: boolean;
52
+ /** Restore the applied view (or the pristine default) — discards local changes. */
53
+ onReset: () => void;
54
+ /** Persist the live state over the applied view. Absent ⇒ no update affordance. */
55
+ onUpdate?: () => void;
56
+ /** Persist the live state as a NEW view. */
57
+ onSaveAs: () => void;
58
+ /**
59
+ * The saved views themselves, rendered INLINE in this panel's body when the
60
+ * VISÃO header is opened — not as a dropdown of their own. The panel stays
61
+ * data-free: the host owns the views and every mutation, this only decides
62
+ * where they appear.
63
+ */
64
+ nav?: ViewNavHandlers;
65
+ }
66
+
67
+ interface DisplayPanelProps<T extends Record<string, unknown>> {
68
+ c: DataViewsController<T>;
69
+ testIdPrefix: string;
70
+ /** Per-sort-field value kind, so directions read in the column's own terms. */
71
+ sortKinds?: Record<string, string>;
72
+ view?: DisplayPanelView;
73
+ /** Icon only — step 2 of the toolbar's measured degradation ladder. */
74
+ compact?: boolean;
75
+ }
76
+
77
+ /** The tab strip inside the panel. */
78
+ function PanelTabs({
79
+ tab,
80
+ onChange,
81
+ muted,
82
+ testIdPrefix,
83
+ }: {
84
+ tab: DisplayTabKey;
85
+ onChange: (tab: DisplayTabKey) => void;
86
+ /** The views own the body — no tab is showing, so none reads as selected. */
87
+ muted?: boolean;
88
+ testIdPrefix: string;
89
+ }): React.JSX.Element {
90
+ return (
91
+ <Box sx={{ display: "flex", gap: 0.5, p: 0.75, bgcolor: "action.hover", borderBottom: 1, borderColor: "divider" }}>
92
+ {TABS.map((entry) => {
93
+ const active = tab === entry.key && !muted;
94
+ return (
95
+ <Box
96
+ key={entry.key}
97
+ component="button"
98
+ type="button"
99
+ onClick={() => onChange(entry.key)}
100
+ data-testid={`${testIdPrefix}-display-tab-${entry.key}`}
101
+ aria-current={active}
102
+ sx={{
103
+ flex: 1,
104
+ px: 1,
105
+ py: 0.75,
106
+ border: 0,
107
+ borderRadius: 1,
108
+ cursor: "pointer",
109
+ font: "inherit",
110
+ fontSize: "0.8125rem",
111
+ bgcolor: active ? "background.paper" : "transparent",
112
+ boxShadow: active ? 1 : 0,
113
+ fontWeight: active ? 600 : 400,
114
+ color: active ? "text.primary" : "text.secondary",
115
+ }}
116
+ >
117
+ {entry.label}
118
+ </Box>
119
+ );
120
+ })}
121
+ </Box>
122
+ );
123
+ }
124
+
125
+ /** The active tab's body. */
126
+ function PanelBody<T extends Record<string, unknown>>({
127
+ tab,
128
+ c,
129
+ sortKinds,
130
+ testIdPrefix,
131
+ }: {
132
+ tab: DisplayTabKey;
133
+ c: DataViewsController<T>;
134
+ sortKinds?: Record<string, string>;
135
+ testIdPrefix: string;
136
+ }): React.JSX.Element {
137
+ if (tab === "sort") {
138
+ return (
139
+ <SortTab
140
+ fields={c.resolvedSortFields}
141
+ activeField={c.activeSortField}
142
+ activeOrder={c.activeSortOrder}
143
+ onChange={(field, order) => c.patch({ sortBy: [{ id: field, dir: order }] })}
144
+ sortKinds={sortKinds}
145
+ testIdPrefix={testIdPrefix}
146
+ />
147
+ );
148
+ }
149
+ if (tab === "columns") {
150
+ return (
151
+ <ColumnsTab
152
+ columns={c.hideableColumns}
153
+ order={c.columnOrder}
154
+ visibleColumns={c.state.visibleColumns}
155
+ onToggle={c.toggleColumn}
156
+ onReorder={(order) => c.patch({ order })}
157
+ onShowAll={() => c.patch({ visibleColumns: c.columnOrder })}
158
+ // "Padrão" restores BOTH: the declared order and every column visible.
159
+ onReset={() => c.patch({ visibleColumns: c.columnOrder, order: undefined })}
160
+ testIdPrefix={testIdPrefix}
161
+ />
162
+ );
163
+ }
164
+ return <DisplayTab testIdPrefix={testIdPrefix} />;
165
+ }
166
+
167
+ /** Everything inside the popover: VISÃO header, tabs, body, save footer. */
168
+ function PanelSurface<T extends Record<string, unknown>>({
169
+ c, view, nav, tab, onTab, viewsOpen, onToggleViews, actionsFor, onOpenActions,
170
+ onBack, sortKinds, onDone, testIdPrefix,
171
+ }: {
172
+ c: DataViewsController<T>;
173
+ view?: DisplayPanelView;
174
+ nav?: ViewNavHandlers;
175
+ tab: DisplayTabKey;
176
+ onTab: (tab: DisplayTabKey) => void;
177
+ viewsOpen: boolean;
178
+ onToggleViews: () => void;
179
+ actionsFor: SavedViewSummary | null;
180
+ onOpenActions: (view: SavedViewSummary) => void;
181
+ onBack: () => void;
182
+ sortKinds?: Record<string, string>;
183
+ onDone: () => void;
184
+ testIdPrefix: string;
185
+ }): React.JSX.Element {
186
+ return (
187
+ <Box data-testid={`${testIdPrefix}-display-panel`}>
188
+ {view && (
189
+ <ViewHeader
190
+ view={view}
191
+ open={viewsOpen}
192
+ onToggle={onToggleViews}
193
+ testIdPrefix={testIdPrefix}
194
+ />
195
+ )}
196
+ <PanelTabs
197
+ tab={tab}
198
+ onChange={onTab}
199
+ muted={Boolean(nav)}
200
+ testIdPrefix={testIdPrefix}
201
+ />
202
+ {/* Constrained + scrolled: the columns list is unbounded, and a panel
203
+ taller than the viewport puts its own save button off screen. */}
204
+ <Box sx={{ maxHeight: 320, minHeight: 220, overflowY: "auto" }}>
205
+ {nav ? (
206
+ <ViewsBody
207
+ nav={nav}
208
+ actionsFor={actionsFor}
209
+ onOpenActions={onOpenActions}
210
+ onBack={onBack}
211
+ testIdPrefix={testIdPrefix}
212
+ />
213
+ ) : (
214
+ <PanelBody tab={tab} c={c} sortKinds={sortKinds} testIdPrefix={testIdPrefix} />
215
+ )}
216
+ </Box>
217
+ {view && <ViewFooter view={view} onDone={onDone} testIdPrefix={testIdPrefix} />}
218
+ </Box>
219
+ );
220
+ }
221
+
222
+
223
+ /** The views as the panel's body: the list, or one view's actions. */
224
+ function ViewsBody({
225
+ nav,
226
+ actionsFor,
227
+ onOpenActions,
228
+ onBack,
229
+ testIdPrefix,
230
+ }: {
231
+ nav: ViewNavHandlers;
232
+ actionsFor: SavedViewSummary | null;
233
+ onOpenActions: (view: SavedViewSummary) => void;
234
+ onBack: () => void;
235
+ testIdPrefix: string;
236
+ }): React.JSX.Element {
237
+ // Resolve against the LIVE list, not the object captured when the kebab was
238
+ // clicked: toggling "Fixar" or "Compartilhar" replaces the view in `views`,
239
+ // and a held snapshot would keep showing the state from before the click.
240
+ const live = actionsFor ? nav.views.find((view) => view.id === actionsFor.id) : undefined;
241
+ if (live) {
242
+ return <ViewActions view={live} handlers={nav} onBack={onBack} testIdPrefix={testIdPrefix} />;
243
+ }
244
+ return <ViewsList handlers={nav} onOpenActions={onOpenActions} testIdPrefix={testIdPrefix} />;
245
+ }
246
+
247
+ /**
248
+ * The toolbar's "Exibir" control: one button, one popover, three tabs, and the
249
+ * view bracketing all of it.
250
+ */
251
+ export function DataViewsDisplayPanel<T extends Record<string, unknown>>({
252
+ c,
253
+ testIdPrefix,
254
+ sortKinds,
255
+ view,
256
+ compact,
257
+ }: DisplayPanelProps<T>): React.JSX.Element {
258
+ const [anchor, setAnchor] = useState<HTMLElement | null>(null);
259
+ const [tab, setTab] = useState<DisplayTabKey>("columns");
260
+ // The views REPLACE the tab body rather than opening over it — see
261
+ // `data-views-view-nav`. `actionsFor` is the second level of that drill-down.
262
+ const [viewsOpen, setViewsOpen] = useState(false);
263
+ const [actionsFor, setActionsFor] = useState<SavedViewSummary | null>(null);
264
+ // Non-null only while the views are the body — the one place that decides
265
+ // whether this panel is showing tabs or views.
266
+ const nav = viewsOpen ? view?.nav : undefined;
267
+ const close = (): void => setAnchor(null);
268
+ const leaveViews = (): void => {
269
+ setViewsOpen(false);
270
+ setActionsFor(null);
271
+ };
272
+ return (
273
+ <>
274
+ <Button
275
+ variant="outline"
276
+ size="sm"
277
+ color="neutral"
278
+ onClick={(event) => setAnchor(event.currentTarget as HTMLElement)}
279
+ dataTestId={`${testIdPrefix}-display-trigger`}
280
+ aria-label="Exibir"
281
+ title={compact ? "Exibir" : undefined}
282
+ >
283
+ <Box component="span" sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}>
284
+ <TuneRoundedIcon fontSize="small" />
285
+ {!compact && (
286
+ <Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}>
287
+ Exibir
288
+ </Box>
289
+ )}
290
+ {view?.dirty && (
291
+ <Box component="span" sx={{ height: 6, width: 6, borderRadius: "50%", bgcolor: "primary.main" }} />
292
+ )}
293
+ <KeyboardArrowDownRoundedIcon fontSize="small" />
294
+ </Box>
295
+ </Button>
296
+ <Popover
297
+ open={Boolean(anchor)}
298
+ anchorEl={anchor}
299
+ onClose={close}
300
+ anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
301
+ transformOrigin={{ vertical: "top", horizontal: "right" }}
302
+ slotProps={{ paper: { sx: { width: 320, maxWidth: "calc(100vw - 32px)" } } }}
303
+ >
304
+ <PanelSurface
305
+ c={c}
306
+ view={view}
307
+ nav={nav}
308
+ tab={tab}
309
+ onTab={(next) => {
310
+ setTab(next);
311
+ leaveViews();
312
+ }}
313
+ viewsOpen={viewsOpen}
314
+ onToggleViews={() => {
315
+ setViewsOpen((prev) => !prev);
316
+ setActionsFor(null);
317
+ }}
318
+ actionsFor={actionsFor}
319
+ onOpenActions={setActionsFor}
320
+ onBack={() => setActionsFor(null)}
321
+ sortKinds={sortKinds}
322
+ onDone={close}
323
+ testIdPrefix={testIdPrefix}
324
+ />
325
+ </Popover>
326
+ </>
327
+ );
328
+ }
@@ -0,0 +1,328 @@
1
+ "use client";
2
+
3
+ import ArrowDownwardRoundedIcon from "@mui/icons-material/ArrowDownwardRounded";
4
+ import ArrowUpwardRoundedIcon from "@mui/icons-material/ArrowUpwardRounded";
5
+
6
+ import type { SortFieldDefinition } from "../../layout/ContentToolbar";
7
+ import { Box } from "../../../mui/Box";
8
+ import { Text } from "../../typography/Text";
9
+
10
+ import {
11
+ BoardGlyph,
12
+ GridGlyph,
13
+ ListGlyph,
14
+ RowsGlyph,
15
+ TableGlyph,
16
+ } from "./data-views-glyphs";
17
+ import {
18
+ useDataViewsLayout,
19
+ type DataViewsDensity,
20
+ type DataViewsLayout,
21
+ } from "./data-views-layout-context";
22
+
23
+ /**
24
+ * Two of the three tabs inside the "Exibir" panel — Ordenar and Exibição. The
25
+ * Colunas tab is its own file (`data-views-columns-tab.tsx`) for the size budget.
26
+ *
27
+ * They live together because they answer one question ("how do I want to read
28
+ * this list?") and were previously three separate toolbar dropdowns that the
29
+ * operator had to know apart. Split into this file so the panel that frames
30
+ * them, and each tab, stays inside the size budget.
31
+ */
32
+
33
+ /* ── Shared tile ─────────────────────────────────────────────────────────── */
34
+
35
+ /** A picker tile: a glyph over its label, highlighted when it is the choice. */
36
+ function Tile({
37
+ active,
38
+ label,
39
+ onClick,
40
+ testId,
41
+ children,
42
+ }: {
43
+ active: boolean;
44
+ label: string;
45
+ onClick: () => void;
46
+ testId: string;
47
+ children: React.ReactNode;
48
+ }): React.JSX.Element {
49
+ return (
50
+ <Box
51
+ component="button"
52
+ type="button"
53
+ onClick={onClick}
54
+ aria-pressed={active}
55
+ data-testid={testId}
56
+ sx={{
57
+ flex: 1,
58
+ minWidth: 0,
59
+ display: "flex",
60
+ flexDirection: "column",
61
+ alignItems: "center",
62
+ gap: 0.75,
63
+ p: 1.25,
64
+ border: 1,
65
+ borderStyle: "solid",
66
+ borderColor: active ? "primary.main" : "divider",
67
+ bgcolor: active ? "action.selected" : "transparent",
68
+ borderRadius: 1,
69
+ cursor: "pointer",
70
+ font: "inherit",
71
+ fontSize: "0.75rem",
72
+ fontWeight: active ? 600 : 400,
73
+ color: active ? "primary.main" : "text.secondary",
74
+ "&:hover": { bgcolor: "action.hover" },
75
+ }}
76
+ >
77
+ {children}
78
+ {label}
79
+ </Box>
80
+ );
81
+ }
82
+
83
+ /* ── Ordenar ─────────────────────────────────────────────────────────────── */
84
+
85
+ /**
86
+ * Direction labels read in the COLUMN'S OWN TERMS. "Crescente" on a currency
87
+ * column is a puzzle the operator has to solve every time; "Menor → maior" is
88
+ * not, and "Mais recente" is what someone actually means about a date.
89
+ */
90
+ const DIRECTION_LABELS: Record<string, [string, string]> = {
91
+ number: ["Menor → maior", "Maior → menor"],
92
+ currency: ["Menor → maior", "Maior → menor"],
93
+ date: ["Mais antigo", "Mais recente"],
94
+ text: ["A → Z", "Z → A"],
95
+ };
96
+
97
+ /** The direction wording for a sort field, falling back to alphabetical. */
98
+ function directionLabels(kind: string | undefined): [string, string] {
99
+ return DIRECTION_LABELS[kind ?? "text"] ?? DIRECTION_LABELS.text!;
100
+ }
101
+
102
+ interface SortTabProps {
103
+ fields: SortFieldDefinition[];
104
+ activeField: string;
105
+ activeOrder: "asc" | "desc";
106
+ onChange: (field: string, order: "asc" | "desc") => void;
107
+ /** Per-field value kind, so the direction can be phrased in its own terms. */
108
+ sortKinds?: Record<string, string>;
109
+ testIdPrefix: string;
110
+ }
111
+
112
+ /** Direction first (two tiles), then the field list — the order they are decided in. */
113
+ export function SortTab({
114
+ fields,
115
+ activeField,
116
+ activeOrder,
117
+ onChange,
118
+ sortKinds,
119
+ testIdPrefix,
120
+ }: SortTabProps): React.JSX.Element {
121
+ const [ascLabel, descLabel] = directionLabels(sortKinds?.[activeField]);
122
+ return (
123
+ <Box sx={{ p: 1 }}>
124
+ <Text variant="caption" as="p">
125
+ <Box component="span" sx={{ px: 0.5, color: "text.secondary" }}>
126
+ Direção
127
+ </Box>
128
+ </Text>
129
+ <Box sx={{ display: "flex", gap: 0.75, mb: 1.5, mt: 0.5 }}>
130
+ {(["asc", "desc"] as const).map((direction, index) => (
131
+ <Tile
132
+ key={direction}
133
+ active={activeOrder === direction}
134
+ label={index === 0 ? ascLabel : descLabel}
135
+ onClick={() => onChange(activeField || fields[0]?.value || "", direction)}
136
+ testId={`${testIdPrefix}-sort-dir-${direction}`}
137
+ >
138
+ {direction === "asc" ? (
139
+ <ArrowUpwardRoundedIcon fontSize="small" />
140
+ ) : (
141
+ <ArrowDownwardRoundedIcon fontSize="small" />
142
+ )}
143
+ </Tile>
144
+ ))}
145
+ </Box>
146
+ <Text variant="caption" as="p">
147
+ <Box component="span" sx={{ px: 0.5, color: "text.secondary" }}>
148
+ Campo
149
+ </Box>
150
+ </Text>
151
+ {fields.map((field) => (
152
+ <SortFieldRow
153
+ key={field.value}
154
+ label={field.label}
155
+ active={field.value === activeField}
156
+ onClick={() => onChange(field.value, activeOrder)}
157
+ testId={`${testIdPrefix}-sort-field-${field.value}`}
158
+ />
159
+ ))}
160
+ </Box>
161
+ );
162
+ }
163
+
164
+ /** One sort field: a dot and its label, highlighted when it is the active sort. */
165
+ function SortFieldRow({
166
+ label,
167
+ active,
168
+ onClick,
169
+ testId,
170
+ }: {
171
+ label: string;
172
+ active: boolean;
173
+ onClick: () => void;
174
+ testId: string;
175
+ }): React.JSX.Element {
176
+ return (
177
+ <Box
178
+ component="button"
179
+ type="button"
180
+ onClick={onClick}
181
+ data-testid={testId}
182
+ sx={{
183
+ display: "flex",
184
+ alignItems: "center",
185
+ gap: 1,
186
+ width: "100%",
187
+ px: 1,
188
+ py: 0.75,
189
+ border: 0,
190
+ background: "none",
191
+ borderRadius: 1,
192
+ cursor: "pointer",
193
+ font: "inherit",
194
+ fontSize: "0.8125rem",
195
+ textAlign: "left",
196
+ fontWeight: active ? 600 : 400,
197
+ color: active ? "primary.main" : "text.primary",
198
+ "&:hover": { bgcolor: "action.hover" },
199
+ }}
200
+ >
201
+ <Box
202
+ component="span"
203
+ sx={{ height: 6, width: 6, borderRadius: "50%", bgcolor: active ? "primary.main" : "action.disabled" }}
204
+ />
205
+ <Box component="span" sx={{ flex: 1, minWidth: 0 }}>
206
+ {label}
207
+ </Box>
208
+ </Box>
209
+ );
210
+ }
211
+
212
+ /* ── Exibição ────────────────────────────────────────────────────────────── */
213
+
214
+ /** One line of copy per layout, saying what it is FOR rather than what it is. */
215
+ const LAYOUT_HINTS: Record<DataViewsLayout, string> = {
216
+ table: "Colunas comparáveis, boa para varrer números.",
217
+ list: "Uma linha por item, com os dados principais.",
218
+ cards: "Blocos maiores, bom para poucos itens.",
219
+ board: "Colunas por etapa, para acompanhar o que está onde.",
220
+ };
221
+
222
+ /** The density tiles, phrased for whichever layout is on screen. */
223
+ function densityTiles(cards: boolean): { value: DataViewsDensity; label: string; gap: number; columns: number }[] {
224
+ return [
225
+ { value: "compact", label: cards ? "Muitos" : "Baixa", gap: 2, columns: 4 },
226
+ { value: "cozy", label: cards ? "Médio" : "Média", gap: 5, columns: 3 },
227
+ { value: "comfortable", label: cards ? "Poucos" : "Alta", gap: 8, columns: 2 },
228
+ ];
229
+ }
230
+
231
+ /** The glyph for a layout tile. */
232
+ function layoutGlyph(layout: DataViewsLayout, active: boolean): React.JSX.Element {
233
+ if (layout === "table") return <TableGlyph active={active} />;
234
+ if (layout === "list") return <ListGlyph active={active} />;
235
+ if (layout === "cards") return <GridGlyph active={active} />;
236
+ return <BoardGlyph active={active} />;
237
+ }
238
+
239
+ const LAYOUT_TILE_LABELS: Record<DataViewsLayout, string> = {
240
+ table: "Tabela",
241
+ list: "Lista",
242
+ cards: "Grade",
243
+ board: "Quadro",
244
+ };
245
+
246
+ /**
247
+ * Format and density, both shown as PREVIEWS rather than words — see
248
+ * `data-views-glyphs.tsx` for why. A layout this table cannot render is simply
249
+ * not offered, and the panel says why rather than leaving a gap.
250
+ */
251
+ export function DisplayTab({ testIdPrefix }: { testIdPrefix: string }): React.JSX.Element {
252
+ const { layout, setLayout, canUseCards, canUseList, canUseBoard, density, setDensity } =
253
+ useDataViewsLayout();
254
+ const available: DataViewsLayout[] = [
255
+ "table",
256
+ ...(canUseList ? (["list"] as const) : []),
257
+ ...(canUseCards ? (["cards"] as const) : []),
258
+ ...(canUseBoard ? (["board"] as const) : []),
259
+ ];
260
+ const cards = layout === "cards";
261
+ return (
262
+ <Box sx={{ p: 1 }}>
263
+ <Text variant="caption" as="p">
264
+ <Box component="span" sx={{ px: 0.5, color: "text.secondary" }}>
265
+ Formato
266
+ </Box>
267
+ </Text>
268
+ <Box
269
+ sx={{
270
+ display: "grid",
271
+ gridTemplateColumns: available.length > 3 ? "repeat(2, 1fr)" : `repeat(${available.length}, 1fr)`,
272
+ gap: 0.75,
273
+ mt: 0.5,
274
+ }}
275
+ >
276
+ {available.map((value) => (
277
+ <Tile
278
+ key={value}
279
+ active={layout === value}
280
+ label={LAYOUT_TILE_LABELS[value]}
281
+ onClick={() => setLayout(value)}
282
+ testId={`${testIdPrefix}-layout-${value}`}
283
+ >
284
+ {layoutGlyph(value, layout === value)}
285
+ </Tile>
286
+ ))}
287
+ </Box>
288
+ <Text variant="caption" as="p">
289
+ <Box component="span" sx={{ px: 0.5, py: 1, display: "block", color: "text.disabled" }}>
290
+ {LAYOUT_HINTS[layout]}
291
+ </Box>
292
+ </Text>
293
+ {layout !== "board" && (
294
+ <>
295
+ <Text variant="caption" as="p">
296
+ <Box component="span" sx={{ px: 0.5, color: "text.secondary" }}>
297
+ {cards ? "Cards por linha" : "Altura das linhas"}
298
+ </Box>
299
+ </Text>
300
+ <Box sx={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 0.75, mt: 0.5 }}>
301
+ {densityTiles(cards).map((tile) => (
302
+ <Tile
303
+ key={tile.value}
304
+ active={density === tile.value}
305
+ label={tile.label}
306
+ onClick={() => setDensity(tile.value)}
307
+ testId={`${testIdPrefix}-density-${tile.value}`}
308
+ >
309
+ {cards ? (
310
+ <GridGlyph n={tile.columns} active={density === tile.value} />
311
+ ) : (
312
+ <RowsGlyph gap={tile.gap} active={density === tile.value} />
313
+ )}
314
+ </Tile>
315
+ ))}
316
+ </Box>
317
+ </>
318
+ )}
319
+ {!canUseBoard && (
320
+ <Text variant="caption" as="p">
321
+ <Box component="span" sx={{ px: 0.5, pt: 1.25, display: "block", color: "text.disabled" }}>
322
+ Esta tela não declara etapas, então não oferece quadro.
323
+ </Box>
324
+ </Text>
325
+ )}
326
+ </Box>
327
+ );
328
+ }