@tristan2828/ui-foundation 3.10.0 → 3.11.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.
@@ -353,6 +353,12 @@ is a filled-in example while the Widgets demo is still here).
353
353
 
354
354
  ## What not to copy
355
355
 
356
+ - The saved-views row in `widgets-table.tsx` (`VIEWS`, the "Saved views"
357
+ button group and `visibleColumns`) unless the plan's List screen lists
358
+ saved views. When it does, copy it: `useTableUrlState`'s third argument
359
+ takes the views, `activeView` says which one the filters are, and that
360
+ view's `columns` go to `DataTable`'s `visibleColumns` (the table's own
361
+ default list when no view is active, or `undefined` for every column).
356
362
  - `src/api/gateway/widgets.ts`, `src/mocks/data.ts`, `src/mocks/handlers.ts`
357
363
  are demo-domain content — write the entity's own versions rather than
358
364
  adapting these by find-and-replace.
@@ -92,6 +92,19 @@ per list; delete the section if there are none.
92
92
 
93
93
  - Default sort:
94
94
  - Page size: 10 unless there's a reason.
95
+ - Saved views (optional; delete if none): one row per view, a button
96
+ above the filters that sets every filter and the sort in one press.
97
+ Columns: blank for the table's usual columns, or the exact columns the
98
+ view shows, for a view of one kind of record that shows a field only
99
+ that kind uses. The columns last while the view's filters do; any other
100
+ filter puts the usual columns back. If the usual columns should leave
101
+ out a field only a view shows, say so here.
102
+
103
+ | View | Filters | Sort | Columns |
104
+ |---|---|---|---|
105
+ | All tasks | none | dueDate asc | |
106
+ | Bugs | kind = bug | dueDate asc | title, severity, assignee, dueDate |
107
+
95
108
  - Anything else the table must show or do:
96
109
 
97
110
  ## View screen
@@ -52,5 +52,13 @@ export type DataTableProps<TData extends Record<string, unknown>> = {
52
52
  * unaffected. See conventions/docs/entity-plan-template.md "Default sort".
53
53
  */
54
54
  enableSortingRemoval?: boolean;
55
+ /**
56
+ * The ids of the columns to show; the rest are hidden. They keep their
57
+ * order in `columns`. Leave undefined to show every column. A table
58
+ * with saved views passes the active view's `columns`
59
+ * (useTableUrlState's `activeView`), or its own default list when no
60
+ * view is active, which can leave out a column only one view shows.
61
+ */
62
+ visibleColumns?: readonly string[];
55
63
  };
56
- export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, }: DataTableProps<TData>): import("react").JSX.Element;
64
+ export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, }: DataTableProps<TData>): import("react").JSX.Element;
@@ -20,6 +20,20 @@ import { Button } from '../ui/button.js';
20
20
  import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '../ui/empty.js';
21
21
  import { Skeleton } from '../ui/skeleton.js';
22
22
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '../ui/table.js';
23
+ // A column def's id as TanStack resolves it (constructColumn): `id`, then
24
+ // `accessorKey` with dots as underscores, then a string header. Group
25
+ // columns are looked through to their leaves, which visibility is keyed by.
26
+ function leafColumnIds(columns) {
27
+ return columns.flatMap((column) => {
28
+ if ('columns' in column && column.columns)
29
+ return leafColumnIds(column.columns);
30
+ const accessorKey = 'accessorKey' in column ? column.accessorKey : undefined;
31
+ const id = column.id ??
32
+ (accessorKey === undefined ? undefined : String(accessorKey).replaceAll('.', '_')) ??
33
+ (typeof column.header === 'string' ? column.header : undefined);
34
+ return id === undefined ? [] : [id];
35
+ });
36
+ }
23
37
  function TableSkeleton({ columnCount }) {
24
38
  return (_jsx("div", { className: "flex flex-col gap-2 p-2", "data-state": "loading", children: Array.from({ length: 5 }, (_, row) => (_jsx("div", { className: "flex gap-4", children: Array.from({ length: columnCount }, (_, col) => (_jsx(Skeleton, { className: "h-8 flex-1" }, col))) }, row))) }));
25
39
  }
@@ -109,8 +123,13 @@ function useBottomScrollbar(containerRef, isSuccessView) {
109
123
  }, [containerRef, isSuccessView, isOverflowing]);
110
124
  return { barRef, isOverflowing, scrollWidth };
111
125
  }
112
- export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, }) {
126
+ export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, }) {
113
127
  const pageCount = Math.max(1, Math.ceil(total / pageSize));
128
+ // TanStack treats a column missing from this map as visible, so every
129
+ // column gets an entry once a list is given.
130
+ const columnVisibility = visibleColumns
131
+ ? Object.fromEntries(leafColumnIds(columns).map((id) => [id, visibleColumns.includes(id)]))
132
+ : {};
114
133
  const table = useLegacyTable({
115
134
  data,
116
135
  columns,
@@ -118,6 +137,7 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
118
137
  state: {
119
138
  sorting,
120
139
  pagination: { pageIndex: page - 1, pageSize },
140
+ columnVisibility,
121
141
  },
122
142
  manualSorting: true,
123
143
  ...(enableSortingRemoval === undefined ? {} : { enableSortingRemoval }),
@@ -143,7 +163,7 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
143
163
  const isSuccessView = !error && !isLoading && !isPastLastPage && !isEmpty;
144
164
  const containerRef = useRef(null);
145
165
  const { barRef, isOverflowing, scrollWidth } = useBottomScrollbar(containerRef, Boolean(pinFirstColumn) && isSuccessView);
146
- return (_jsxs("div", { className: "flex flex-col gap-4", children: [toolbar, error ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : isLoading || isPastLastPage ? (_jsx(TableSkeleton, { columnCount: columns.length })) : isEmpty ? (_jsxs(Empty, { "data-state": "empty", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: emptyTitle }), emptyDescription && _jsx(EmptyDescription, { children: emptyDescription })] }), emptyAction] })) : (_jsxs(_Fragment, { children: [_jsxs(Table, { "data-state": "success", containerRef: containerRef,
166
+ return (_jsxs("div", { className: "flex flex-col gap-4", children: [toolbar, error ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : isLoading || isPastLastPage ? (_jsx(TableSkeleton, { columnCount: table.getVisibleLeafColumns().length })) : isEmpty ? (_jsxs(Empty, { "data-state": "empty", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: emptyTitle }), emptyDescription && _jsx(EmptyDescription, { children: emptyDescription })] }), emptyAction] })) : (_jsxs(_Fragment, { children: [_jsxs(Table, { "data-state": "success", containerRef: containerRef,
147
167
  // With the sticky bar below as the visible scrollbar, hide the
148
168
  // container's own. It stays overflow-x-auto and fully
149
169
  // scrollable (wheel, trackpad, drag, and the sync effect); it
@@ -7,8 +7,15 @@ export type TableView<F extends string, M extends string> = {
7
7
  filters?: Partial<Record<F, string>>;
8
8
  multiFilters?: Partial<Record<M, string[]>>;
9
9
  sort?: Sort;
10
+ /**
11
+ * The ids of the columns this view shows, for DataTable's
12
+ * `visibleColumns`. Leave it out to show the table's default columns.
13
+ * Applies only while the view is active (see activeViewOf).
14
+ */
15
+ columns?: readonly string[];
10
16
  };
11
17
  export declare function serializeSort(next: Sort): string | null;
12
18
  export declare function applyUrlChanges(previous: URLSearchParams, changes: UrlChanges): URLSearchParams;
13
19
  export declare function viewToUrlChanges<F extends string, M extends string>(filterNames: readonly F[], multiFilterNames: readonly M[], view: TableView<F, M>): UrlChanges;
20
+ export declare function activeViewOf<F extends string, M extends string, V extends string>(views: Readonly<Record<V, TableView<F, M>>>, filters: Readonly<Record<F, string>>, multiFilters: Readonly<Record<M, readonly string[]>>): V | null;
14
21
  export {};
@@ -30,3 +30,17 @@ export function viewToUrlChanges(filterNames, multiFilterNames, view) {
30
30
  page: null,
31
31
  };
32
32
  }
33
+ // Which of `views` the current filters are: the first whose filters and
34
+ // multi filters equal the URL's exactly (multi filters in any order), or
35
+ // null. A view is active only while it is still "this combination and
36
+ // nothing else", so adding or changing any filter returns the table to its
37
+ // default columns: a view's columns are chosen for the records its filters
38
+ // select, and would hide a field those other records fill. Sort and page
39
+ // don't change which records are shown, so they don't count. Decided with
40
+ // the developer on 2026-10-05.
41
+ export function activeViewOf(views, filters, multiFilters) {
42
+ const sameItems = (a, b) => a.length === b.length && [...a].sort().every((item, index) => item === [...b].sort()[index]);
43
+ const matches = (view) => Object.keys(filters).every((name) => filters[name] === (view.filters?.[name] ?? '')) &&
44
+ Object.keys(multiFilters).every((name) => sameItems(multiFilters[name], view.multiFilters?.[name] ?? []));
45
+ return Object.keys(views).find((id) => matches(views[id])) ?? null;
46
+ }
@@ -1,10 +1,11 @@
1
1
  import type { SortingState } from '../components/app/data-table.js';
2
2
  import { type TableView } from './table-url-changes.js';
3
- export declare function useTableUrlState<F extends string, M extends string = never>(filterNames: readonly F[], multiFilterNames?: readonly M[]): {
3
+ export declare function useTableUrlState<F extends string, M extends string = never, V extends string = never>(filterNames: readonly F[], multiFilterNames?: readonly M[], views?: Readonly<Record<V, TableView<NoInfer<F>, NoInfer<M>>>>): {
4
4
  page: number;
5
5
  sorting: SortingState;
6
6
  filters: Record<F, string>;
7
7
  multiFilters: Record<M, string[]>;
8
+ activeView: V | null;
8
9
  setPage: (next: number) => void;
9
10
  setSorting: (next: SortingState) => void;
10
11
  setFilter: (name: F, value: string) => void;
@@ -1,5 +1,5 @@
1
1
  import { useSearchParams } from 'react-router';
2
- import { applyUrlChanges, serializeSort, viewToUrlChanges } from './table-url-changes.js';
2
+ import { activeViewOf, applyUrlChanges, serializeSort, viewToUrlChanges, } from './table-url-changes.js';
3
3
  // A table's page, sort and filters, kept in the URL query string
4
4
  // (`?page=2&sort=name:asc&search=mouse&tags=fragile&tags=bulky`) instead of
5
5
  // component state, so they survive going to the edit form and back, a
@@ -10,14 +10,20 @@ import { applyUrlChanges, serializeSort, viewToUrlChanges } from './table-url-ch
10
10
  // (multi-choice fields) are string arrays, repeated in the URL the same way
11
11
  // the API takes them. Changing the sort or any filter returns to page 1.
12
12
  //
13
+ // `views` are the table's saved views, by id. `activeView` is the id of the
14
+ // one the current filters are, or null (activeViewOf() says when a view
15
+ // counts as active); pass that view's `columns` to DataTable. A view is
16
+ // pressed with applyView().
17
+ //
13
18
  // Every setter is one update(), and must stay one: see setFilters.
14
- export function useTableUrlState(filterNames, multiFilterNames = []) {
19
+ export function useTableUrlState(filterNames, multiFilterNames = [], views = {}) {
15
20
  const [params, setParams] = useSearchParams();
16
21
  const page = Math.max(1, Math.floor(Number(params.get('page'))) || 1);
17
22
  const [sortField, sortDir] = (params.get('sort') ?? '').split(':');
18
23
  const sorting = sortField ? [{ id: sortField, desc: sortDir === 'desc' }] : [];
19
24
  const filters = Object.fromEntries(filterNames.map((name) => [name, params.get(name) ?? '']));
20
25
  const multiFilters = Object.fromEntries(multiFilterNames.map((name) => [name, params.getAll(name)]));
26
+ const activeView = activeViewOf(views, filters, multiFilters);
21
27
  function update(changes) {
22
28
  setParams((previous) => applyUrlChanges(previous, changes), { replace: true });
23
29
  }
@@ -26,6 +32,7 @@ export function useTableUrlState(filterNames, multiFilterNames = []) {
26
32
  sorting,
27
33
  filters,
28
34
  multiFilters,
35
+ activeView,
29
36
  setPage: (next) => update({ page: next > 1 ? String(next) : null }),
30
37
  setSorting: (next) => update({ sort: serializeSort(next), page: null }),
31
38
  setFilter: (name, value) => update({ [name]: value, page: null }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "3.10.0",
3
+ "version": "3.11.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {