@12-apps/ui 1.6.2 → 1.7.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 (32) hide show
  1. package/package.json +4 -4
  2. package/src/components/data-display/DataViews/DataViewsTableBase.tsx +1 -1
  3. package/src/components/data-display/DataViews/RESPONSIVE-BEHAVIOUR.md +183 -0
  4. package/src/components/data-display/DataViews/data-views-bar-controls.tsx +119 -0
  5. package/src/components/data-display/DataViews/data-views-display-panel.tsx +48 -22
  6. package/src/components/data-display/DataViews/data-views-display-tabs.tsx +64 -31
  7. package/src/components/data-display/DataViews/data-views-export.tsx +2 -1
  8. package/src/components/data-display/DataViews/data-views-glyphs.tsx +16 -0
  9. package/src/components/data-display/DataViews/data-views-grid-bodies.tsx +5 -1
  10. package/src/components/data-display/DataViews/data-views-grid-parts.tsx +20 -14
  11. package/src/components/data-display/DataViews/data-views-inline-bar.tsx +134 -155
  12. package/src/components/data-display/DataViews/data-views-layout-context.tsx +21 -0
  13. package/src/components/data-display/DataViews/data-views-more-fields.tsx +291 -0
  14. package/src/components/data-display/DataViews/data-views-more-filters.tsx +151 -199
  15. package/src/components/data-display/DataViews/data-views-overflow-costs.ts +100 -0
  16. package/src/components/data-display/DataViews/data-views-overflow.ts +89 -73
  17. package/src/components/data-display/DataViews/data-views-preset-chips.tsx +100 -0
  18. package/src/components/data-display/DataViews/data-views-range-pill.tsx +27 -11
  19. package/src/components/data-display/DataViews/data-views-range-presets.ts +121 -0
  20. package/src/components/data-display/DataViews/data-views-range-types.ts +94 -0
  21. package/src/components/data-display/DataViews/data-views-scope-tabs.tsx +57 -0
  22. package/src/components/data-display/DataViews/data-views-scopes.tsx +2 -2
  23. package/src/components/data-display/DataViews/data-views-search.tsx +19 -1
  24. package/src/components/data-display/DataViews/data-views-shell-toolbar.tsx +4 -0
  25. package/src/components/data-display/DataViews/data-views-toolbar.tsx +31 -21
  26. package/src/components/data-display/DataViews/data-views-types.ts +14 -47
  27. package/src/components/data-display/DataViews/index.ts +1 -0
  28. package/src/components/data-display/DataViews/pedidos.server.ts +118 -0
  29. package/src/components/data-display/DataViews/use-data-views-state.ts +1 -1
  30. package/src/components/layout/ContentToolbar/ContentToolbar.tsx +16 -1
  31. package/src/components/layout/ContentToolbar/MultiSelectDropdown.tsx +9 -1
  32. package/src/components/layout/ContentToolbar/MultiSelectTriggers.tsx +39 -1
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useRef } from "react";
5
5
  import { Box } from "../../../mui/Box";
6
6
 
7
7
  /**
8
- * SCOPES — a page-level partition rendered as a strip of tabs above the toolbar.
8
+ * SCOPES — a page-level partition rendered as a strip of tabs under the toolbar.
9
9
  *
10
10
  * A scope is not a filter. Filters NARROW within the current partition and are
11
11
  * multi-select by design; a scope PARTITIONS and is exclusive. The distinction
@@ -302,7 +302,7 @@ const STRIP_SX = {
302
302
  } as const;
303
303
 
304
304
  /**
305
- * The scope strip: a horizontally scrolling tab list above the toolbar.
305
+ * The scope strip: a horizontally scrolling tab list under the toolbar.
306
306
  *
307
307
  * It SCROLLS rather than collapsing into a select on a narrow screen, which is
308
308
  * the opposite of what the filter pills do — and deliberately. The counts are
@@ -15,6 +15,21 @@ import { Box } from "../../../mui/Box";
15
15
  /** How long the box waits after the last keystroke before it queries. */
16
16
  const SEARCH_DEBOUNCE_MS = 350;
17
17
 
18
+ /**
19
+ * How wide the keyword box is allowed to be.
20
+ *
21
+ * The 200px floor is right while the box shares the row: a search you cannot
22
+ * read six characters in is not a search. It is WRONG once the box has been
23
+ * expanded out of its collapsed state on a narrow screen, where the cluster was
24
+ * sized for a magnifier — the floor cannot be met there, so the row scrolled
25
+ * sideways instead (154px of overhang at 320px). In that mode the box shrinks,
26
+ * and if what is left would still be unreadable the bar hands it the whole
27
+ * cluster instead (`searchTakeover`).
28
+ */
29
+ function boxWidth(fill: boolean): { flex: number; minWidth: number; maxWidth: number | "none" } {
30
+ return { flex: 1, minWidth: fill ? 0 : 200, maxWidth: fill ? "none" : 384 };
31
+ }
32
+
18
33
  /**
19
34
  * The bar's keyword box.
20
35
  *
@@ -34,6 +49,7 @@ export function InlineKeyword({
34
49
  testId,
35
50
  autoFocus,
36
51
  onEscape,
52
+ fill = false,
37
53
  }: {
38
54
  value: string;
39
55
  onChange: (value: string) => void;
@@ -41,6 +57,8 @@ export function InlineKeyword({
41
57
  autoFocus?: boolean;
42
58
  /** Called on Escape with the box already empty — the collapsed bar closes it. */
43
59
  onEscape?: () => void;
60
+ /** Take whatever width there is, with no minimum — see {@link boxWidth}. */
61
+ fill?: boolean;
44
62
  }): React.JSX.Element {
45
63
  const [draft, setDraft] = useState(value);
46
64
  const prev = useRef(value);
@@ -98,7 +116,7 @@ export function InlineKeyword({
98
116
  }}
99
117
  // Takes the free space and gives it up first — step 3 of the ladder,
100
118
  // which needs no flag because flex does it (see `computeSplit`).
101
- sx={{ flex: 1, minWidth: 200, maxWidth: 384 }}
119
+ sx={boxWidth(fill)}
102
120
  />
103
121
  );
104
122
  }
@@ -25,6 +25,7 @@ export function ShellToolbar<T extends Record<string, unknown>>({
25
25
  toolbarRightSlot,
26
26
  showInline,
27
27
  compactControls,
28
+ counterHidden,
28
29
  sortKinds,
29
30
  displayView,
30
31
  exportConfig,
@@ -46,6 +47,8 @@ export function ShellToolbar<T extends Record<string, unknown>>({
46
47
  showInline: boolean;
47
48
  /** Step 2 of the ladder: Exibir/Exportar as icons only. */
48
49
  compactControls?: boolean;
50
+ /** Step 5, last resort: drop the counter so the row still fits one line. */
51
+ counterHidden?: boolean;
49
52
  }): React.JSX.Element {
50
53
  const columnOptions: ColumnVisibilityOption[] = c.hideableColumns.map((col) => ({
51
54
  id: col.id,
@@ -61,6 +64,7 @@ export function ShellToolbar<T extends Record<string, unknown>>({
61
64
  filterControls={filterControls}
62
65
  barRef={barRef}
63
66
  compactControls={compactControls}
67
+ counterHidden={counterHidden}
64
68
  testIdPrefix={testIdPrefix}
65
69
  selectedRows={c.selectedRows}
66
70
  selectAll={c.selectAll}
@@ -60,6 +60,12 @@ export interface GridToolbarProps<T extends Record<string, unknown>> {
60
60
  * has already given them up.
61
61
  */
62
62
  compactControls?: boolean;
63
+ /**
64
+ * Step 5 of the ladder, and its floor: on a screen too narrow for even the
65
+ * fully-collapsed row, the counter is dropped so the controls still fit one
66
+ * line instead of spilling outside the toolbar.
67
+ */
68
+ counterHidden?: boolean;
63
69
  }
64
70
 
65
71
  /** The right-aligned toolbar controls: Sort By, the counter, zoom/layout/columns, filters. */
@@ -67,27 +73,31 @@ function ToolbarRightControls<T extends Record<string, unknown>>(props: GridTool
67
73
  const { testIdPrefix, matchedCount, totalCount, filterOpen, setFilterOpen } = props;
68
74
  return (
69
75
  <>
70
- <Text variant="caption" as="span">
71
- {/* Always rendered, never breakpointed: `RESERVED.counter` already
72
- prices it into the collapse ladder, so hiding it at `md` was the
73
- ladder and a breakpoint disagreeing about the same pixels — which
74
- is what broke the row at exactly 900px. */}
75
- <Box
76
- component="span"
77
- data-testid={`${testIdPrefix}-counter`}
78
- sx={{ whiteSpace: "nowrap" }}
79
- >
80
- {matchedCount} de {totalCount}
81
- </Box>
82
- </Text>
83
- {/* Divides the READING of the list from the controls that change it —
84
- the counter states what is on screen, everything right of here acts
85
- on it. */}
86
- <Divider
87
- orientation="vertical"
88
- flexItem
89
- sx={{ height: 20, alignSelf: "center", mx: 0.5 }}
90
- />
76
+ {/* MEASURED, never breakpointed: `RESERVED.counter` prices it into the
77
+ ladder, so hiding it at `md` was the ladder and a breakpoint
78
+ disagreeing about the same pixels — which broke the row at exactly
79
+ 900px. It is dropped only as the ladder's last rung. */}
80
+ {!props.counterHidden && (
81
+ <>
82
+ <Text variant="caption" as="span">
83
+ <Box
84
+ component="span"
85
+ data-testid={`${testIdPrefix}-counter`}
86
+ sx={{ whiteSpace: "nowrap" }}
87
+ >
88
+ {matchedCount} de {totalCount}
89
+ </Box>
90
+ </Text>
91
+ {/* Divides the READING of the list from the controls that change it —
92
+ the counter states what is on screen, everything right of here
93
+ acts on it. */}
94
+ <Divider
95
+ orientation="vertical"
96
+ flexItem
97
+ sx={{ height: 20, alignSelf: "center", mx: 0.5 }}
98
+ />
99
+ </>
100
+ )}
91
101
  {props.toolbarRightSlot}
92
102
  {/* Sort + columns + format, in ONE control — see DataViewsDisplayPanel. */}
93
103
  <DataViewsDisplayPanel
@@ -1,5 +1,8 @@
1
1
  import type { GridColumn, GridSort } from "../DataGrid";
2
2
  import type { DropdownMenuItem } from "../../navigation/DropdownMenu";
3
+ // A re-export does not bring the name into local scope, and the state/query
4
+ // types below are written in terms of it.
5
+ import type { RangeValue } from "./data-views-range-types";
3
6
 
4
7
  /**
5
8
  * Shared types for the reusable admin "DataViews" table (FUT-88): a filter bar
@@ -106,54 +109,18 @@ export interface FilterFieldConfig<T extends Record<string, unknown>> {
106
109
  }
107
110
 
108
111
  /**
109
- * A selected range for a range filter (either bound optional). Numbers for a
110
- * `number` field; `AAAA-MM-DD` day strings for a `day` one — which compare
111
- * correctly with `<`/`>` precisely because ISO days sort lexicographically.
112
+ * The range types live in `data-views-range-types` (this file outgrew the
113
+ * size gate); they are re-exported here so `data-views-types` stays the one
114
+ * name every consumer imports the model from.
112
115
  */
113
- export interface RangeValue {
114
- min?: number | string;
115
- max?: number | string;
116
- }
117
-
118
- /** What a range field measures — the input it renders and the bound it stores. */
119
- export type RangeFieldKind = "number" | "day";
120
-
121
- interface RangeFieldBase {
122
- /** Stable id; the key under `DataViewState.ranges`. */
123
- id: string;
124
- label: string;
125
- }
126
-
127
- /** "Filter by amount": price, stock, margin, order total. */
128
- export interface NumberRangeFieldConfig<T extends Record<string, unknown>> extends RangeFieldBase {
129
- kind?: "number";
130
- /** Reads the row's numeric value for this range. */
131
- accessor: (row: T) => number | null | undefined;
132
- /** Optional unit suffix shown next to the inputs (e.g. "R$", "un"). */
133
- unit?: string;
134
- /** Numeric input step. Defaults to 1. */
135
- step?: number;
136
- }
137
-
138
- /**
139
- * "Filter by period": a pair of INCLUSIVE calendar days, rendered as two native
140
- * date inputs. Inclusive is the reason a day range is its own kind rather than a
141
- * number over timestamps — coercing an `até` of `2026-07-15` to a instant lands
142
- * on that day's midnight and drops the whole final day (FUT-668).
143
- */
144
- export interface DayRangeFieldConfig<T extends Record<string, unknown>> extends RangeFieldBase {
145
- kind: "day";
146
- /** Reads the row's `AAAA-MM-DD` day for this range. */
147
- accessor: (row: T) => string | null | undefined;
148
- }
149
-
150
- /**
151
- * A min/max range filter's configuration. Complements the multi-select pills.
152
- * A row with a null/undefined value never matches a bounded range.
153
- */
154
- export type RangeFieldConfig<T extends Record<string, unknown>> =
155
- | NumberRangeFieldConfig<T>
156
- | DayRangeFieldConfig<T>;
116
+ export type {
117
+ DayRangeFieldConfig,
118
+ NumberRangeFieldConfig,
119
+ RangeFieldConfig,
120
+ RangeFieldKind,
121
+ RangePreset,
122
+ RangeValue,
123
+ } from "./data-views-range-types";
157
124
 
158
125
  /** A grid column that can join the search scan and the column-visibility menu. */
159
126
  export interface DataViewColumn<T extends Record<string, unknown>> extends GridColumn<T> {
@@ -35,6 +35,7 @@ export type {
35
35
  RangeFieldConfig,
36
36
  RangeFieldKind,
37
37
  RangeValue,
38
+ RangePreset,
38
39
  NumberRangeFieldConfig,
39
40
  DayRangeFieldConfig,
40
41
  DataViewPersistence,
@@ -0,0 +1,118 @@
1
+ import type { DataViewQuery, RangeValue } from "./data-views-types";
2
+
3
+ /**
4
+ * THE "SERVER" FOR THE PEDIDOS STORY.
5
+ *
6
+ * Server mode means the grid does not filter, sort or paginate — it emits the
7
+ * query and renders exactly the page it is handed back. A story that wires
8
+ * `onQueryChange` to a no-op spy therefore looks broken in a specific and
9
+ * misleading way: the pills apply, the counter never moves, and every row
10
+ * stays. The controls are working; there is simply nobody on the other end.
11
+ *
12
+ * So this is the other end. It is deliberately fenced into its own module and
13
+ * written the way the real endpoint is: it is the ONLY code allowed to narrow
14
+ * the dataset, and the story never reaches past `data`.
15
+ */
16
+
17
+ /** The subset of a row this fake backend knows how to query. */
18
+ export interface ServerRow extends Record<string, unknown> {
19
+ pedido: string;
20
+ cliente: string;
21
+ metodo: string;
22
+ pagamento: string;
23
+ situacao: string;
24
+ dataIso: string;
25
+ valor: number;
26
+ }
27
+
28
+ export interface ServerPage<T> {
29
+ data: T[];
30
+ totalCount: number;
31
+ /**
32
+ * Per-scope totals over search + pills + ranges, IGNORING the active scope —
33
+ * computed with it applied, every inactive tab reads zero.
34
+ */
35
+ scopeCounts: Record<string, number>;
36
+ }
37
+
38
+ /** A row matches a pill field when ANY selected value matches. */
39
+ function matchesPills(row: ServerRow, pills: Record<string, string[]>): boolean {
40
+ return Object.entries(pills).every(([fieldId, values]) => {
41
+ if (!values || values.length === 0) return true;
42
+ return values.includes(String(row[fieldId] ?? ""));
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Ranges. A DAY bound is compared as an ISO STRING, never coerced to a
48
+ * timestamp: turning an `até` of 2026-07-15 into an instant lands on that day's
49
+ * midnight and silently drops the final day (FUT-668). Both ends are inclusive.
50
+ */
51
+ function matchesRanges(row: ServerRow, ranges: Record<string, RangeValue>): boolean {
52
+ return Object.entries(ranges).every(([fieldId, range]) => {
53
+ if (!range || (range.min == null && range.max == null)) return true;
54
+ const value = fieldId === "data" ? row.dataIso : row.valor;
55
+ if (value == null) return false;
56
+ if (range.min != null && value < range.min) return false;
57
+ if (range.max != null && value > range.max) return false;
58
+ return true;
59
+ });
60
+ }
61
+
62
+ function matchesSearch(row: ServerRow, search: string): boolean {
63
+ const term = search.trim().toLowerCase();
64
+ if (!term) return true;
65
+ return [row.pedido, row.cliente, row.metodo].some((field) =>
66
+ String(field).toLowerCase().includes(term),
67
+ );
68
+ }
69
+
70
+ /** The scopes this story declares, as predicates over a row's `situacao`. */
71
+ const SCOPES: Record<string, (row: ServerRow) => boolean> = {
72
+ todos: () => true,
73
+ "Em aberto": (row) => row.situacao === "Em aberto",
74
+ Cancelado: (row) => row.situacao === "Cancelado",
75
+ };
76
+
77
+ function compare(a: ServerRow, b: ServerRow, id: string): number {
78
+ const left = a[id];
79
+ const right = b[id];
80
+ if (typeof left === "number" && typeof right === "number") return left - right;
81
+ return String(left ?? "").localeCompare(String(right ?? ""), "pt-BR");
82
+ }
83
+
84
+ /**
85
+ * Answer one query: narrow, count, scope, sort, then cut the page.
86
+ *
87
+ * `scopeCounts` is computed BEFORE the scope is applied, which is the whole
88
+ * reason it cannot be done in the browser — a count taken from the loaded page
89
+ * is wrong the moment there is a second page.
90
+ */
91
+ export function queryPedidos(rows: ServerRow[], query: DataViewQuery): ServerPage<ServerRow> {
92
+ const narrowed = rows.filter(
93
+ (row) =>
94
+ matchesSearch(row, query.search) &&
95
+ matchesPills(row, query.pills) &&
96
+ matchesRanges(row, query.ranges ?? {}),
97
+ );
98
+
99
+ const scopeCounts = Object.fromEntries(
100
+ Object.entries(SCOPES).map(([id, predicate]) => [id, narrowed.filter(predicate).length]),
101
+ );
102
+
103
+ const predicate = query.scope ? SCOPES[query.scope] : undefined;
104
+ const scoped = predicate ? narrowed.filter(predicate) : narrowed;
105
+
106
+ const sorted = [...scoped];
107
+ const sort = query.sortBy?.[0];
108
+ if (sort) {
109
+ sorted.sort((a, b) => (sort.dir === "asc" ? compare(a, b, sort.id) : compare(b, a, sort.id)));
110
+ }
111
+
112
+ const start = (query.page - 1) * query.pageSize;
113
+ return {
114
+ data: sorted.slice(start, start + query.pageSize),
115
+ totalCount: scoped.length,
116
+ scopeCounts,
117
+ };
118
+ }
@@ -58,7 +58,7 @@ interface UseDataViewsStateArgs<T extends Record<string, unknown>> {
58
58
  */
59
59
  server?: DataViewServer;
60
60
  /**
61
- * The page-level partition rendered as tabs above the toolbar. Requires server
61
+ * The page-level partition rendered as tabs under the toolbar. Requires server
62
62
  * mode: a scope is applied at the BACKEND, never in the browser.
63
63
  */
64
64
  scopes?: ScopeConfig[];
@@ -120,7 +120,22 @@ function BrowsingClusters({
120
120
  {/* minWidth:0 lets this cluster be the one that gives up width as the row
121
121
  * tightens, which is what the filter overflow measures against. */}
122
122
  {leadingControls !== undefined && (
123
- <Box sx={{ display: 'flex', alignItems: 'center', flexGrow: 1, minWidth: 0 }}>
123
+ <Box
124
+ sx={{
125
+ display: 'flex',
126
+ alignItems: 'center',
127
+ flexGrow: 1,
128
+ minWidth: 0,
129
+ // The collapse ladder has a FLOOR — below roughly 400px even the
130
+ // fully-collapsed row does not fit, and its controls cannot shrink
131
+ // (they are flexShrink:0 so an over-packed row sheds instead of
132
+ // squeezing). Past that point this scrolls rather than letting them
133
+ // paint outside the toolbar, which is what a narrow phone did.
134
+ overflowX: 'auto',
135
+ // No scrollbar gutter stealing height on the common case.
136
+ scrollbarWidth: 'thin',
137
+ }}
138
+ >
124
139
  {leadingControls}
125
140
  </Box>
126
141
  )}
@@ -93,7 +93,15 @@ export function MultiSelectDropdown<TValue extends string = string>({
93
93
 
94
94
  const renderTrigger = (): React.JSX.Element => {
95
95
  if (stacked) return <StackedTrigger label={label} {...triggerProps} />;
96
- if (layout === 'pill') return <PillTrigger label={label} selectedCount={selected.size} {...triggerProps} />;
96
+ if (layout === 'pill')
97
+ return (
98
+ <PillTrigger
99
+ label={label}
100
+ selectedCount={selected.size}
101
+ onClear={onClear}
102
+ {...triggerProps}
103
+ />
104
+ );
97
105
  return <InlineTrigger label={label} {...triggerProps} />;
98
106
  };
99
107
 
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import CloseIcon from '@mui/icons-material/Close';
3
4
  import ChevronDownIcon from '@mui/icons-material/KeyboardArrowDown';
4
5
  import { Box, Button, Typography } from '@mui/material';
5
6
  import React, { useId } from 'react';
@@ -107,7 +108,8 @@ export function PillTrigger({
107
108
  onOpen,
108
109
  testId,
109
110
  selectedCount,
110
- }: TriggerProps & { selectedCount: number }): React.JSX.Element {
111
+ onClear,
112
+ }: TriggerProps & { selectedCount: number; onClear?: () => void }): React.JSX.Element {
111
113
  const active = selectedCount > 0;
112
114
  return (
113
115
  <Button
@@ -118,6 +120,42 @@ export function PillTrigger({
118
120
  aria-haspopup="menu"
119
121
  aria-expanded={open}
120
122
  onClick={onOpen}
123
+ // Clearing lives ON the pill: the filter and the way to remove it are one
124
+ // control, so removing one does not mean finding it again in a second
125
+ // list somewhere else on the page.
126
+ startIcon={
127
+ active && onClear ? (
128
+ <Box
129
+ component="span"
130
+ role="button"
131
+ tabIndex={0}
132
+ aria-label={`Limpar ${label}`}
133
+ data-testid={testId ? `${testId}-clear` : undefined}
134
+ onClick={(event: React.MouseEvent) => {
135
+ // The pill's own click opens the menu; clearing must not.
136
+ event.stopPropagation();
137
+ onClear();
138
+ }}
139
+ onKeyDown={(event: React.KeyboardEvent) => {
140
+ if (event.key !== 'Enter' && event.key !== ' ') return;
141
+ event.preventDefault();
142
+ event.stopPropagation();
143
+ onClear();
144
+ }}
145
+ sx={{
146
+ display: 'inline-flex',
147
+ alignItems: 'center',
148
+ justifyContent: 'center',
149
+ borderRadius: '50%',
150
+ cursor: 'pointer',
151
+ color: 'primary.main',
152
+ '&:hover': { bgcolor: 'primary.main', color: 'primary.contrastText' },
153
+ }}
154
+ >
155
+ <CloseIcon sx={{ fontSize: 14 }} />
156
+ </Box>
157
+ ) : undefined
158
+ }
121
159
  endIcon={<ChevronDownIcon sx={{ fontSize: 16 }} />}
122
160
  sx={{
123
161
  borderRadius: 999,