@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.
- package/package.json +4 -4
- package/src/components/data-display/DataViews/DataViewsTableBase.tsx +1 -1
- package/src/components/data-display/DataViews/RESPONSIVE-BEHAVIOUR.md +183 -0
- package/src/components/data-display/DataViews/base-card.tsx +93 -23
- package/src/components/data-display/DataViews/base-list-card-slots.tsx +256 -0
- package/src/components/data-display/DataViews/base-list-card.tsx +352 -0
- package/src/components/data-display/DataViews/card-surface.ts +297 -0
- package/src/components/data-display/DataViews/data-views-bar-controls.tsx +119 -0
- package/src/components/data-display/DataViews/data-views-display-panel.tsx +48 -22
- package/src/components/data-display/DataViews/data-views-display-tabs.tsx +64 -31
- package/src/components/data-display/DataViews/data-views-drag.tsx +135 -0
- package/src/components/data-display/DataViews/data-views-export.tsx +2 -1
- package/src/components/data-display/DataViews/data-views-glyphs.tsx +16 -0
- package/src/components/data-display/DataViews/data-views-grid-bodies.tsx +5 -1
- package/src/components/data-display/DataViews/data-views-grid-parts.tsx +20 -14
- package/src/components/data-display/DataViews/data-views-inline-bar.tsx +134 -155
- package/src/components/data-display/DataViews/data-views-layout-context.tsx +21 -0
- package/src/components/data-display/DataViews/data-views-more-fields.tsx +291 -0
- package/src/components/data-display/DataViews/data-views-more-filters.tsx +151 -199
- package/src/components/data-display/DataViews/data-views-overflow-costs.ts +100 -0
- package/src/components/data-display/DataViews/data-views-overflow.ts +89 -73
- package/src/components/data-display/DataViews/data-views-preset-chips.tsx +100 -0
- package/src/components/data-display/DataViews/data-views-range-pill.tsx +27 -11
- package/src/components/data-display/DataViews/data-views-range-presets.ts +121 -0
- package/src/components/data-display/DataViews/data-views-range-types.ts +94 -0
- package/src/components/data-display/DataViews/data-views-scope-tabs.tsx +57 -0
- package/src/components/data-display/DataViews/data-views-scopes.tsx +2 -2
- package/src/components/data-display/DataViews/data-views-search.tsx +19 -1
- package/src/components/data-display/DataViews/data-views-shell-toolbar.tsx +4 -0
- package/src/components/data-display/DataViews/data-views-toolbar.tsx +31 -21
- package/src/components/data-display/DataViews/data-views-types.ts +14 -47
- package/src/components/data-display/DataViews/index.ts +8 -0
- package/src/components/data-display/DataViews/list-card-rails.tsx +141 -0
- package/src/components/data-display/DataViews/pedidos.server.ts +118 -0
- package/src/components/data-display/DataViews/use-data-views-state.ts +1 -1
- package/src/components/data-display/DescriptionItem/DescriptionItem.tsx +26 -3
- package/src/components/layout/ContentToolbar/ContentToolbar.tsx +16 -1
- package/src/components/layout/ContentToolbar/MultiSelectDropdown.tsx +9 -1
- 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
|
|
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
|
|
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={
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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' :
|
|
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
|
|
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')
|
|
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
|
|