@tristan2828/ui-foundation 3.21.0 → 3.22.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.
@@ -189,7 +189,12 @@ is a filled-in example while the Widgets demo is still here).
189
189
  - `src/routes/widgets/widgets-table.tsx` →
190
190
  `src/routes/<entity>/<entity>-table.tsx` — thin consumer of the
191
191
  foundation's `<DataTable>`. Swap the type, columns, and toolbar
192
- filters.
192
+ filters. More than three filters (search, saved views aside): keep
193
+ the most used in the toolbar and put the rest inside
194
+ `<SecondaryFilters>`, as the template does with Extra Categories,
195
+ Progress and In Stock. Pass it each filter that's on as an
196
+ `ActiveFilter` (its chip text and how to take it off) and an
197
+ `onClear` that clears them all in one `setFilters` call.
193
198
  - `src/routes/widgets/widget-view.tsx` →
194
199
  `src/routes/<entity>/<entity>-view.tsx` — thin consumer of the
195
200
  foundation's `<EntityView>`. Swap the title, badges and sections for
@@ -37,20 +37,27 @@ function leafColumnIds(columns) {
37
37
  function TableSkeleton({ columnCount }) {
38
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))) }));
39
39
  }
40
- // The pinned cell gets a flat `bg-background`, not `bg-inherit`: the zebra
40
+ // The pinned cell gets an opaque background, not `bg-inherit`: the zebra
41
41
  // stripe (table.tsx) is a *translucent* color, so inheriting it would let
42
42
  // the columns scrolling behind show through at 40% opacity — exactly the
43
- // bleed-through a solid background avoids. That also means the pinned
44
- // column shows no stripe, which is why it is the thing that carries the
45
- // hover highlight. --accent is only ~3% off the page background, so a
46
- // solid left edge marker carries the signal; an inset shadow rather than a
47
- // border, so nothing reflows.
48
- const PINNED_COLUMN_CLASS = 'sticky left-0 z-10 border-r bg-background group-hover/row:bg-accent ' +
49
- 'group-hover/row:shadow-[inset_3px_0_0_0_var(--primary)]';
43
+ // bleed-through a solid background avoids. On an even row it's the stripe
44
+ // pre-mixed onto the page (--table-stripe), so the stripe runs the whole
45
+ // row's width (issue #92); `:where()` keeps that rule's specificity at the
46
+ // background's, so the hover below still wins on every row. The pinned
47
+ // cell carries the hover highlight. --accent is only ~3% off the page
48
+ // background, so a solid left edge marker carries the signal; an inset
49
+ // shadow rather than a border, so nothing reflows.
50
+ const PINNED_CELL_CLASS = 'z-10 bg-background [:where(tbody>tr:nth-child(even))>&]:bg-(--table-stripe) group-hover/row:bg-accent';
51
+ const PINNED_COLUMN_CLASS = `sticky left-0 border-r ${PINNED_CELL_CLASS} group-hover/row:shadow-[inset_3px_0_0_0_var(--primary)]`;
50
52
  // The right-edge mirror, opaque for the same reason. Its edge marker points
51
53
  // inward from the right, so the two pinned columns bracket the row.
52
- const PINNED_LAST_COLUMN_CLASS = 'sticky right-0 z-10 border-l bg-background group-hover/row:bg-accent ' +
53
- 'group-hover/row:shadow-[inset_-3px_0_0_0_var(--primary)]';
54
+ const PINNED_LAST_COLUMN_CLASS = `sticky right-0 border-l ${PINNED_CELL_CLASS} group-hover/row:shadow-[inset_-3px_0_0_0_var(--primary)]`;
55
+ // A pinned first column's content, capped below md at 45% of the table's
56
+ // scroll area (`cqw`: the container is an inline-size query container)
57
+ // and wrapping, so a long title can't widen the column past the screen
58
+ // and leave every other column scrolling underneath it (issue #91).
59
+ // Wider screens are unchanged: the column sizes to its content.
60
+ const PINNED_FIRST_CONTENT_CLASS = 'max-md:max-w-[45cqw] max-md:whitespace-normal max-md:break-words';
54
61
  // Hover highlights the pinned cell, not the whole row. A row-level hover
55
62
  // cannot be made consistent against the zebra stripe: `tr:nth-child(even)`
56
63
  // is specificity (0,2,1) and `tr:hover` is (0,2,0), so the stripe wins and
@@ -169,9 +176,10 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
169
176
  // scrollable (wheel, trackpad, drag, and the sync effect); it
170
177
  // just no longer draws a bar directly under the last row.
171
178
  containerClassName: cn(pinFirstColumn &&
172
- '[scrollbar-width:none] [&::-webkit-scrollbar]:hidden'), children: [_jsx(TableHeader, { children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { children: headerGroup.headers.map((header, index) => {
179
+ '@container [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'), children: [_jsx(TableHeader, { children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { children: headerGroup.headers.map((header, index) => {
173
180
  const canSort = header.column.getCanSort();
174
181
  const sortDirection = header.column.getIsSorted();
182
+ const content = header.isPlaceholder ? null : canSort ? (_jsxs("button", { type: "button", className: cn('flex items-center gap-1 text-foreground', header.column.columnDef.meta?.align === 'center' && 'w-full justify-center'), onClick: header.column.getToggleSortingHandler(), children: [flexRender(header.column.columnDef.header, header.getContext()), sortDirection === 'asc' ? (_jsx(ArrowUpIcon, { className: "size-3.5 text-muted-foreground" })) : sortDirection === 'desc' ? (_jsx(ArrowDownIcon, { className: "size-3.5 text-muted-foreground" })) : (_jsx(ArrowUpDownIcon, { className: "size-3.5 text-muted-foreground/50" }))] })) : (flexRender(header.column.columnDef.header, header.getContext()));
175
183
  return (_jsx(TableHead, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn &&
176
184
  index === headerGroup.headers.length - 1 &&
177
185
  PINNED_LAST_COLUMN_CLASS, header.column.columnDef.meta?.align === 'center' && 'text-center'), "aria-sort": !canSort
@@ -180,8 +188,8 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
180
188
  ? 'ascending'
181
189
  : sortDirection === 'desc'
182
190
  ? 'descending'
183
- : 'none', children: header.isPlaceholder ? null : canSort ? (_jsxs("button", { type: "button", className: cn('flex items-center gap-1 text-foreground', header.column.columnDef.meta?.align === 'center' && 'w-full justify-center'), onClick: header.column.getToggleSortingHandler(), children: [flexRender(header.column.columnDef.header, header.getContext()), sortDirection === 'asc' ? (_jsx(ArrowUpIcon, { className: "size-3.5 text-muted-foreground" })) : sortDirection === 'desc' ? (_jsx(ArrowDownIcon, { className: "size-3.5 text-muted-foreground" })) : (_jsx(ArrowUpDownIcon, { className: "size-3.5 text-muted-foreground/50" }))] })) : (flexRender(header.column.columnDef.header, header.getContext())) }, header.id));
191
+ : 'none', children: pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: content })) : (content) }, header.id));
184
192
  }) }, headerGroup.id))) }), _jsx(TableBody, { children: table.getRowModel().rows.map((row) => (_jsx(TableRow, { className: cn((pinFirstColumn || pinLastColumn) && ROW_GROUP_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn &&
185
193
  index === row.getVisibleCells().length - 1 &&
186
- PINNED_LAST_COLUMN_CLASS, cell.column.columnDef.meta?.align === 'center' && 'text-center'), children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id))) }, row.id))) })] }), pinFirstColumn && isOverflowing && (_jsx("div", { ref: barRef, "aria-hidden": "true", tabIndex: -1, "data-slot": "bottom-scrollbar", className: "sticky bottom-0 z-20 h-4 overflow-x-auto border-t border-border bg-background", children: _jsx("div", { style: { width: scrollWidth, height: 1 } }) })), _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("p", { className: "type-body text-muted-foreground", children: [total === 0 ? 0 : (page - 1) * pageSize + 1, '–', Math.min(page * pageSize, total), " of ", total] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Previous page", disabled: page <= 1, onClick: () => onPageChange(page - 1), children: _jsx(ChevronLeftIcon, {}) }), _jsxs("span", { className: "type-body text-muted-foreground", children: ["Page ", page, " of ", pageCount] }), _jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Next page", disabled: page >= pageCount, onClick: () => onPageChange(page + 1), children: _jsx(ChevronRightIcon, {}) })] })] })] }))] }));
194
+ PINNED_LAST_COLUMN_CLASS, cell.column.columnDef.meta?.align === 'center' && 'text-center'), children: pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: flexRender(cell.column.columnDef.cell, cell.getContext()) })) : (flexRender(cell.column.columnDef.cell, cell.getContext())) }, cell.id))) }, row.id))) })] }), pinFirstColumn && isOverflowing && (_jsx("div", { ref: barRef, "aria-hidden": "true", tabIndex: -1, "data-slot": "bottom-scrollbar", className: "sticky bottom-0 z-20 h-4 overflow-x-auto border-t border-border bg-background", children: _jsx("div", { style: { width: scrollWidth, height: 1 } }) })), _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("p", { className: "type-body text-muted-foreground", children: [total === 0 ? 0 : (page - 1) * pageSize + 1, '–', Math.min(page * pageSize, total), " of ", total] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Previous page", disabled: page <= 1, onClick: () => onPageChange(page - 1), children: _jsx(ChevronLeftIcon, {}) }), _jsxs("span", { className: "type-body text-muted-foreground", children: ["Page ", page, " of ", pageCount] }), _jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Next page", disabled: page >= pageCount, onClick: () => onPageChange(page + 1), children: _jsx(ChevronRightIcon, {}) })] })] })] }))] }));
187
195
  }
@@ -0,0 +1,18 @@
1
+ import { type ReactNode } from 'react';
2
+ /** A filter that's on: its chip's text ("In stock: No") and how to take it off. */
3
+ export type ActiveFilter = {
4
+ id: string;
5
+ label: string;
6
+ onRemove: () => void;
7
+ };
8
+ export type SecondaryFiltersProps = {
9
+ /** The filter controls the button opens, each with its own label. */
10
+ children: ReactNode;
11
+ /** The filters among them that are on now, in the order their chips show. */
12
+ active: readonly ActiveFilter[];
13
+ /** Takes every one of them off at once. */
14
+ onClear: () => void;
15
+ /** The button's text. */
16
+ label?: string;
17
+ };
18
+ export declare function SecondaryFilters({ children, active, onClear, label }: SecondaryFiltersProps): import("react").JSX.Element;
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // A table toolbar's less-used filters behind one "Filters" button, so a
3
+ // table with many filters still starts near the top of the page (issue
4
+ // #93). The primary filters (search, status, whatever most visits use)
5
+ // stay in the toolbar; this goes beside them, holding the rest:
6
+ //
7
+ // - The button says how many of its filters are on ("Filters 2", named
8
+ // "Filters, 2 active"), and opens them in a popover, or a sheet from the
9
+ // bottom on a phone. Base UI gives focus back to the button on close.
10
+ // - Each filter that's on shows beside the button as a chip with a remove
11
+ // button, so a filtered list never looks unfiltered while they're
12
+ // hidden; "Clear filters" takes them all off. Both are here too, in the
13
+ // panel's footer.
14
+ // - It holds no state of its own: the app passes what's on (`active`, from
15
+ // the URL like every filter, useTableUrlState), so the count and chips
16
+ // follow back/forward and saved views. Collapsing is display only.
17
+ // - Esc in a filter's open list closes the list only (Base UI's lists keep
18
+ // their Esc); the next one closes the panel.
19
+ import { useState } from 'react';
20
+ import { SlidersHorizontalIcon, XIcon } from 'lucide-react';
21
+ import { Button } from '../ui/button.js';
22
+ import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '../ui/popover.js';
23
+ import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '../ui/sheet.js';
24
+ import { useIsMobile } from '../../hooks/use-mobile.js';
25
+ export function SecondaryFilters({ children, active, onClear, label = 'Filters' }) {
26
+ const isMobile = useIsMobile();
27
+ const [open, setOpen] = useState(false);
28
+ const count = active.length;
29
+ const trigger = (_jsxs(Button, { variant: "outline", "aria-label": count > 0 ? `${label}, ${count} active` : label, children: [_jsx(SlidersHorizontalIcon, { "aria-hidden": "true" }), label, count > 0 && (_jsx("span", { "aria-hidden": "true", className: "rounded-full bg-primary px-1.5 type-caption text-primary-foreground tabular-nums", children: count }))] }));
30
+ const footer = (_jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { variant: "ghost", size: "sm", disabled: count === 0, onClick: onClear, children: "Clear filters" }), _jsx(Button, { size: "sm", onClick: () => setOpen(false), children: "Done" })] }));
31
+ return (_jsxs("div", { "data-slot": "secondary-filters", className: "flex flex-wrap items-center gap-2", children: [isMobile ? (_jsxs(Sheet, { open: open, onOpenChange: setOpen, children: [_jsx(SheetTrigger, { render: trigger }), _jsxs(SheetContent, { side: "bottom", className: "max-h-[85svh] overflow-y-auto", children: [_jsx(SheetHeader, { children: _jsx(SheetTitle, { children: label }) }), _jsx("div", { className: "flex flex-col gap-3 px-4 [&>*]:w-full", children: children }), _jsx(SheetFooter, { children: footer })] })] })) : (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { render: trigger }), _jsxs(PopoverContent, { align: "start", className: "w-auto min-w-64 gap-3 p-3", children: [_jsx(PopoverTitle, { children: label }), _jsx("div", { className: "flex flex-col gap-3", children: children }), footer] })] })), active.map((filter) => (_jsxs("span", { "data-slot": "secondary-filter-chip", className: "inline-flex h-7 items-center gap-0.5 rounded-md bg-muted pr-0.5 pl-2 type-caption text-foreground", children: [filter.label, _jsx(Button, { variant: "ghost", size: "icon-xs", "aria-label": `Remove filter: ${filter.label}`, onClick: filter.onRemove, children: _jsx(XIcon, { "aria-hidden": "true" }) })] }, filter.id))), count > 1 && (_jsx(Button, { variant: "ghost", size: "sm", onClick: onClear, children: "Clear filters" }))] }));
32
+ }
@@ -53,8 +53,14 @@ function ComboboxChip({ className, children, showRemove = true, ...props }) {
53
53
  // it after the chip ("Remove fragile") when the chip is plain text.
54
54
  , { "aria-label": typeof children === "string" ? `Remove ${children}` : "Remove", render: _jsx(Button, { variant: "ghost", size: "icon-xs" }), className: "-ml-1 opacity-50 hover:opacity-100", "data-slot": "combobox-chip-remove", children: _jsx(XIcon, { className: "pointer-events-none" }) }))] }));
55
55
  }
56
+ // Patched (ui-foundation, issue #94): upstream's input always keeps 4rem,
57
+ // so chips that fit on one line still pushed it onto an empty second one
58
+ // and the control stood twice as tall as a Select beside it. With chips
59
+ // in and no focus it takes only what's left of the chips' line (none, if
60
+ // they fill it); focused, it gets its 4rem back to type in, wrapping then
61
+ // if the line is full.
56
62
  function ComboboxChipsInput({ className, ...props }) {
57
- return (_jsx(ComboboxPrimitive.Input, { "data-slot": "combobox-chip-input", className: cn("min-w-16 flex-1 outline-none", className), ...props }));
63
+ return (_jsx(ComboboxPrimitive.Input, { "data-slot": "combobox-chip-input", className: cn("min-w-16 flex-1 outline-none [[data-slot=combobox-chips]:has([data-slot=combobox-chip])_&:not(:focus)]:min-w-0", className), ...props }));
58
64
  }
59
65
  function useComboboxAnchor() {
60
66
  return React.useRef(null);
@@ -9,7 +9,7 @@ export declare function useTableUrlState<F extends string, M extends string = ne
9
9
  setPage: (next: number) => void;
10
10
  setSorting: (next: SortingState) => void;
11
11
  setFilter: (name: F, value: string) => void;
12
- setFilters: (changes: Partial<Record<F, string>>) => void;
12
+ setFilters: (changes: Partial<Record<F, string>> & Partial<Record<M, string[]>>) => void;
13
13
  setMultiFilter: (name: M, values: string[]) => void;
14
14
  applyView: (view: TableView<F, M>) => void;
15
15
  };
@@ -36,11 +36,12 @@ export function useTableUrlState(filterNames, multiFilterNames = [], views = {})
36
36
  setPage: (next) => update({ page: next > 1 ? String(next) : null }),
37
37
  setSorting: (next) => update({ sort: serializeSort(next), page: null }),
38
38
  setFilter: (name, value) => update({ [name]: value, page: null }),
39
- // Several single-value filters in one call — for any user action that
40
- // changes more than one (both ends of a date range, "clear all"). Two
41
- // setFilter()s in the same tick lose all but the last: react-router
42
- // hands each setSearchParams updater the params from the last render,
43
- // not the ones the previous updater produced.
39
+ // Several filters in one call — for any user action that changes more
40
+ // than one (both ends of a date range, "clear filters"). Two setters in
41
+ // the same tick lose all but the last: react-router hands each
42
+ // setSearchParams updater the params from the last render, not the
43
+ // ones the previous updater produced. Single-value filters take a
44
+ // string ('' clears), multi-value ones a list ([] clears).
44
45
  setFilters: (changes) => update({ ...changes, page: null }),
45
46
  setMultiFilter: (name, values) => update({ [name]: values, page: null }),
46
47
  // Replaces the whole filter + sort state at once — a saved view. See
package/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export { ListEditor } from './components/app/list-editor.js';
15
15
  export { PasswordInput } from './components/app/password-input.js';
16
16
  export { RichTextEditor, type RichTextEditorProps } from './components/app/rich-text-editor.js';
17
17
  export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
18
+ export { SecondaryFilters, type ActiveFilter, type SecondaryFiltersProps } from './components/app/secondary-filters.js';
18
19
  export { StageCircle, type StageCircleProps, type StageCircleStage, type StageCircleTone, } from './components/app/stage-circle.js';
19
20
  export { ThemeProvider } from './components/theme-provider.js';
20
21
  export { LoginRoute, type LoginRouteProps } from './routes/login.js';
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ export { ListEditor } from './components/app/list-editor.js';
20
20
  export { PasswordInput } from './components/app/password-input.js';
21
21
  export { RichTextEditor } from './components/app/rich-text-editor.js';
22
22
  export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
23
+ export { SecondaryFilters } from './components/app/secondary-filters.js';
23
24
  export { StageCircle, } from './components/app/stage-circle.js';
24
25
  export { ThemeProvider } from './components/theme-provider.js';
25
26
  // Screens outside the app shell
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "3.21.0",
3
+ "version": "3.22.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": {
package/styles/theme.css CHANGED
@@ -198,6 +198,11 @@
198
198
  --category-6: var(--category-6-700);
199
199
  --category-7: var(--category-7-700);
200
200
  --category-8: var(--category-8-700);
201
+ /* The zebra stripe (table.tsx: bg-muted/40 on even rows) mixed onto the
202
+ page, opaque: what a pinned table cell paints on an even row, so it
203
+ matches the row without letting scrolled columns show through. Mixed
204
+ in sRGB, as the browser composites the translucent stripe. */
205
+ --table-stripe: color-mix(in srgb, var(--muted) 40%, var(--background));
201
206
  --border: var(--gray-200);
202
207
  --input: var(--gray-200);
203
208
  --ring: var(--gray-400);
@@ -269,6 +274,7 @@
269
274
  --category-6: var(--category-6-400);
270
275
  --category-7: var(--category-7-400);
271
276
  --category-8: var(--category-8-400);
277
+ --table-stripe: color-mix(in srgb, var(--muted) 40%, var(--background));
272
278
  --border: var(--white-a10);
273
279
  --input: var(--white-a15);
274
280
  --ring: var(--gray-500);