@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
@@ -1,31 +1,28 @@
1
1
  "use client";
2
2
 
3
- import { useMediaQuery, useTheme } from "@mui/material";
4
- import { useState } from "react";
3
+ import { useEffect, useState } from "react";
5
4
 
6
- import {
7
- DataGrid,
8
- type GridColumn,
9
- type GridSort,
10
- } from "../DataGrid";
11
- import {
12
- type ColumnVisibilityOption,
13
- } from "../../layout/ContentToolbar";
14
5
  import { TableFilter } from "../../layout/TableFilter";
6
+ import { Text } from "../../typography/Text";
15
7
  import { Box } from "../../../mui/Box";
16
8
  import { Stack } from "../../../mui/Stack";
17
9
 
18
10
  import {
19
- cardMinWidthForZoom,
20
- cardScaleForZoom,
21
11
  DataViewsLayoutProvider,
22
12
  useDataViewsLayout,
23
13
  type DataViewsLayout,
24
14
  } from "./data-views-layout-context";
25
15
  import { FilterDialog, GridFilterPanel } from "./data-views-filter-panel";
26
- import { InlineFilterBar } from "./data-views-inline-bar";
27
- import { GridToolbar } from "./data-views-toolbar";
16
+ import { GridMain } from "./data-views-grid-bodies";
17
+ import { InlineFilterChips, InlineFilterControls } from "./data-views-inline-bar";
18
+ import { toOverflowFields, useFilterOverflow, type OverflowSplit } from "./data-views-overflow";
19
+ import { ShellToolbar } from "./data-views-shell-toolbar";
20
+ import type { DisplayPanelView } from "./data-views-display-panel";
21
+ import type { DataViewExport } from "./data-views-export";
22
+ import { DataViewsEmpty } from "./data-views-empty";
28
23
  import { DataViewsPagination } from "./data-views-pagination";
24
+ import type { BoardConfig } from "./DataViewsBoard";
25
+ import { DataViewsScopeTabs, type ScopeConfig } from "./data-views-scopes";
29
26
  import { togglePillValues } from "./data-views-grid-helpers";
30
27
  import type {
31
28
  DataViewCardSelection,
@@ -36,181 +33,82 @@ import type {
36
33
  } from "./data-views-types";
37
34
  import type { DataViewsController } from "./use-data-views-state";
38
35
 
39
- /* ── Body (grid) ─────────────────────────────────────────────────────────── */
40
-
41
- interface GridBodyProps<T extends Record<string, unknown>> {
42
- rows: T[];
43
- columns: GridColumn<T>[];
44
- getRowId: (row: T) => string | number;
45
- selectedIds: Array<string | number>;
46
- onChangeSelected: (ids: Array<string | number>) => void;
47
- sortBy: GridSort[];
48
- onChangeSortBy: (next: GridSort[]) => void;
49
- /** "server" defers ordering to the backend (rows render as-is); "client" sorts in-grid. */
50
- sortMode: "client" | "server";
51
- dataTestId?: string;
52
- emptyState?: React.ReactNode;
53
- }
54
-
55
- /** The dense DataGrid with multi-select, wrapped in the scrollable table region. */
56
- function GridBody<T extends Record<string, unknown>>({
57
- rows,
58
- columns,
59
- getRowId,
60
- selectedIds,
61
- onChangeSelected,
62
- sortBy,
63
- onChangeSortBy,
64
- sortMode,
65
- dataTestId,
66
- emptyState,
67
- }: GridBodyProps<T>): React.JSX.Element {
68
- return (
69
- <Box
70
- sx={{
71
- width: "100%",
72
- overflowX: "auto",
73
- mt: 1.5,
74
- // Tabwoah-style dense rows: MUI's default TableCell padding keeps rows
75
- // tall regardless of rowHeight, so trim it here.
76
- "& .MuiTableCell-root": { py: 0.25, fontSize: "0.8125rem" },
77
- "& .MuiTableCell-head": { py: 0.5, fontSize: "0.75rem" },
78
- }}
79
- >
80
- <DataGrid<T>
81
- rows={rows}
82
- columns={columns}
83
- getRowId={(row) => getRowId(row)}
84
- virtualizeRows={false}
85
- density="compact"
86
- rowHeight={36}
87
- headerHeight={36}
88
- selection={{ mode: "multi", selectedRowIds: selectedIds, onChangeSelected }}
89
- sorting={{ mode: sortMode, sortBy, onChangeSortBy }}
90
- data-testid={dataTestId}
91
- emptyState={emptyState}
92
- />
93
- </Box>
94
- );
95
- }
96
-
97
- /* ── Body (cards) ────────────────────────────────────────────────────────── */
98
-
99
- interface CardBodyProps<T extends Record<string, unknown>> {
100
- rows: T[];
101
- renderCard: (row: T, selection: DataViewCardSelection) => React.ReactNode;
102
- getRowId: (row: T) => string | number;
103
- selectedIds: Set<string | number>;
104
- onToggleId: (id: string | number) => void;
105
- minCardWidth: number;
106
- /** Content scale (padding + type) handed to each card, from the zoom slider. */
107
- cardScale: number;
108
- dataTestId?: string;
109
- emptyState?: React.ReactNode;
110
- }
36
+ /* ── Header row ──────────────────────────────────────────────────────────── */
111
37
 
112
38
  /**
113
- * The "Grade" layout: the filtered/sorted rows rendered as an auto-filling grid
114
- * of entity-supplied cards. Reuses `rows` (= `c.matched`), so search/filter/sort
115
- * apply; the column width comes from the zoom slider, and each card is handed its
116
- * selection state so it can drive its own checkbox (BaseCard) — the same
117
- * selection model as the table.
39
+ * The grid's own header row: the page title on the left, the primary page
40
+ * actions on the right.
41
+ *
42
+ * `title` and `headerActions` were DECLARED on `DataViewsGridProps` and
43
+ * `DataViewsTableBaseProps` but never destructured or forwarded — dead props
44
+ * whose types lied to every caller that set them. Wired here rather than
45
+ * deleted: a table that carries its own title keeps the title, the scope tabs
46
+ * and the toolbar as one block, instead of the page having to space them.
47
+ *
48
+ * Renders NOTHING when neither is supplied, so no existing table gains a row.
118
49
  */
119
- function CardBody<T extends Record<string, unknown>>({
120
- rows,
121
- renderCard,
122
- getRowId,
123
- selectedIds,
124
- onToggleId,
125
- minCardWidth,
126
- cardScale,
127
- dataTestId,
128
- emptyState,
129
- }: CardBodyProps<T>): React.JSX.Element {
130
- if (rows.length === 0) {
131
- return <Box sx={{ mt: 1.5 }}>{emptyState}</Box>;
132
- }
50
+ function GridHeaderRow({
51
+ title,
52
+ headerActions,
53
+ testIdPrefix,
54
+ }: {
55
+ title?: string;
56
+ headerActions?: React.ReactNode;
57
+ testIdPrefix: string;
58
+ }): React.JSX.Element | null {
59
+ if (!title && !headerActions) return null;
133
60
  return (
134
61
  <Box
62
+ data-testid={`${testIdPrefix}-header`}
135
63
  sx={{
136
- mt: 1.5,
137
- display: "grid",
138
- // Fixed inter-card gap — deliberately NOT scaled by the zoom slider, so
139
- // only the cards grow while the space between them stays constant.
140
- gap: 1.5,
141
- gridTemplateColumns: `repeat(auto-fill, minmax(${minCardWidth}px, 1fr))`,
64
+ display: "flex",
65
+ alignItems: "center",
66
+ justifyContent: "space-between",
67
+ gap: 2,
68
+ flexWrap: "wrap",
69
+ pb: 1.5,
142
70
  }}
143
- data-testid={dataTestId ? `${dataTestId}-cards` : "data-views-cards"}
144
71
  >
145
- {rows.map((row) => {
146
- const id = getRowId(row);
147
- return (
148
- <Box key={id}>
149
- {renderCard(row, {
150
- selected: selectedIds.has(id),
151
- onToggleSelect: () => onToggleId(id),
152
- scale: cardScale,
153
- })}
154
- </Box>
155
- );
156
- })}
72
+ {title ? (
73
+ <Text variant="heading" size="lg" as="h2">
74
+ {title}
75
+ </Text>
76
+ ) : (
77
+ <Box />
78
+ )}
79
+ {headerActions && <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>{headerActions}</Box>}
157
80
  </Box>
158
81
  );
159
82
  }
160
83
 
161
- /* ── Body selector (cards vs table, from context) ────────────────────────── */
84
+ /* ── Layout ⇄ view-state mirror ──────────────────────────────────────────── */
162
85
 
163
- interface GridMainProps<T extends Record<string, unknown>> {
164
- c: DataViewsController<T>;
165
- getRowId: (row: T) => string | number;
166
- renderCard?: (row: T, selection: DataViewCardSelection) => React.ReactNode;
167
- dataTestId?: string;
168
- emptyState?: React.ReactNode;
169
- }
170
-
171
- /** Picks the body to render from the layout context: cards or the dense grid. */
172
- function GridMain<T extends Record<string, unknown>>({
86
+ /**
87
+ * Mirrors the LIVE layout back into the view state, so saving a view captures
88
+ * the layout the user is actually looking at.
89
+ *
90
+ * A component rather than a callback prop because only something INSIDE the
91
+ * provider can read the layout, and only the controller can write the state.
92
+ * The `stored !== layout` guard is what makes it idempotent: `c` is rebuilt on
93
+ * every render, so an unguarded effect here would patch → re-render → patch.
94
+ *
95
+ * It re-fires when `stored` changes too, which is the case that matters after
96
+ * applying a saved view: `appliedState` REPLACES the state, so a view saved
97
+ * before layouts were captured clears `state.layout`, and the mirror puts the
98
+ * current one back rather than leaving the next save with nothing to store.
99
+ */
100
+ function LayoutStateSync<T extends Record<string, unknown>>({
173
101
  c,
174
- getRowId,
175
- renderCard,
176
- dataTestId,
177
- emptyState,
178
- }: GridMainProps<T>): React.JSX.Element {
179
- const { layout, zoom } = useDataViewsLayout();
180
- if (layout === "cards" && renderCard) {
181
- return (
182
- <CardBody
183
- rows={c.matched}
184
- renderCard={renderCard}
185
- getRowId={getRowId}
186
- selectedIds={c.selectedIds}
187
- onToggleId={(id) => {
188
- const next = new Set(c.selectedIds);
189
- if (next.has(id)) next.delete(id);
190
- else next.add(id);
191
- c.setSelectedIds(next);
192
- }}
193
- minCardWidth={cardMinWidthForZoom(zoom)}
194
- cardScale={cardScaleForZoom(zoom)}
195
- dataTestId={dataTestId}
196
- emptyState={emptyState}
197
- />
198
- );
199
- }
200
- return (
201
- <GridBody
202
- rows={c.matched}
203
- columns={c.gridColumns}
204
- getRowId={getRowId}
205
- selectedIds={[...c.selectedIds]}
206
- onChangeSelected={(ids) => c.setSelectedIds(new Set(ids))}
207
- sortBy={c.state.sortBy}
208
- onChangeSortBy={(next: GridSort[]) => c.patch({ sortBy: next })}
209
- sortMode={c.serverMode ? "server" : "client"}
210
- dataTestId={dataTestId}
211
- emptyState={emptyState}
212
- />
213
- );
102
+ }: {
103
+ c: DataViewsController<T>;
104
+ }): null {
105
+ const { layout } = useDataViewsLayout();
106
+ const stored = c.state.layout;
107
+ useEffect(() => {
108
+ if (stored !== layout) c.patch({ layout });
109
+ // `c` is intentionally out of the deps — see the guard above.
110
+ }, [layout, stored]);
111
+ return null;
214
112
  }
215
113
 
216
114
  /* ── Shell (composes toolbar + body + panel) ─────────────────────────────── */
@@ -256,22 +154,36 @@ function useGridShellFilters<T extends Record<string, unknown>>({
256
154
  showInline: boolean;
257
155
  useModal: boolean;
258
156
  inlineVisible: boolean;
259
- filtersHidden: boolean;
260
- toggleFilters: () => void;
261
157
  filterProps: FilterSurfaceProps<T>;
158
+ split: OverflowSplit<T>;
159
+ onControlOpenChange: (open: boolean) => void;
262
160
  } {
263
161
  const { state } = c;
264
- // Read the theme explicitly (falls back to the default when there's no
265
- // ThemeProvider) so the query never dereferences a null theme. `noSsr`
266
- // evaluates on the client only (no hydration mismatch).
267
- const theme = useTheme();
268
- const wide = useMediaQuery(theme.breakpoints.up("lg"), { noSsr: true });
269
- const [filtersHidden, setFiltersHidden] = useState(false);
270
- const showInline = inlineFilters && wide;
271
- const useModal = inlineFilters && !wide;
162
+ // How many filter controls are open. A COUNT, not a flag: closing one popover
163
+ // by opening another overlaps, and a boolean would unfreeze mid-handover.
164
+ const [openControls, setOpenControls] = useState(0);
165
+ // ONE measurement for the whole shell: the filter row decides which controls
166
+ // it can keep, and the TOOLBAR needs the same answer to drop its labels.
167
+ // Measuring twice would let the two disagree at the crossover width.
168
+ const split = useFilterOverflow(
169
+ toOverflowFields(fields, rangeFields),
170
+ state.pills,
171
+ c.ranges,
172
+ openControls > 0,
173
+ );
174
+ // THE BAR RENDERS AT EVERY WIDTH. It used to be swapped for a full-screen
175
+ // filter MODAL below `lg`, which was the responsive strategy before the
176
+ // measured ladder existed — and the two now do the same job, badly together:
177
+ // the modal took the bar away at 1199px, and with it the very degradation
178
+ // (labels off, search collapsed, filters into "Mais") that exists to make a
179
+ // narrow bar work. So a 900px window got no bar and no ladder, and the
180
+ // operator lost the search entirely rather than gaining a compact one.
181
+ //
182
+ // The ladder is measured, so it already covers every width the modal did.
183
+ const showInline = inlineFilters;
184
+ const useModal = false;
272
185
  const inlineVisible =
273
186
  showInline &&
274
- !filtersHidden &&
275
187
  (alwaysShowSearch || fields.length > 0 || rangeFields.length > 0 || state.search !== "");
276
188
  const filterProps: FilterSurfaceProps<T> = {
277
189
  testIdPrefix,
@@ -287,7 +199,15 @@ function useGridShellFilters<T extends Record<string, unknown>>({
287
199
  onClearField: (fieldId) => c.patch({ pills: { ...state.pills, [fieldId]: [] } }),
288
200
  onClearAll: () => c.patch({ search: "", pills: {}, ranges: {} }),
289
201
  };
290
- return { showInline, useModal, inlineVisible, filtersHidden, toggleFilters: () => setFiltersHidden((v) => !v), filterProps };
202
+ return {
203
+ showInline,
204
+ useModal,
205
+ inlineVisible,
206
+ filterProps,
207
+ split,
208
+ onControlOpenChange: (open) =>
209
+ setOpenControls((count) => Math.max(0, count + (open ? 1 : -1))),
210
+ };
291
211
  }
292
212
 
293
213
  interface GridShellProps<T extends Record<string, unknown>> {
@@ -305,8 +225,30 @@ interface GridShellProps<T extends Record<string, unknown>> {
305
225
  /** Opt-in "Grade" (cards) layout: renders each row as an entity-supplied card.
306
226
  * Omit ⇒ table only (no layout toggle). */
307
227
  renderCard?: (row: T, selection: DataViewCardSelection) => React.ReactNode;
308
- /** Which layout to show first when cards are available (default "table"). */
228
+ /**
229
+ * Opt-in "Quadro" (board) layout. Needs `renderCard` too — the board reuses
230
+ * the entity's card, so a board config without one simply offers no board
231
+ * rather than failing: that is a config gap, not a runtime error.
232
+ */
233
+ board?: BoardConfig<T>;
234
+ /** Opt-in "Lista" layout: one full-width, entity-rendered row per record. */
235
+ renderListRow?: (row: T, selection: DataViewCardSelection) => React.ReactNode;
236
+ /** The page-level partition rendered as tabs above the toolbar. */
237
+ scopes?: ScopeConfig[];
238
+ /** Per-sort-field value kind, so directions read in the column's own terms. */
239
+ sortKinds?: Record<string, string>;
240
+ /** The saved-view chrome bracketing the Exibir panel. */
241
+ displayView?: DisplayPanelView;
242
+ /** Injected export — the host re-queries; the grid never fetches. */
243
+ exportConfig?: DataViewExport;
244
+ /** Page title in the grid's own header row, above the scopes + toolbar. */
245
+ title?: string;
246
+ /** Primary page actions rendered at the header row's right. */
247
+ headerActions?: React.ReactNode;
248
+ /** Which layout to show first when the user has expressed no preference (default "table"). */
309
249
  defaultLayout?: DataViewsLayout;
250
+ /** Pin to `defaultLayout`, ignoring the remembered preference (stories/docs). */
251
+ ignoreStoredLayout?: boolean;
310
252
  /**
311
253
  * Opt into the responsive inline filter UX: a collapsible filter row below the
312
254
  * toolbar on wide screens and a modal on narrow ones. When false (default),
@@ -323,78 +265,114 @@ interface GridShellProps<T extends Record<string, unknown>> {
323
265
  alwaysShowSearch?: boolean;
324
266
  }
325
267
 
326
- /** Wires the controller into the shared toolbar + filter panel + grid/cards body. */
327
- export function GridShell<T extends Record<string, unknown>>({
328
- c,
329
- rows,
330
- fields,
331
- rangeFields,
332
- getRowId,
333
- testIdPrefix,
334
- dataTestId,
335
- emptyState,
336
- toolbarRightSlot,
337
- rowActions,
338
- bulkActions,
339
- renderCard,
340
- defaultLayout,
341
- inlineFilters = false,
342
- alwaysShowSearch = false,
343
- }: GridShellProps<T>): React.JSX.Element {
344
- const { state } = c;
345
- const { showInline, useModal, inlineVisible, filtersHidden, toggleFilters, filterProps } = useGridShellFilters(
346
- { c, inlineFilters, fields, rangeFields, testIdPrefix, alwaysShowSearch },
268
+ /** The scrollable content region: scope tabs, toolbar, filter bar, body, pager. */
269
+ function ShellStack<T extends Record<string, unknown>>({
270
+ props,
271
+ filters,
272
+ }: {
273
+ props: GridShellProps<T>;
274
+ filters: ReturnType<typeof useGridShellFilters<T>>;
275
+ }): React.JSX.Element {
276
+ const { c, testIdPrefix, dataTestId, scopes = [], emptyState, inlineFilters = false } = props;
277
+ const { showInline, useModal, inlineVisible, filterProps, split, onControlOpenChange } = filters;
278
+ // The grid renders the FILTERED empty state itself — it is the only party
279
+ // that knows a filter is applied. See {@link DataViewsEmpty}.
280
+ const body = (
281
+ <DataViewsEmpty
282
+ filtered={c.activeFilterCount > 0 || c.state.search !== ""}
283
+ onClearFilters={() => c.patch({ search: "", pills: {}, ranges: {} })}
284
+ emptyState={emptyState}
285
+ testIdPrefix={testIdPrefix}
286
+ />
347
287
  );
348
- const columnOptions: ColumnVisibilityOption[] = c.hideableColumns.map((col) => ({
349
- id: col.id,
350
- label: col.label,
351
- visible: state.visibleColumns.includes(col.id),
352
- }));
353
288
  return (
354
- <DataViewsLayoutProvider canUseCards={Boolean(renderCard)} defaultLayout={defaultLayout}>
355
289
  <TableFilter open={c.filterOpen} onOpenChange={c.setFilterOpen} hasActiveFilters={c.activeFilterCount > 0}>
356
290
  <Stack spacing={0} data-testid={dataTestId ? `${dataTestId}-container` : undefined}>
357
- <GridToolbar
291
+ <GridHeaderRow title={props.title} headerActions={props.headerActions} testIdPrefix={testIdPrefix} />
292
+ {/* Renders nothing (and reserves nothing) for an empty scope list. */}
293
+ <DataViewsScopeTabs
294
+ scopes={scopes}
295
+ value={c.scope}
296
+ onChange={c.setScope}
297
+ counts={c.scopeCounts}
298
+ testIdPrefix={testIdPrefix}
299
+ />
300
+ <ShellToolbar
301
+ c={c}
302
+ rows={props.rows}
303
+ sortKinds={props.sortKinds}
304
+ displayView={props.displayView}
305
+ exportConfig={props.exportConfig}
306
+ filterControls={
307
+ inlineVisible ? (
308
+ <InlineFilterControls
309
+ {...filterProps}
310
+ split={split}
311
+ onControlOpenChange={onControlOpenChange}
312
+ />
313
+ ) : undefined
314
+ }
315
+ barRef={split.barRef}
358
316
  testIdPrefix={testIdPrefix}
359
- selectedRows={c.selectedRows}
360
- selectAll={c.selectAll}
361
- clearSelection={c.clearSelection}
362
- rowActions={rowActions}
363
- bulkActions={bulkActions}
364
- sortFields={c.resolvedSortFields}
365
- activeSortField={c.activeSortField}
366
- activeSortOrder={c.activeSortOrder}
367
- onChangeSort={(field, order) => c.patch({ sortBy: [{ id: field, dir: order }] })}
368
- matchedCount={c.matched.length}
369
- totalCount={c.serverMode ? c.serverTotalCount : rows.length}
370
- toolbarRightSlot={toolbarRightSlot}
371
- columnOptions={columnOptions}
372
- onToggleColumn={c.toggleColumn}
373
- filterOpen={c.filterOpen}
374
- setFilterOpen={c.setFilterOpen}
375
- activeFilterCount={c.activeFilterCount}
376
- showFilterTrigger={!showInline}
377
- showFiltersToggle={showInline}
378
- filtersHidden={filtersHidden}
379
- onToggleFilters={toggleFilters}
317
+ rowActions={props.rowActions}
318
+ bulkActions={props.bulkActions}
319
+ toolbarRightSlot={props.toolbarRightSlot}
320
+ compactControls={showInline && split.compactControls}
321
+ showInline={showInline}
380
322
  />
381
- {inlineVisible && <InlineFilterBar {...filterProps} />}
323
+ {/* Only the applied-filter chips live under the toolbar now — the
324
+ controls themselves ride on the toolbar line above. */}
325
+ {inlineVisible && <InlineFilterChips {...filterProps} />}
382
326
  <TableFilter.Layout>
383
327
  <TableFilter.Main>
384
328
  <GridMain
385
329
  c={c}
386
- getRowId={getRowId}
387
- renderCard={renderCard}
330
+ getRowId={props.getRowId}
331
+ renderCard={props.renderCard}
332
+ renderListRow={props.renderListRow}
333
+ board={props.board}
388
334
  dataTestId={dataTestId}
389
- emptyState={emptyState}
335
+ testIdPrefix={testIdPrefix}
336
+ emptyState={body}
390
337
  />
391
338
  <DataViewsPagination c={c} testIdPrefix={testIdPrefix} />
392
339
  </TableFilter.Main>
393
- {!inlineFilters && <GridFilterPanel {...filterProps} />}
340
+ {inlineFilters ? null : <GridFilterPanel {...filterProps} />}
394
341
  </TableFilter.Layout>
395
342
  {useModal && <FilterDialog open={c.filterOpen} onClose={() => c.setFilterOpen(false)} {...filterProps} />}
396
343
  </Stack>
397
344
  </TableFilter>
345
+ );
346
+ }
347
+
348
+ export function GridShell<T extends Record<string, unknown>>(props: GridShellProps<T>): React.JSX.Element {
349
+ const {
350
+ c,
351
+ fields,
352
+ rangeFields,
353
+ testIdPrefix,
354
+ renderCard,
355
+ renderListRow,
356
+ board,
357
+ defaultLayout,
358
+ ignoreStoredLayout,
359
+ inlineFilters = false,
360
+ alwaysShowSearch = false,
361
+ } = props;
362
+ const filters = useGridShellFilters({ c, inlineFilters, fields, rangeFields, testIdPrefix, alwaysShowSearch });
363
+ return (
364
+ <DataViewsLayoutProvider
365
+ canUseCards={Boolean(renderCard)}
366
+ canUseList={Boolean(renderListRow)}
367
+ // The board reuses the entity's card, so it needs BOTH — a `board` with no
368
+ // `renderCard` offers no board rather than throwing.
369
+ canUseBoard={Boolean(board && renderCard)}
370
+ defaultLayout={defaultLayout}
371
+ ignoreStoredLayout={ignoreStoredLayout}
372
+ viewLayout={c.state.layout}
373
+ >
374
+ <LayoutStateSync c={c} />
375
+ <ShellStack props={props} filters={filters} />
398
376
  </DataViewsLayoutProvider>
399
377
  );
400
378
  }