@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,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
|
+
}
|