@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
@@ -11,49 +11,21 @@
11
11
  * and a page wanting one had to hang its own inputs outside the grid, outside
12
12
  * `DataViewState`, and outside the grid's "Limpar filtros" (FUT-668).
13
13
  */
14
+ import CloseIcon from '@mui/icons-material/Close';
14
15
  import ChevronDownIcon from '@mui/icons-material/KeyboardArrowDown';
15
16
  import { Box, Button, Popover, TextField, Typography } from '@mui/material';
16
17
  import React, { useState } from 'react';
17
18
 
18
19
  import { TableFilter } from '../../layout/TableFilter';
20
+ import {
21
+ isRangeInverted,
22
+ isRangeSet,
23
+ numericRange,
24
+ rangeChipLabel,
25
+ } from './data-views-range-values';
19
26
  import type { RangeFieldConfig, RangeValue } from './data-views-types';
20
27
 
21
- /** Is either bound set? Drives the pill's active styling and the chip. */
22
- export function isRangeSet(range: RangeValue | undefined): boolean {
23
- return Boolean(range && (range.min != null || range.max != null));
24
- }
25
-
26
- /** `AAAA-MM-DD` → `DD/MM` — the compact form a chip shows. */
27
- function shortDay(day: string): string {
28
- const [, month, date] = day.split('-');
29
- return month && date ? `${date}/${month}` : day;
30
- }
31
28
 
32
- /** One bound, rendered for the chip: money in reais, a day as `DD/MM`. */
33
- function boundLabel<T extends Record<string, unknown>>(
34
- field: RangeFieldConfig<T>,
35
- bound: number | string,
36
- ): string {
37
- if (field.kind === 'day') return shortDay(String(bound));
38
- return field.unit ? `${field.unit} ${bound}` : String(bound);
39
- }
40
-
41
- /**
42
- * The chip text for an applied range: "Data: 01/07–31/07" when both ends are
43
- * set, and a one-sided window as the inequality it actually is ("Valor: ≥ R$ 20")
44
- * rather than an en-dash with a blank side.
45
- */
46
- export function rangeChipLabel<T extends Record<string, unknown>>(
47
- field: RangeFieldConfig<T>,
48
- range: RangeValue,
49
- ): string {
50
- const { min, max } = range;
51
- if (min != null && max != null) {
52
- return `${field.label}: ${boundLabel(field, min)}–${boundLabel(field, max)}`;
53
- }
54
- if (min != null) return `${field.label}: ≥ ${boundLabel(field, min)}`;
55
- return `${field.label}: ≤ ${boundLabel(field, max as number | string)}`;
56
- }
57
29
 
58
30
  /** A bound as the input's string value ('' when unset). */
59
31
  function inputValue(bound: number | string | undefined): string {
@@ -106,6 +78,7 @@ function BoundField<T extends Record<string, unknown>>({
106
78
  );
107
79
  }
108
80
 
81
+
109
82
  /**
110
83
  * The `De`/`Até` pair for a range — shared by the pill's popover and the
111
84
  * slide-in panel's day fields, so the two surfaces cannot drift on what a bound
@@ -122,22 +95,46 @@ export function RangeBounds<T extends Record<string, unknown>>({
122
95
  onChange: (range: RangeValue) => void;
123
96
  testId: string;
124
97
  }): React.JSX.Element {
98
+ const inverted = isRangeInverted(value);
125
99
  return (
126
- <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }} data-testid={`${testId}-panel`}>
127
- <BoundField
128
- field={field}
129
- label="De"
130
- value={value.min}
131
- onChange={(min) => onChange({ ...value, min })}
132
- testId={`${testId}-min`}
133
- />
134
- <BoundField
135
- field={field}
136
- label="Até"
137
- value={value.max}
138
- onChange={(max) => onChange({ ...value, max })}
139
- testId={`${testId}-max`}
140
- />
100
+ <Box data-testid={`${testId}-panel`}>
101
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
102
+ <BoundField
103
+ field={field}
104
+ label="De"
105
+ value={value.min}
106
+ onChange={(min) => onChange({ ...value, min })}
107
+ testId={`${testId}-min`}
108
+ />
109
+ <BoundField
110
+ field={field}
111
+ label="Até"
112
+ value={value.max}
113
+ onChange={(max) => onChange({ ...value, max })}
114
+ testId={`${testId}-max`}
115
+ />
116
+ </Box>
117
+ {inverted && (
118
+ <Typography
119
+ component="p"
120
+ data-testid={`${testId}-inverted`}
121
+ sx={{ mt: 0.75, fontSize: '0.75rem', color: 'error.main' }}
122
+ >
123
+ O início precisa ser menor que o fim.
124
+ </Typography>
125
+ )}
126
+ {/* BOTH days are in the result. Saying so is FUT-668's other half: the
127
+ bounds compare as ISO strings precisely so `até` keeps its whole day,
128
+ and nothing on screen would otherwise tell the merchant that. */}
129
+ {field.kind === 'day' && !inverted && (
130
+ <Typography
131
+ component="p"
132
+ data-testid={`${testId}-inclusive`}
133
+ sx={{ mt: 0.75, fontSize: '0.75rem', color: 'text.disabled' }}
134
+ >
135
+ Ambas as datas entram no resultado.
136
+ </Typography>
137
+ )}
141
138
  </Box>
142
139
  );
143
140
  }
@@ -147,15 +144,116 @@ export function RangeBounds<T extends Record<string, unknown>>({
147
144
  * each edit is a query) and the popover stays open, which is what lets a
148
145
  * merchant set "de" and then "até" without re-opening it.
149
146
  */
147
+ /** An applied range clears from the pill itself — re-opening to empty two fields is three gestures for one intent. */
148
+ function ClearAffordance({
149
+ label,
150
+ onClear,
151
+ testId,
152
+ }: {
153
+ label: string;
154
+ onClear: () => void;
155
+ testId: string;
156
+ }): React.JSX.Element {
157
+ const clear = (event: React.SyntheticEvent): void => {
158
+ event.stopPropagation();
159
+ onClear();
160
+ };
161
+ return (
162
+ <Box
163
+ component="span"
164
+ role="button"
165
+ tabIndex={0}
166
+ aria-label={`Limpar ${label}`}
167
+ data-testid={testId}
168
+ onClick={clear}
169
+ onKeyDown={(event: React.KeyboardEvent) => {
170
+ if (event.key !== 'Enter' && event.key !== ' ') return;
171
+ event.preventDefault();
172
+ clear(event);
173
+ }}
174
+ sx={{ display: 'inline-flex', borderRadius: 999, p: 0.125, '&:hover': { bgcolor: 'action.selected' } }}
175
+ >
176
+ <CloseIcon sx={{ fontSize: 14 }} />
177
+ </Box>
178
+ );
179
+ }
180
+
181
+ /** The pill button: its label carries the applied window, and its tone the state. */
182
+ function RangeTrigger<T extends Record<string, unknown>>({
183
+ field,
184
+ value,
185
+ onChange,
186
+ onOpen,
187
+ open,
188
+ testId,
189
+ }: {
190
+ field: RangeFieldConfig<T>;
191
+ value: RangeValue;
192
+ onChange: (range: RangeValue) => void;
193
+ onOpen: (anchor: HTMLElement) => void;
194
+ open: boolean;
195
+ testId: string;
196
+ }): React.JSX.Element {
197
+ const active = isRangeSet(value);
198
+ const inverted = isRangeInverted(value);
199
+ // An INVERTED window gets its own tone rather than the ordinary "applied"
200
+ // one: it is applied, and it matches nothing, and those are different states.
201
+ const tone = inverted ? 'error.main' : active ? 'primary.main' : 'divider';
202
+ return (
203
+ <Button
204
+ variant="outlined"
205
+ color="inherit"
206
+ size="small"
207
+ data-testid={testId}
208
+ aria-haspopup="dialog"
209
+ aria-expanded={open}
210
+ onClick={(event) => onOpen(event.currentTarget)}
211
+ endIcon={
212
+ active ? (
213
+ <ClearAffordance label={field.label} onClear={() => onChange({})} testId={`${testId}-clear-inline`} />
214
+ ) : (
215
+ <ChevronDownIcon sx={{ fontSize: 16 }} />
216
+ )
217
+ }
218
+ sx={{
219
+ borderRadius: 999,
220
+ height: 34,
221
+ px: 1.5,
222
+ maxWidth: 280,
223
+ color: inverted ? 'error.main' : 'text.primary',
224
+ fontWeight: 600,
225
+ fontSize: '0.8125rem',
226
+ textTransform: 'none',
227
+ whiteSpace: 'nowrap',
228
+ borderColor: tone,
229
+ bgcolor: active ? 'action.selected' : 'background.paper',
230
+ '&:hover': {
231
+ borderColor: active ? tone : 'text.primary',
232
+ bgcolor: active ? 'action.selected' : 'action.hover',
233
+ },
234
+ }}
235
+ >
236
+ {/* The applied bounds, in the label — a pill reading only "Valor" hides
237
+ the very thing that is narrowing the list. */}
238
+ <Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
239
+ {active ? rangeChipLabel(field, value) : field.label}
240
+ </Box>
241
+ </Button>
242
+ );
243
+ }
244
+
150
245
  export function RangePill<T extends Record<string, unknown>>({
151
246
  field,
152
247
  value,
153
248
  onChange,
249
+ onOpenChange,
154
250
  testIdPrefix,
155
251
  }: {
156
252
  field: RangeFieldConfig<T>;
157
253
  value: RangeValue;
158
254
  onChange: (range: RangeValue) => void;
255
+ /** So a measured toolbar can hold still while this is open. */
256
+ onOpenChange?: (open: boolean) => void;
159
257
  testIdPrefix: string;
160
258
  }): React.JSX.Element {
161
259
  const [anchor, setAnchor] = useState<HTMLElement | null>(null);
@@ -163,37 +261,24 @@ export function RangePill<T extends Record<string, unknown>>({
163
261
  const testId = `${testIdPrefix}-range-${field.id}`;
164
262
  return (
165
263
  <>
166
- <Button
167
- variant="outlined"
168
- color="inherit"
169
- size="small"
170
- data-testid={testId}
171
- aria-haspopup="dialog"
172
- aria-expanded={Boolean(anchor)}
173
- onClick={(event) => setAnchor(event.currentTarget)}
174
- endIcon={<ChevronDownIcon sx={{ fontSize: 16 }} />}
175
- sx={{
176
- borderRadius: 999,
177
- height: 34,
178
- px: 1.5,
179
- color: 'text.primary',
180
- fontWeight: 600,
181
- fontSize: '0.8125rem',
182
- textTransform: 'none',
183
- borderColor: active ? 'primary.main' : 'divider',
184
- bgcolor: active ? 'action.selected' : 'background.paper',
185
- '&:hover': {
186
- borderColor: active ? 'primary.main' : 'text.primary',
187
- bgcolor: active ? 'action.selected' : 'action.hover',
188
- },
264
+ <RangeTrigger
265
+ field={field}
266
+ value={value}
267
+ onChange={onChange}
268
+ onOpen={(element) => {
269
+ setAnchor(element);
270
+ onOpenChange?.(true);
189
271
  }}
190
- >
191
- {field.label}
192
- </Button>
272
+ open={Boolean(anchor)}
273
+ testId={testId}
274
+ />
193
275
  <Popover
194
276
  open={Boolean(anchor)}
195
277
  anchorEl={anchor}
196
- onClose={() => setAnchor(null)}
278
+ onClose={() => {
279
+ setAnchor(null);
280
+ onOpenChange?.(false);
281
+ }}
197
282
  anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
198
283
  slotProps={{ paper: { sx: { p: 2, mt: 0.5 } } }}
199
284
  >
@@ -222,21 +307,6 @@ export function RangePill<T extends Record<string, unknown>>({
222
307
  );
223
308
  }
224
309
 
225
- /**
226
- * A stored range narrowed to NUMBERS, for the slide-in panel's numeric control.
227
- * A bound that doesn't parse is dropped rather than passed on as `NaN`, which
228
- * the control would render as an empty box that still filtered.
229
- */
230
- export function numericRange(range: RangeValue): { min?: number; max?: number } {
231
- const parse = (bound: number | string | undefined): number | undefined => {
232
- if (bound == null || bound === "") return undefined;
233
- const value = Number(bound);
234
- return Number.isNaN(value) ? undefined : value;
235
- };
236
- const min = parse(range.min);
237
- const max = parse(range.max);
238
- return { ...(min !== undefined ? { min } : {}), ...(max !== undefined ? { max } : {}) };
239
- }
240
310
 
241
311
  /**
242
312
  * One range on the SLIDE-IN panel (and the narrow-screen modal): the numeric
@@ -0,0 +1,69 @@
1
+ import type { RangeFieldConfig, RangeValue } from "./data-views-types";
2
+
3
+ /**
4
+ * What a range VALUE means, separate from the controls that edit one.
5
+ *
6
+ * Pure and component-free: whether a window is set, whether it is inverted, how
7
+ * it reads as a label, and its numeric form. The pill, the overflow panel and
8
+ * the filter panel all ask these questions, and none of them should have to
9
+ * import a component to do it.
10
+ */
11
+
12
+ /** Is either bound set? Drives the pill's active styling and the chip. */
13
+ export function isRangeSet(range: RangeValue | undefined): boolean {
14
+ return Boolean(range && (range.min != null || range.max != null));
15
+ }
16
+ /** `AAAA-MM-DD` → `DD/MM` — the compact form a chip shows. */
17
+ function shortDay(day: string): string {
18
+ const [, month, date] = day.split('-');
19
+ return month && date ? `${date}/${month}` : day;
20
+ }
21
+ /** One bound, rendered for the chip: money in reais, a day as `DD/MM`. */
22
+ function boundLabel<T extends Record<string, unknown>>(
23
+ field: RangeFieldConfig<T>,
24
+ bound: number | string,
25
+ ): string {
26
+ if (field.kind === 'day') return shortDay(String(bound));
27
+ return field.unit ? `${field.unit} ${bound}` : String(bound);
28
+ }
29
+ /**
30
+ * The chip text for an applied range: "Data: 01/07–31/07" when both ends are
31
+ * set, and a one-sided window as the inequality it actually is ("Valor: ≥ R$ 20")
32
+ * rather than an en-dash with a blank side.
33
+ */
34
+ export function rangeChipLabel<T extends Record<string, unknown>>(
35
+ field: RangeFieldConfig<T>,
36
+ range: RangeValue,
37
+ ): string {
38
+ const { min, max } = range;
39
+ if (min != null && max != null) {
40
+ return `${field.label}: ${boundLabel(field, min)}–${boundLabel(field, max)}`;
41
+ }
42
+ if (min != null) return `${field.label}: ≥ ${boundLabel(field, min)}`;
43
+ return `${field.label}: ≤ ${boundLabel(field, max as number | string)}`;
44
+ }
45
+ /**
46
+ * Is the window INVERTED — a `de` after its `até`?
47
+ *
48
+ * Not an error the merchant must clear before the list responds: it simply
49
+ * matches nothing. But an empty grid with no explanation reads as "there is no
50
+ * data", so the control says which of the two it is.
51
+ */
52
+ export function isRangeInverted(value: RangeValue): boolean {
53
+ return value.min != null && value.max != null && value.min > value.max;
54
+ }
55
+ /**
56
+ * A stored range narrowed to NUMBERS, for the slide-in panel's numeric control.
57
+ * A bound that doesn't parse is dropped rather than passed on as `NaN`, which
58
+ * the control would render as an empty box that still filtered.
59
+ */
60
+ export function numericRange(range: RangeValue): { min?: number; max?: number } {
61
+ const parse = (bound: number | string | undefined): number | undefined => {
62
+ if (bound == null || bound === "") return undefined;
63
+ const value = Number(bound);
64
+ return Number.isNaN(value) ? undefined : value;
65
+ };
66
+ const min = parse(range.min);
67
+ const max = parse(range.max);
68
+ return { ...(min !== undefined ? { min } : {}), ...(max !== undefined ? { max } : {}) };
69
+ }