@tristan2828/ui-foundation 3.21.0 → 3.23.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/conventions/docs/add-an-entity.md +6 -1
- package/conventions/docs/cell-patterns.md +74 -8
- package/conventions/docs/design-language.md +4 -0
- package/conventions/docs/entity-plan-template.md +2 -1
- package/dist/components/app/count-link.d.ts +14 -0
- package/dist/components/app/count-link.js +20 -0
- package/dist/components/app/data-table.js +21 -13
- package/dist/components/app/secondary-filters.d.ts +18 -0
- package/dist/components/app/secondary-filters.js +32 -0
- package/dist/components/ui/combobox.js +7 -1
- package/dist/hooks/use-table-url-state.d.ts +1 -1
- package/dist/hooks/use-table-url-state.js +6 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
- package/styles/theme.css +6 -0
|
@@ -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
|
|
@@ -10,14 +10,14 @@ alternatives ([`column-options.md`](column-options.md)), not designed in the abs
|
|
|
10
10
|
Patterns 12 (boolean), 15 (yes/no flipped in the row), 16 (title linking
|
|
11
11
|
to its view), 17 (quick action on the view) and 18 (editable value) are
|
|
12
12
|
the template's own references: Widget's In Stock and Name columns and its
|
|
13
|
-
view render them, and their specs pin them.
|
|
14
|
-
and
|
|
15
|
-
|
|
16
|
-
package's Storybook renders
|
|
17
|
-
`patterns/CellPatterns`), and
|
|
18
|
-
contrast in both themes. The
|
|
19
|
-
marked **Unproven**: written ahead of a
|
|
20
|
-
column, so no app has tested them on real data yet. Prefer a proven
|
|
13
|
+
view render them, and their specs pin them. Patterns 19 (stage circle)
|
|
14
|
+
and 20 (count linking to the related records), the multi-value variant
|
|
15
|
+
of 4, the dependency list in 10 and the pressed icon in 15 were each
|
|
16
|
+
built for a real app's screen first; the package's Storybook renders
|
|
17
|
+
them (`app/StageCircle`, `app/CountLink`, `patterns/CellPatterns`), and
|
|
18
|
+
its checks pin their accessibility and contrast in both themes. The
|
|
19
|
+
other patterns from 11 on are marked **Unproven**: written ahead of a
|
|
20
|
+
real column, so no app has tested them on real data yet. Prefer a proven
|
|
21
21
|
pattern when one fits. When you use an unproven one, say so in the
|
|
22
22
|
entity's plan, and raise what you learned for the foundation, so the
|
|
23
23
|
entry can lose its label. A pattern that proves generic is worth raising
|
|
@@ -836,3 +836,69 @@ cell: ({ getValue }) => {
|
|
|
836
836
|
`<EditableValue>` like a badge.
|
|
837
837
|
- **Cost:** a reader learns the shapes once. Until then the word does the
|
|
838
838
|
work, which is why it stays.
|
|
839
|
+
|
|
840
|
+
## 20. Count linking to the related records
|
|
841
|
+
|
|
842
|
+
A count of the records in another table that point at this row (open
|
|
843
|
+
Tasks per Project, items per Tag), which opens that table already filtered
|
|
844
|
+
to them. The count is a `computed` integer (the entity plan's type).
|
|
845
|
+
The package ships it: `CountLink`, rendered in its Storybook
|
|
846
|
+
(`app/CountLink`).
|
|
847
|
+
|
|
848
|
+
```tsx
|
|
849
|
+
import { CountLink } from '@tristan2828/ui-foundation'
|
|
850
|
+
|
|
851
|
+
cell: ({ row }) => {
|
|
852
|
+
const { id, name, openTaskCount: count } = row.original
|
|
853
|
+
return (
|
|
854
|
+
<CountLink
|
|
855
|
+
count={count}
|
|
856
|
+
to={`/tasks?project=${id}`}
|
|
857
|
+
label={`open ${count === 1 ? 'task' : 'tasks'} in ${name}`}
|
|
858
|
+
/>
|
|
859
|
+
)
|
|
860
|
+
}
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
What the component decides, so a cell doesn't have to:
|
|
864
|
+
|
|
865
|
+
- **Underlined at rest**, not only on hover as in pattern 16. A title
|
|
866
|
+
reads as a name to open; a bare number doesn't look like it goes
|
|
867
|
+
anywhere until it's underlined. Hover thickens the line. Foreground,
|
|
868
|
+
not `text-link`: `--link` is for links in written text, and a column of
|
|
869
|
+
blue numbers pulls the eye from every other column.
|
|
870
|
+
- **Zero is a plain `0`**, not a link (a link to an empty list is a dead
|
|
871
|
+
end) and not an em dash: zero is a value (pattern 11). Same
|
|
872
|
+
`tabular-nums`, so it lines up with the links.
|
|
873
|
+
- **The accessible name is the count, then `label`**: `3 open tasks in
|
|
874
|
+
Kitchen remodel`. Every row's link text is just a number, so `3` alone
|
|
875
|
+
isn't distinct, and starting with it keeps the link findable by what
|
|
876
|
+
it shows (speech input, `getByRole('link', { name: /^3 / })`).
|
|
877
|
+
|
|
878
|
+
What the cell decides:
|
|
879
|
+
|
|
880
|
+
- **`label` says what's counted and which row**, singular for one
|
|
881
|
+
(`open task in Garden`).
|
|
882
|
+
- **The target is the other entity's list, filtered by the URL**:
|
|
883
|
+
`/<other>?<filterName>=<id>`, the same query string `useTableUrlState`
|
|
884
|
+
reads (a multi-value filter takes the id the same way). Back, forward,
|
|
885
|
+
a refresh and saved views keep working, and the other list needs a
|
|
886
|
+
filter by this reference (`Filter: yes` in its plan).
|
|
887
|
+
- **The count and the list it opens must agree.** If the target list
|
|
888
|
+
narrows by default (hides finished records unless asked), the count
|
|
889
|
+
applies the same condition, and the word in the name says so (`open`
|
|
890
|
+
tasks). Where the list has a filter for that condition, prefer putting
|
|
891
|
+
it in the link too (`?project=7&status=open`), so the list shows what
|
|
892
|
+
was counted even if its default changes. Say the rule in the field's
|
|
893
|
+
`description` in `openapi.yaml` (`Tasks in this project that aren't
|
|
894
|
+
done; what /tasks?project=<id> lists`), so the backend, the mocks and
|
|
895
|
+
the next agent count the same thing.
|
|
896
|
+
- **Sorted on the server, as a number**, like any computed field: the
|
|
897
|
+
scalar subquery is the sort expression, with a stable tiebreak (the
|
|
898
|
+
name, then the id) so rows with the same count don't change places
|
|
899
|
+
between pages.
|
|
900
|
+
- **Cost:** one correlated subquery per row read. Fine at an app's scale;
|
|
901
|
+
give the other table's foreign key column an index, since every row's
|
|
902
|
+
count reads it.
|
|
903
|
+
- **On the record's view**, the same `CountLink` beside its label (`Open
|
|
904
|
+
tasks: 3`), with the record's name in `label` like the table's.
|
|
@@ -194,6 +194,10 @@ shade: note `info`, tip `success`, important category slot 2, warning
|
|
|
194
194
|
`warning`, caution `destructive`. The package draws all of it; an app
|
|
195
195
|
only renders `<Markdown>` or the editor.
|
|
196
196
|
|
|
197
|
+
`--link` is for written text only. A link in a table cell is
|
|
198
|
+
`text-foreground` and underlined, never blue: a column of coloured text
|
|
199
|
+
pulls the eye from every other column (cell patterns 16 and 20).
|
|
200
|
+
|
|
197
201
|
## Density
|
|
198
202
|
|
|
199
203
|
Tables have three densities, set with `data-density` on **any ancestor**
|
|
@@ -43,7 +43,8 @@ One or two sentences: what one record is, and who uses the screens.
|
|
|
43
43
|
every read and never stores it — give its rule, and if it's a choice,
|
|
44
44
|
its values in sort order with a label for each; a read-only column, a filter and a sort the
|
|
45
45
|
server evaluates, never on the form). A count of related records is a
|
|
46
|
-
`computed` integer
|
|
46
|
+
`computed` integer; say whether its column links to those records
|
|
47
|
+
(cell pattern 20) and which of them it counts (`open tasks`).
|
|
47
48
|
- *Close variants* (built by a small, stated change to the nearest
|
|
48
49
|
pattern): `integer` (from decimal), `url` (from email — a format
|
|
49
50
|
check), `date` (from date-time — no time part).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type To } from 'react-router';
|
|
2
|
+
export interface CountLinkProps {
|
|
3
|
+
/** How many related records there are. Count what `to` lists. */
|
|
4
|
+
count: number;
|
|
5
|
+
/** The other entity's list, filtered to this row: `/tasks?project=7`. */
|
|
6
|
+
to: To;
|
|
7
|
+
/**
|
|
8
|
+
* What's counted and for which row, read after the count: `open tasks in
|
|
9
|
+
* Kitchen remodel` (`open task` for one).
|
|
10
|
+
*/
|
|
11
|
+
label: string;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function CountLink({ count, to, label, className }: CountLinkProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// A count linking to the related records (cell pattern 20): how many
|
|
3
|
+
// records of another entity point at this row, as a link to that entity's
|
|
4
|
+
// list filtered to them (`/tasks?project=7`, the query string
|
|
5
|
+
// useTableUrlState reads).
|
|
6
|
+
//
|
|
7
|
+
// - Underlined at rest: a bare number doesn't look like it goes anywhere.
|
|
8
|
+
// Foreground, not text-link: --link is for written text.
|
|
9
|
+
// - Zero is a plain 0, not a link (a list of nothing is a dead end) and
|
|
10
|
+
// not an em dash (zero is a value).
|
|
11
|
+
// - The accessible name is the count, then `label`, so every row's link is
|
|
12
|
+
// distinct ("3 open tasks in Kitchen remodel") and still starts with
|
|
13
|
+
// what it shows.
|
|
14
|
+
import { Link } from 'react-router';
|
|
15
|
+
import { cn } from 'cn';
|
|
16
|
+
export function CountLink({ count, to, label, className }) {
|
|
17
|
+
if (count === 0)
|
|
18
|
+
return _jsx("span", { className: cn('tabular-nums', className), children: "0" });
|
|
19
|
+
return (_jsx(Link, { to: to, "aria-label": `${count} ${label}`, className: cn('rounded-sm tabular-nums text-foreground underline underline-offset-4 outline-none', 'hover:decoration-2 focus-visible:ring-3 focus-visible:ring-ring/50', className), children: count }));
|
|
20
|
+
}
|
|
@@ -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
|
|
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.
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
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 =
|
|
53
|
-
|
|
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:
|
|
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
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { AppShell, type AppShellProps, type NavItem } from './components/app/app-shell.js';
|
|
2
|
+
export { CountLink, type CountLinkProps } from './components/app/count-link.js';
|
|
2
3
|
export { DataTable, type DataTableProps, type SortingState } from './components/app/data-table.js';
|
|
3
4
|
export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
|
|
4
5
|
export { EntityView, type EntityViewField, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
|
|
@@ -15,6 +16,7 @@ export { ListEditor } from './components/app/list-editor.js';
|
|
|
15
16
|
export { PasswordInput } from './components/app/password-input.js';
|
|
16
17
|
export { RichTextEditor, type RichTextEditorProps } from './components/app/rich-text-editor.js';
|
|
17
18
|
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
19
|
+
export { SecondaryFilters, type ActiveFilter, type SecondaryFiltersProps } from './components/app/secondary-filters.js';
|
|
18
20
|
export { StageCircle, type StageCircleProps, type StageCircleStage, type StageCircleTone, } from './components/app/stage-circle.js';
|
|
19
21
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
20
22
|
export { LoginRoute, type LoginRouteProps } from './routes/login.js';
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// points (`/gateway`, `/mocks`, `/testing`) so each stays behind its boundary.
|
|
5
5
|
// Composites
|
|
6
6
|
export { AppShell } from './components/app/app-shell.js';
|
|
7
|
+
export { CountLink } from './components/app/count-link.js';
|
|
7
8
|
export { DataTable } from './components/app/data-table.js';
|
|
8
9
|
export { EntityForm } from './components/app/entity-form.js';
|
|
9
10
|
export { EntityView, } from './components/app/entity-view.js';
|
|
@@ -20,6 +21,7 @@ export { ListEditor } from './components/app/list-editor.js';
|
|
|
20
21
|
export { PasswordInput } from './components/app/password-input.js';
|
|
21
22
|
export { RichTextEditor } from './components/app/rich-text-editor.js';
|
|
22
23
|
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
24
|
+
export { SecondaryFilters } from './components/app/secondary-filters.js';
|
|
23
25
|
export { StageCircle, } from './components/app/stage-circle.js';
|
|
24
26
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
25
27
|
// Screens outside the app shell
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.23.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);
|