@12-apps/ui 1.6.3 → 1.8.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 +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/base-card.tsx +93 -23
  5. package/src/components/data-display/DataViews/base-list-card-slots.tsx +256 -0
  6. package/src/components/data-display/DataViews/base-list-card.tsx +352 -0
  7. package/src/components/data-display/DataViews/card-surface.ts +297 -0
  8. package/src/components/data-display/DataViews/data-views-bar-controls.tsx +119 -0
  9. package/src/components/data-display/DataViews/data-views-display-panel.tsx +48 -22
  10. package/src/components/data-display/DataViews/data-views-display-tabs.tsx +64 -31
  11. package/src/components/data-display/DataViews/data-views-drag.tsx +135 -0
  12. package/src/components/data-display/DataViews/data-views-export.tsx +2 -1
  13. package/src/components/data-display/DataViews/data-views-glyphs.tsx +16 -0
  14. package/src/components/data-display/DataViews/data-views-grid-bodies.tsx +5 -1
  15. package/src/components/data-display/DataViews/data-views-grid-parts.tsx +20 -14
  16. package/src/components/data-display/DataViews/data-views-inline-bar.tsx +134 -155
  17. package/src/components/data-display/DataViews/data-views-layout-context.tsx +21 -0
  18. package/src/components/data-display/DataViews/data-views-more-fields.tsx +291 -0
  19. package/src/components/data-display/DataViews/data-views-more-filters.tsx +151 -199
  20. package/src/components/data-display/DataViews/data-views-overflow-costs.ts +100 -0
  21. package/src/components/data-display/DataViews/data-views-overflow.ts +89 -73
  22. package/src/components/data-display/DataViews/data-views-preset-chips.tsx +100 -0
  23. package/src/components/data-display/DataViews/data-views-range-pill.tsx +27 -11
  24. package/src/components/data-display/DataViews/data-views-range-presets.ts +121 -0
  25. package/src/components/data-display/DataViews/data-views-range-types.ts +94 -0
  26. package/src/components/data-display/DataViews/data-views-scope-tabs.tsx +57 -0
  27. package/src/components/data-display/DataViews/data-views-scopes.tsx +2 -2
  28. package/src/components/data-display/DataViews/data-views-search.tsx +19 -1
  29. package/src/components/data-display/DataViews/data-views-shell-toolbar.tsx +4 -0
  30. package/src/components/data-display/DataViews/data-views-toolbar.tsx +31 -21
  31. package/src/components/data-display/DataViews/data-views-types.ts +14 -47
  32. package/src/components/data-display/DataViews/index.ts +8 -0
  33. package/src/components/data-display/DataViews/list-card-rails.tsx +141 -0
  34. package/src/components/data-display/DataViews/pedidos.server.ts +118 -0
  35. package/src/components/data-display/DataViews/use-data-views-state.ts +1 -1
  36. package/src/components/data-display/DescriptionItem/DescriptionItem.tsx +26 -3
  37. package/src/components/layout/ContentToolbar/ContentToolbar.tsx +16 -1
  38. package/src/components/layout/ContentToolbar/MultiSelectDropdown.tsx +9 -1
  39. 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> {
@@ -6,6 +6,13 @@
6
6
  * wires them to its backend and framework router.
7
7
  */
8
8
  export { BaseCard } from "./base-card";
9
+ export { BaseListCard, type BaseListCardProps } from "./base-list-card";
10
+ export {
11
+ DragContainerProvider,
12
+ useDragItem,
13
+ type DragContainerValue,
14
+ type DragItemProps,
15
+ } from "./data-views-drag";
9
16
  export { DataViewsGrid } from "./DataViewsGrid";
10
17
  export { DataViewsTableBase, type DataViewsTableBaseProps } from "./DataViewsTableBase";
11
18
  export { toSavedViewSummary, CARD_ASPECT_RATIOS, DATA_VIEWS_LAYOUTS } from "./data-views-types";
@@ -35,6 +42,7 @@ export type {
35
42
  RangeFieldConfig,
36
43
  RangeFieldKind,
37
44
  RangeValue,
45
+ RangePreset,
38
46
  NumberRangeFieldConfig,
39
47
  DayRangeFieldConfig,
40
48
  DataViewPersistence,
@@ -0,0 +1,141 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, type ReactNode } from "react";
4
+
5
+ import { Box } from "../../../mui/Box";
6
+ import { DENSITY_ROW_PADDING, type DataViewsDensity } from "./data-views-layout-context";
7
+
8
+ /**
9
+ * THE LIST OWNS THE COLUMNS, NOT THE ROW.
10
+ *
11
+ * Every row laying itself out with flex produces a list where nothing lines up.
12
+ * The caption takes `flex: 1` and shoves the rest right; the value sits after
13
+ * the meta cluster with the same gap as any other pair, so `MÉTODO PIX
14
+ * R$ 13,90` reads as one continuous run and the only unlabelled thing in the
15
+ * row is the number that matters most; and because the status chip's width
16
+ * varies with its text (`Pago` vs `Em aberto` vs `Cancelado`), the money jitters
17
+ * left and right on every row down the page.
18
+ *
19
+ * So the rails are declared ONCE, here, and each card is `grid-template-columns:
20
+ * subgrid` spanning them. One set of edges for the whole list, the value on its
21
+ * own rail, the status in a slot wide enough for the longest label it will ever
22
+ * hold. Adding a row cannot move the column.
23
+ *
24
+ * Subgrid needs the card to be a DIRECT child of this grid — no wrapper element
25
+ * between them, which is why the group renders its children bare.
26
+ */
27
+
28
+ /** One rail. Fixed where alignment matters, flexible where it does not. */
29
+ export interface ListRails {
30
+ /** Reserved even when nothing drags: toggling drag mode must not shift the list. */
31
+ drag: string;
32
+ /** Reserved even when nothing is selectable, for the same reason. */
33
+ select: string;
34
+ leading: string;
35
+ /** The one column that absorbs spare width. */
36
+ caption: string;
37
+ meta: string;
38
+ /** The value's own rail, with a fixed right edge. */
39
+ value: string;
40
+ /** Wide enough for the longest status this list holds. */
41
+ status: string;
42
+ actions: string;
43
+ }
44
+
45
+ const DEFAULT_RAILS: ListRails = {
46
+ drag: "auto",
47
+ select: "auto",
48
+ leading: "auto",
49
+ caption: "minmax(0, 1fr)",
50
+ meta: "auto",
51
+ // `max-content` on the value, so the rail is as wide as the widest amount in
52
+ // the list and no wider — a fixed px guess is either clipping or dead space.
53
+ value: "max-content",
54
+ status: "max-content",
55
+ actions: "auto",
56
+ };
57
+
58
+ interface ListRailsValue {
59
+ density: DataViewsDensity;
60
+ /** Whether the gutters are held open even when empty. */
61
+ reserveGutters: boolean;
62
+ }
63
+
64
+ const ListRailsContext = createContext<ListRailsValue | null>(null);
65
+
66
+ /** Is this card inside a group that owns the rails? */
67
+ export function useListRails(): ListRailsValue | null {
68
+ return useContext(ListRailsContext);
69
+ }
70
+
71
+ /** The column template, as one `grid-template-columns` value. */
72
+ export function railsTemplate(rails: ListRails): string {
73
+ return [
74
+ rails.drag,
75
+ rails.select,
76
+ rails.leading,
77
+ rails.caption,
78
+ rails.meta,
79
+ rails.value,
80
+ rails.status,
81
+ rails.actions,
82
+ ].join(" ");
83
+ }
84
+
85
+ /** How many rails there are — the span a card claims. */
86
+ export const RAIL_COUNT = 8;
87
+
88
+ /**
89
+ * A list of {@link BaseListCard}s that share one set of columns.
90
+ *
91
+ * Also the natural home for the things a review pointed out belong to the list
92
+ * rather than the card: the density every row answers, and — when it lands —
93
+ * shift-click range selection and the single `aria-live` region a list needs
94
+ * (fifty polite live regions is a screen-reader nightmare, which is why the
95
+ * card no longer takes one).
96
+ */
97
+ export function ListCardGroup({
98
+ children,
99
+ density = "cozy",
100
+ rails,
101
+ reserveGutters = true,
102
+ gap,
103
+ dataTestId,
104
+ }: {
105
+ children: ReactNode;
106
+ density?: DataViewsDensity;
107
+ /** Override individual rails; anything omitted keeps its default. */
108
+ rails?: Partial<ListRails>;
109
+ /**
110
+ * Hold the drag and select gutters open even when nothing uses them.
111
+ *
112
+ * On by default: a selectable list and a read-only one should line up, and
113
+ * turning drag mode on should not shift every row sideways.
114
+ */
115
+ reserveGutters?: boolean;
116
+ /** Row gap. Defaults to the density's own spacing. */
117
+ gap?: number;
118
+ dataTestId?: string;
119
+ }): React.JSX.Element {
120
+ const resolved = { ...DEFAULT_RAILS, ...rails };
121
+ return (
122
+ <ListRailsContext.Provider value={{ density, reserveGutters }}>
123
+ <Box
124
+ data-testid={dataTestId}
125
+ sx={{
126
+ // The query container for every row inside it — a row cannot be one
127
+ // itself and a subgrid at the same time (containment drops subgrid).
128
+ // Every row in a list is the same width anyway, so the group is the
129
+ // honest place to ask the question.
130
+ containerType: "inline-size",
131
+ display: "grid",
132
+ gridTemplateColumns: railsTemplate(resolved),
133
+ alignItems: "center",
134
+ rowGap: gap ?? DENSITY_ROW_PADDING[density],
135
+ }}
136
+ >
137
+ {children}
138
+ </Box>
139
+ </ListRailsContext.Provider>
140
+ );
141
+ }
@@ -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[];
@@ -21,6 +21,21 @@ export interface DescriptionItemProps {
21
21
  */
22
22
  orientation?: 'vertical' | 'horizontal';
23
23
 
24
+ /**
25
+ * Which edge the label and value line up on.
26
+ *
27
+ * - `start` (default): both flush left, the reading order for a form or a
28
+ * description list.
29
+ * - `center`: both centred over each other — for a pair sitting in a column
30
+ * of its own, where a left-flush label over a shorter value looks unhinged
31
+ * from it.
32
+ * - `end`: both flush right, which is what a numeric column wants so its
33
+ * digits line up with the value above and below.
34
+ *
35
+ * Applies to `vertical`; a `horizontal` pair is already a single line.
36
+ */
37
+ align?: 'start' | 'center' | 'end';
38
+
24
39
  /**
25
40
  * Optional CSS class name
26
41
  */
@@ -54,13 +69,20 @@ export interface DescriptionItemProps {
54
69
  * />
55
70
  * ```
56
71
  */
72
+ /** `align` in the terms flexbox and CSS each want it. */
73
+ const FLEX_ALIGN = { start: 'flex-start', center: 'center', end: 'flex-end' } as const;
74
+
57
75
  // Vertical stacks the label above the value; horizontal sets them side by side
58
76
  // with the label sized to its content.
59
- const containerSx = (isHorizontal: boolean) => ({
77
+ const containerSx = (isHorizontal: boolean, align: 'start' | 'center' | 'end') => ({
60
78
  display: 'flex',
61
79
  flexDirection: isHorizontal ? ('row' as const) : ('column' as const),
62
- alignItems: isHorizontal ? 'center' : 'flex-start',
80
+ alignItems: isHorizontal ? 'center' : FLEX_ALIGN[align],
63
81
  gap: isHorizontal ? 1 : 0,
82
+ // `alignItems` places the two boxes; `textAlign` handles a label or value
83
+ // that WRAPS, whose second line would otherwise fall back to flush-left
84
+ // inside a box the alignment had already centred.
85
+ textAlign: align,
64
86
  });
65
87
 
66
88
  const labelStyle = (isHorizontal: boolean) => ({
@@ -79,6 +101,7 @@ export const DescriptionItem: React.FC<DescriptionItemProps> = ({
79
101
  label,
80
102
  value,
81
103
  orientation = 'vertical',
104
+ align = 'start',
82
105
  className,
83
106
  'data-testid': dataTestId,
84
107
  }) => {
@@ -88,7 +111,7 @@ export const DescriptionItem: React.FC<DescriptionItemProps> = ({
88
111
  return (
89
112
  <Box
90
113
  className={className}
91
- sx={containerSx(isHorizontal)}
114
+ sx={containerSx(isHorizontal, align)}
92
115
  data-testid={dataTestId || 'description-item'}
93
116
  >
94
117
  <Text
@@ -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