@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.
Files changed (39) hide show
  1. package/package.json +5 -5
  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
@@ -0,0 +1,356 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useRef } from "react";
4
+
5
+ import { Box } from "../../../mui/Box";
6
+
7
+ /**
8
+ * SCOPES — a page-level partition rendered as a strip of tabs above the toolbar.
9
+ *
10
+ * A scope is not a filter. Filters NARROW within the current partition and are
11
+ * multi-select by design; a scope PARTITIONS and is exclusive. The distinction
12
+ * is load-bearing for three reasons, and each is why `FilterFieldConfig` could
13
+ * not express these tabs:
14
+ *
15
+ * 1. They are mutually exclusive, and a pill is not.
16
+ * 2. They carry counts of the WHOLE query, which no client can compute under
17
+ * pagination — the count has to come off the response
18
+ * ({@link DataViewServer.scopeCounts}).
19
+ * 3. They compose with the filters instead of competing with them: the scope
20
+ * picks the bucket, the pills narrow inside it.
21
+ *
22
+ * The `predicate` is deliberately opaque to this package. Only the host knows
23
+ * what "recusados" selects; the component's whole job is to say WHICH scope is
24
+ * active and let the server answer.
25
+ */
26
+ export interface ScopeConfig {
27
+ /** Stable id — travels in `DataViewQuery.scope` and in a saved view's state. */
28
+ id: string;
29
+ label: string;
30
+ /**
31
+ * Opaque payload the SERVER interprets. Omitted on the "all" scope. Never read
32
+ * by this package: a scope is applied at the backend, never in the browser.
33
+ */
34
+ predicate?: unknown;
35
+ }
36
+
37
+ /**
38
+ * The active scope id, resolved against what the table currently declares.
39
+ *
40
+ * Resolution happens at READ time, on purpose. A saved view is written once and
41
+ * read many times, and the declared scopes change underneath it — a scope
42
+ * removed after a view was saved must fall back at render rather than break the
43
+ * view or, worse, emit a scope the backend rejects. Resolving on write would
44
+ * bake the mistake in.
45
+ *
46
+ * Returns `undefined` when the table declares no scopes, which is what keeps
47
+ * `scope` out of the emitted query entirely for every table that never opted in.
48
+ */
49
+ export function resolveScope(scopes: ScopeConfig[], stored: string | undefined): string | undefined {
50
+ if (scopes.length === 0) return undefined;
51
+ const declared = stored !== undefined && scopes.some((scope) => scope.id === stored);
52
+ return declared ? stored : scopes[0]?.id;
53
+ }
54
+
55
+ /** Are we running outside production? Config mistakes fail loudly only here. */
56
+ function isDevelopment(): boolean {
57
+ return typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
58
+ }
59
+
60
+ /**
61
+ * Reject a table that partitions by a field AND declares a filter pill over the
62
+ * same field. Two controls over one field is how a tab and a pill end up
63
+ * contradicting each other — the pill says "estado ∈ {pago, recusado}" while the
64
+ * tab says "estado = recusado", and nothing in either control can show the user
65
+ * which one the server honoured.
66
+ *
67
+ * Throws in DEVELOPMENT ONLY. A thrown config error in production is strictly
68
+ * worse than a duplicated control: the duplicate is confusing, the throw is a
69
+ * blank page. Fail where it is seen and fixed.
70
+ *
71
+ * `scopeFieldId` is the field the scopes partition by, when the host knows it.
72
+ * Absent, the scope IDS are checked against the field ids — which catches the
73
+ * common shape where the scope ids ARE the field's values (`pago`, `recusado`)
74
+ * declared a second time as a pill.
75
+ */
76
+ export function assertNoScopePillOverlap(
77
+ scopes: ScopeConfig[],
78
+ fieldIds: string[],
79
+ scopeFieldId?: string,
80
+ ): void {
81
+ if (scopes.length === 0 || !isDevelopment()) return;
82
+ const clash = scopeFieldId !== undefined && fieldIds.includes(scopeFieldId) ? scopeFieldId : undefined;
83
+ if (clash === undefined) return;
84
+ throw new Error(
85
+ `DataViews: the field "${clash}" is both the scope partition (scopes: ` +
86
+ `${scopes.map((scope) => scope.id).join(", ")}) and a filter field (fields: ` +
87
+ `${fieldIds.join(", ")}). A field may drive the scope tabs or a filter pill, not both — ` +
88
+ `drop one of the two declarations.`,
89
+ );
90
+ }
91
+
92
+ /**
93
+ * A development-only warning that scopes need server wiring. Scopes are resolved
94
+ * and emitted, never applied in the browser: filtering locally would make the
95
+ * counts, the "N de N" counter and the rendered rows disagree the moment a
96
+ * second page exists.
97
+ */
98
+ export function warnScopesNeedServer(scopes: ScopeConfig[], hasServer: boolean): void {
99
+ if (scopes.length === 0 || hasServer || !isDevelopment()) return;
100
+ // eslint-disable-next-line no-console -- a config warning has no other channel.
101
+ console.warn(
102
+ "DataViews: `scopes` require server mode. The scope is tracked and would be emitted, " +
103
+ "but with no `server` there is nothing to emit it to and rows are NOT filtered in the browser. " +
104
+ "Supply `server` (see DataViewServer) or drop the scopes.",
105
+ );
106
+ }
107
+
108
+ /**
109
+ * The scope configuration's setup-time checks, run once per configuration rather
110
+ * than per render: reject a field that is both a scope partition and a filter
111
+ * pill (development only — see `assertNoScopePillOverlap`), and warn about
112
+ * scopes declared with no server to apply them.
113
+ *
114
+ * In a `useMemo` so a mistake surfaces during the FIRST render, where the stack
115
+ * still points at the table that made it.
116
+ */
117
+ export function useScopeConfigChecks(
118
+ scopes: ScopeConfig[],
119
+ fieldIds: string[],
120
+ scopeFieldId: string | undefined,
121
+ hasServer: boolean,
122
+ ): void {
123
+ useMemo(() => {
124
+ assertNoScopePillOverlap(scopes, fieldIds, scopeFieldId);
125
+ warnScopesNeedServer(scopes, hasServer);
126
+ // Keyed on the ids rather than the arrays: hosts pass inline literals, so the
127
+ // array identity changes every render while the configuration does not.
128
+ }, [scopes, fieldIds.join(","), scopeFieldId, hasServer]);
129
+ }
130
+
131
+ /** Everything the tab strip needs; `counts` is absent when the server omits it. */
132
+ interface DataViewsScopeTabsProps {
133
+ scopes: ScopeConfig[];
134
+ /** The RESOLVED active scope id (see {@link resolveScope}). */
135
+ value: string | undefined;
136
+ onChange: (id: string) => void;
137
+ /** Server-supplied per-scope totals. Absent ⇒ tabs render WITHOUT numbers. */
138
+ counts?: Record<string, number>;
139
+ testIdPrefix: string;
140
+ }
141
+
142
+ /** The accessible name of one tab: its label, plus its count when there is one. */
143
+ function tabLabel(label: string, count: number | undefined): string {
144
+ return count === undefined ? label : `${label}, ${count}`;
145
+ }
146
+
147
+ /**
148
+ * Move focus + selection between tabs with the arrow keys, wrapping at both
149
+ * ends. Selection FOLLOWS focus here (an "automatic activation" tab list): each
150
+ * arrow press is one scope change, which is one query — the same cost as a
151
+ * click, and it keeps the strip's behaviour identical whichever input the
152
+ * operator uses.
153
+ */
154
+ function arrowTarget(key: string, index: number, total: number): number | null {
155
+ if (key === "ArrowRight") return (index + 1) % total;
156
+ if (key === "ArrowLeft") return (index - 1 + total) % total;
157
+ if (key === "Home") return 0;
158
+ if (key === "End") return total - 1;
159
+ return null;
160
+ }
161
+
162
+ /** The active tab's own scroll-into-view, so a deep link to a late scope lands visible. */
163
+ function useScrollActiveIntoView(value: string | undefined): React.RefObject<HTMLDivElement | null> {
164
+ const stripRef = useRef<HTMLDivElement | null>(null);
165
+ useEffect(() => {
166
+ const strip = stripRef.current;
167
+ if (!strip || value === undefined) return;
168
+ const active = strip.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]');
169
+ // jsdom implements neither, so guard rather than assume the DOM is a browser's.
170
+ active?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
171
+ }, [value]);
172
+ return stripRef;
173
+ }
174
+
175
+ /** One tab's visual + a11y state, kept out of the strip's map for readability. */
176
+ function scopeTabSx(active: boolean): Record<string, unknown> {
177
+ return {
178
+ // A 44px min target: the strip is the first thing a thumb reaches on a phone.
179
+ minHeight: 44,
180
+ px: 1.5,
181
+ py: 1,
182
+ border: 0,
183
+ borderBottom: 2,
184
+ borderStyle: "solid",
185
+ borderColor: active ? "primary.main" : "transparent",
186
+ background: "none",
187
+ cursor: "pointer",
188
+ whiteSpace: "nowrap",
189
+ font: "inherit",
190
+ fontSize: "0.8125rem",
191
+ fontWeight: active ? 600 : 400,
192
+ color: active ? "primary.main" : "text.secondary",
193
+ "&:hover": { color: active ? "primary.main" : "text.primary" },
194
+ "&:focus-visible": { outline: "2px solid", outlineColor: "primary.main", outlineOffset: -2 },
195
+ };
196
+ }
197
+
198
+ /** The count badge. Rendered only when the server actually supplied a number. */
199
+ function ScopeCount({
200
+ count,
201
+ active,
202
+ testId,
203
+ }: {
204
+ count: number;
205
+ active: boolean;
206
+ testId: string;
207
+ }): React.JSX.Element {
208
+ return (
209
+ <Box
210
+ component="span"
211
+ // The number is already in the tab's `aria-label`; announcing it twice
212
+ // would read "Recusados 2, 2".
213
+ aria-hidden="true"
214
+ data-testid={testId}
215
+ sx={{
216
+ ml: 0.75,
217
+ px: 0.75,
218
+ borderRadius: 5,
219
+ fontSize: "0.6875rem",
220
+ fontWeight: 600,
221
+ bgcolor: active ? "primary.main" : "action.selected",
222
+ color: active ? "primary.contrastText" : "text.secondary",
223
+ }}
224
+ >
225
+ {count}
226
+ </Box>
227
+ );
228
+ }
229
+
230
+ interface ScopeTabProps {
231
+ scope: ScopeConfig;
232
+ index: number;
233
+ total: number;
234
+ active: boolean;
235
+ count: number | undefined;
236
+ onChange: (id: string) => void;
237
+ /**
238
+ * Move to the tab at `index`: focus it AND select it. Only the strip knows
239
+ * the other scopes and owns the DOM, so the move belongs to it.
240
+ */
241
+ moveTo: (index: number) => void;
242
+ testIdPrefix: string;
243
+ }
244
+
245
+ /** One tab: a real `<button role="tab">` with a roving tabindex and arrow keys. */
246
+ function ScopeTab({
247
+ scope,
248
+ index,
249
+ total,
250
+ active,
251
+ count,
252
+ onChange,
253
+ moveTo,
254
+ testIdPrefix,
255
+ }: ScopeTabProps): React.JSX.Element {
256
+ return (
257
+ <Box
258
+ component="button"
259
+ type="button"
260
+ role="tab"
261
+ id={`${testIdPrefix}-scope-tab-${scope.id}`}
262
+ aria-selected={active}
263
+ // Roving tabindex: one tab stop for the whole strip, arrows move within it.
264
+ tabIndex={active ? 0 : -1}
265
+ aria-label={tabLabel(scope.label, count)}
266
+ data-testid={`${testIdPrefix}-scope-${scope.id}`}
267
+ onClick={() => onChange(scope.id)}
268
+ onKeyDown={(event: React.KeyboardEvent<HTMLButtonElement>) => {
269
+ const target = arrowTarget(event.key, index, total);
270
+ if (target === null) return;
271
+ event.preventDefault();
272
+ moveTo(target);
273
+ }}
274
+ sx={scopeTabSx(active)}
275
+ >
276
+ {scope.label}
277
+ {count !== undefined && (
278
+ <ScopeCount
279
+ count={count}
280
+ active={active}
281
+ testId={`${testIdPrefix}-scope-count-${scope.id}`}
282
+ />
283
+ )}
284
+ </Box>
285
+ );
286
+ }
287
+
288
+ /** The strip's own layout: one non-wrapping row that scrolls horizontally. */
289
+ const STRIP_SX = {
290
+ display: "flex",
291
+ alignItems: "stretch",
292
+ gap: 0.5,
293
+ // One row that scrolls — never wraps, never collapses into a select.
294
+ flexWrap: "nowrap",
295
+ overflowX: "auto",
296
+ overflowY: "hidden",
297
+ scrollbarWidth: "thin",
298
+ borderBottom: 1,
299
+ borderColor: "divider",
300
+ mx: { xs: -2, md: -3 },
301
+ px: { xs: 2, md: 3 },
302
+ } as const;
303
+
304
+ /**
305
+ * The scope strip: a horizontally scrolling tab list above the toolbar.
306
+ *
307
+ * It SCROLLS rather than collapsing into a select on a narrow screen, which is
308
+ * the opposite of what the filter pills do — and deliberately. The counts are
309
+ * the reason the tabs exist at all; burying them behind a menu costs a tap per
310
+ * glance, exactly on the device where glancing is the point.
311
+ *
312
+ * Counts render verbatim from `counts` — never derived from the loaded page, and
313
+ * never invented when the key is missing. A missing count means "the server did
314
+ * not say", which reads honestly as no badge at all.
315
+ */
316
+ export function DataViewsScopeTabs({
317
+ scopes,
318
+ value,
319
+ onChange,
320
+ counts,
321
+ testIdPrefix,
322
+ }: DataViewsScopeTabsProps): React.JSX.Element | null {
323
+ const stripRef = useScrollActiveIntoView(value);
324
+ const moveTo = (index: number): void => {
325
+ // Focus FIRST, then select: the roving tabindex follows the selection, so
326
+ // focusing afterwards would land on a tab that has given up its tab stop.
327
+ stripRef.current?.querySelectorAll<HTMLElement>('[role="tab"]')?.[index]?.focus();
328
+ const next = scopes[index];
329
+ if (next) onChange(next.id);
330
+ };
331
+ // Reserve NO vertical space when there is nothing to show: a page that
332
+ // declares no scopes must not gain an empty row.
333
+ if (scopes.length === 0) return null;
334
+ const activeIndex = Math.max(
335
+ 0,
336
+ scopes.findIndex((scope) => scope.id === value),
337
+ );
338
+
339
+ return (
340
+ <Box ref={stripRef} role="tablist" aria-label="Situação" data-testid={`${testIdPrefix}-scopes`} sx={STRIP_SX}>
341
+ {scopes.map((scope, index) => (
342
+ <ScopeTab
343
+ key={scope.id}
344
+ scope={scope}
345
+ index={index}
346
+ total={scopes.length}
347
+ active={index === activeIndex}
348
+ count={counts?.[scope.id]}
349
+ onChange={onChange}
350
+ moveTo={moveTo}
351
+ testIdPrefix={testIdPrefix}
352
+ />
353
+ ))}
354
+ </Box>
355
+ );
356
+ }
@@ -0,0 +1,156 @@
1
+ "use client";
2
+
3
+ /**
4
+ * THE INLINE BAR'S SEARCH — the one control on the toolbar that cannot be
5
+ * guessed from an icon's position, which is why it is the LAST thing the
6
+ * degradation ladder takes away. See `data-views-overflow.ts`.
7
+ */
8
+ import CloseIcon from "@mui/icons-material/Close";
9
+ import SearchIcon from "@mui/icons-material/Search";
10
+ import { IconButton, InputAdornment, TextField } from "@mui/material";
11
+ import { useEffect, useRef, useState } from "react";
12
+
13
+ import { Box } from "../../../mui/Box";
14
+
15
+ /** How long the box waits after the last keystroke before it queries. */
16
+ const SEARCH_DEBOUNCE_MS = 350;
17
+
18
+ /**
19
+ * The bar's keyword box.
20
+ *
21
+ * DEBOUNCED rather than committed on Enter/blur. In server mode every commit is
22
+ * a round trip, and a box that only fires on blur makes the operator press a
23
+ * key they were not told about to see any result at all — they type, nothing
24
+ * happens, and the list looks broken. 350ms is long enough that a word costs
25
+ * one query, short enough to feel like live search.
26
+ *
27
+ * `Escape` clears; the X clears without leaving the box. Both exist because
28
+ * clearing by selecting-and-deleting is three gestures for the most common
29
+ * thing anyone does to a search box.
30
+ */
31
+ export function InlineKeyword({
32
+ value,
33
+ onChange,
34
+ testId,
35
+ autoFocus,
36
+ onEscape,
37
+ }: {
38
+ value: string;
39
+ onChange: (value: string) => void;
40
+ testId?: string;
41
+ autoFocus?: boolean;
42
+ /** Called on Escape with the box already empty — the collapsed bar closes it. */
43
+ onEscape?: () => void;
44
+ }): React.JSX.Element {
45
+ const [draft, setDraft] = useState(value);
46
+ const prev = useRef(value);
47
+ // An external change (a saved view, the URL, "Limpar") wins over the draft.
48
+ if (prev.current !== value) {
49
+ prev.current = value;
50
+ if (draft !== value) setDraft(value);
51
+ }
52
+ useEffect(() => {
53
+ if (draft === value) return undefined;
54
+ const timer = setTimeout(() => onChange(draft), SEARCH_DEBOUNCE_MS);
55
+ return () => clearTimeout(timer);
56
+ }, [draft, value, onChange]);
57
+
58
+ return (
59
+ <TextField
60
+ size="small"
61
+ value={draft}
62
+ autoFocus={autoFocus}
63
+ onChange={(event) => setDraft(event.target.value)}
64
+ onKeyDown={(event) => {
65
+ if (event.key === "Escape") {
66
+ event.preventDefault();
67
+ if (draft === "") onEscape?.();
68
+ setDraft("");
69
+ return;
70
+ }
71
+ // Enter still commits IMMEDIATELY, ahead of the debounce — someone who
72
+ // presses it has decided, and waiting out the timer reads as a stall.
73
+ if (event.key !== "Enter") return;
74
+ event.preventDefault();
75
+ if (draft !== value) onChange(draft);
76
+ }}
77
+ placeholder="Buscar…"
78
+ inputProps={{ "aria-label": "Buscar em todas as colunas", "data-testid": testId }}
79
+ InputProps={{
80
+ startAdornment: (
81
+ <InputAdornment position="start">
82
+ <SearchIcon sx={{ fontSize: 16, color: "text.secondary" }} />
83
+ </InputAdornment>
84
+ ),
85
+ endAdornment: draft ? (
86
+ <InputAdornment position="end">
87
+ <IconButton
88
+ size="small"
89
+ aria-label="Limpar busca"
90
+ data-testid={testId ? `${testId}-clear` : undefined}
91
+ onClick={() => setDraft("")}
92
+ sx={{ p: 0.25 }}
93
+ >
94
+ <CloseIcon sx={{ fontSize: 14 }} />
95
+ </IconButton>
96
+ </InputAdornment>
97
+ ) : undefined,
98
+ }}
99
+ // Takes the free space and gives it up first — step 3 of the ladder,
100
+ // which needs no flag because flex does it (see `computeSplit`).
101
+ sx={{ flex: 1, minWidth: 200, maxWidth: 384 }}
102
+ />
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Step 4 of the ladder: the search as a magnifier.
108
+ *
109
+ * Carries a dot when a search is APPLIED. Collapsing a control that is
110
+ * currently filtering the list, with nothing saying so, is how an operator ends
111
+ * up staring at 3 rows of 214 and reading it as missing data.
112
+ */
113
+ export function CollapsedSearch({
114
+ active,
115
+ onOpen,
116
+ testId,
117
+ }: {
118
+ active: boolean;
119
+ onOpen: () => void;
120
+ testId?: string;
121
+ }): React.JSX.Element {
122
+ return (
123
+ <IconButton
124
+ size="small"
125
+ onClick={onOpen}
126
+ aria-label="Buscar"
127
+ aria-expanded={false}
128
+ data-testid={testId ? `${testId}-collapsed` : undefined}
129
+ sx={{
130
+ position: "relative",
131
+ border: 1,
132
+ borderRadius: 1,
133
+ borderColor: active ? "primary.main" : "divider",
134
+ color: active ? "primary.main" : "text.secondary",
135
+ bgcolor: active ? "action.selected" : "background.paper",
136
+ }}
137
+ >
138
+ <SearchIcon sx={{ fontSize: 18 }} />
139
+ {active && (
140
+ <Box
141
+ component="span"
142
+ sx={{
143
+ position: "absolute",
144
+ top: 2,
145
+ right: 2,
146
+ width: 6,
147
+ height: 6,
148
+ borderRadius: "50%",
149
+ bgcolor: "primary.main",
150
+ }}
151
+ />
152
+ )}
153
+ </IconButton>
154
+ );
155
+ }
156
+
@@ -0,0 +1,105 @@
1
+ "use client";
2
+
3
+ import { Checkbox } from "@mui/material";
4
+
5
+ import { Button } from "../../form/Button";
6
+ import { Box } from "../../../mui/Box";
7
+ import { Text } from "../../typography/Text";
8
+
9
+ /**
10
+ * SELECT-ALL FOR THE LAYOUTS THAT HAVE NO HEADER ROW.
11
+ *
12
+ * The table gets this for free: its `<thead>` carries a checkbox, so beginning
13
+ * a selection is one click on a control that sits with the rows it selects.
14
+ * The cards, the list and the board have no header at all — so without this
15
+ * there is NO way to start a selection except clicking an item first, and
16
+ * "select everything on this page" costs one click per row.
17
+ *
18
+ * It is deliberately NOT more toolbar chrome. It sits directly above the
19
+ * content it selects, which is exactly where the table's `<thead>` checkbox
20
+ * sits; putting it in the toolbar would separate the control from its object
21
+ * and would also be the third place in this component that renders a
22
+ * select-all.
23
+ *
24
+ * Renders nothing for an empty page: a select-all over zero rows is a control
25
+ * that cannot do anything, and reserving its band would make the empty state
26
+ * sit lower in the cards than it does in the table.
27
+ */
28
+ export function SelectAllStrip<T>({
29
+ rows,
30
+ getRowId,
31
+ selectedIds,
32
+ onChange,
33
+ testIdPrefix,
34
+ }: {
35
+ rows: T[];
36
+ getRowId: (row: T) => string | number;
37
+ selectedIds: Set<string | number>;
38
+ onChange: (next: Set<string | number>) => void;
39
+ testIdPrefix: string;
40
+ }): React.JSX.Element | null {
41
+ if (rows.length === 0) return null;
42
+ const pageIds = rows.map(getRowId);
43
+ const selectedHere = pageIds.filter((id) => selectedIds.has(id)).length;
44
+ const all = selectedHere === pageIds.length;
45
+ // INDETERMINATE, not just checked/unchecked: with 3 of 25 selected, an
46
+ // unchecked box says "nothing is selected" and a checked one says
47
+ // "everything is". Both are wrong, and the third state is the honest one.
48
+ const some = selectedHere > 0 && !all;
49
+
50
+ const toggle = (): void => {
51
+ if (all) {
52
+ // Clear only THIS page's ids — a selection made on another page is not
53
+ // this control's to discard.
54
+ const next = new Set(selectedIds);
55
+ pageIds.forEach((id) => next.delete(id));
56
+ onChange(next);
57
+ return;
58
+ }
59
+ onChange(new Set([...selectedIds, ...pageIds]));
60
+ };
61
+
62
+ return (
63
+ <Box
64
+ data-testid={`${testIdPrefix}-select-all-strip`}
65
+ sx={{
66
+ display: "flex",
67
+ alignItems: "center",
68
+ gap: 1,
69
+ mt: 1.5,
70
+ px: 1,
71
+ py: 0.75,
72
+ borderBottom: 1,
73
+ borderColor: "divider",
74
+ bgcolor: "action.hover",
75
+ borderRadius: 1,
76
+ }}
77
+ >
78
+ <Checkbox
79
+ size="small"
80
+ checked={all}
81
+ indeterminate={some}
82
+ onChange={toggle}
83
+ data-testid={`${testIdPrefix}-select-all-box`}
84
+ inputProps={{ "aria-label": "Selecionar todos nesta página" }}
85
+ sx={{ p: 0.5 }}
86
+ />
87
+ <Button
88
+ variant="text"
89
+ size="sm"
90
+ color="secondary"
91
+ onClick={toggle}
92
+ dataTestId={`${testIdPrefix}-select-all-toggle`}
93
+ >
94
+ {all ? "Limpar seleção" : "Selecionar tudo"}
95
+ </Button>
96
+ <Text variant="caption" as="span">
97
+ {/* "nesta página" is load-bearing: the scope tabs above show
98
+ whole-query totals, so an unqualified count here would read as one. */}
99
+ <Box component="span" sx={{ ml: "auto", color: "text.disabled", whiteSpace: "nowrap" }}>
100
+ {rows.length} nesta página
101
+ </Box>
102
+ </Text>
103
+ </Box>
104
+ );
105
+ }
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+
5
+ /** The multi-select model, identical in every layout (table, cards, board). */
6
+ interface DataViewsSelection<T> {
7
+ selectedIds: Set<string | number>;
8
+ setSelectedIds: (ids: Set<string | number>) => void;
9
+ /** Add/remove ONE id — what a card's or board card's checkbox calls. */
10
+ toggleId: (id: string | number) => void;
11
+ /** The selected rows, narrowed to what is currently rendered. */
12
+ selectedRows: T[];
13
+ clearSelection: () => void;
14
+ selectAll: () => void;
15
+ }
16
+
17
+ /**
18
+ * Owns the DataViews selection. ONE set of ids across every layout, so switching
19
+ * from the board to the table (or to the cards) keeps exactly the same rows
20
+ * selected and offers exactly the same bulk actions.
21
+ *
22
+ * Extracted from {@link useDataViewsState} for that hook's size budget, and
23
+ * because `toggleId` needs a functional update: two cards toggled in the same
24
+ * tick would otherwise both read the pre-render set and the second would drop
25
+ * the first's change.
26
+ */
27
+ export function useSelection<T>(
28
+ matched: T[],
29
+ getRowId: (row: T) => string | number,
30
+ ): DataViewsSelection<T> {
31
+ const [selectedIds, setSelectedIds] = useState<Set<string | number>>(new Set());
32
+ const selectedRows = useMemo(
33
+ () => matched.filter((row) => selectedIds.has(getRowId(row))),
34
+ [matched, selectedIds, getRowId],
35
+ );
36
+ return {
37
+ selectedIds,
38
+ setSelectedIds,
39
+ toggleId: (id) =>
40
+ setSelectedIds((prev) => {
41
+ const next = new Set(prev);
42
+ if (next.has(id)) next.delete(id);
43
+ else next.add(id);
44
+ return next;
45
+ }),
46
+ selectedRows,
47
+ clearSelection: () => setSelectedIds(new Set()),
48
+ selectAll: () => setSelectedIds(new Set(matched.map((row) => getRowId(row)))),
49
+ };
50
+ }