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