@tristan2828/ui-foundation 4.10.0 → 4.12.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 +17 -1
- package/conventions/docs/cell-patterns.md +88 -5
- package/conventions/docs/entity-plan-template.md +4 -1
- package/dist/components/app/data-table.d.ts +8 -1
- package/dist/components/app/data-table.js +9 -2
- package/dist/components/app/date-field.d.ts +31 -0
- package/dist/components/app/date-field.js +41 -0
- package/dist/components/app/progress-ring.d.ts +12 -0
- package/dist/components/app/progress-ring.js +27 -0
- package/dist/components/app/rating-star.d.ts +10 -0
- package/dist/components/app/rating-star.js +22 -0
- package/dist/components/app/step-bar.d.ts +16 -0
- package/dist/components/app/step-bar.js +23 -0
- package/dist/components/ui/calendar.js +9 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/lib/plain-date.d.ts +12 -0
- package/dist/lib/plain-date.js +37 -0
- package/package.json +1 -1
- package/styles/theme.css +9 -0
|
@@ -121,7 +121,8 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
121
121
|
(`WIDGET_STATUS_LABELS`) read by the options, the trigger (through
|
|
122
122
|
`<SelectValue>`'s children, which otherwise shows the raw value), the
|
|
123
123
|
toolbar filter and every badge — a `date-time` copies
|
|
124
|
-
Available From's date picker, a `
|
|
124
|
+
Available From's date picker, a `date` (a day, no time) is the
|
|
125
|
+
foundation's `DateField` (see below), a `yes/no` copies In Stock — a `Switch`
|
|
125
126
|
(`@tristan2828/ui-foundation/ui/switch`) in a horizontal `Field` with
|
|
126
127
|
its label beside it on the form, cell pattern 12 (the word Yes/No) in
|
|
127
128
|
the table, and a three-way toolbar `Select` (either / yes / no) whose URL
|
|
@@ -170,6 +171,21 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
170
171
|
that arrives later reaches the cells through context, as
|
|
171
172
|
`category-names.tsx` does for category names.
|
|
172
173
|
|
|
174
|
+
**A `date`** (a day with no time: when something was finished, a
|
|
175
|
+
due day) is `type: string, format: date` in `openapi.yaml`, so the
|
|
176
|
+
wire, the generated type and the form all hold `'YYYY-MM-DD'`, and
|
|
177
|
+
`''` on the form for an optional one not set (sent as `null`). No
|
|
178
|
+
`Date` object anywhere, so no time zone can move the day. On the form
|
|
179
|
+
it's the foundation's `DateField` in a `Controller` (`clearable` when
|
|
180
|
+
optional); the schema is the string (`z.string().min(1, …)` when
|
|
181
|
+
required). The table and the view show `formatPlainDate(value)`
|
|
182
|
+
("Sep 2, 2026"), the not-set label when empty. Editing in place is
|
|
183
|
+
`editInPlace({ kind: 'choice', ... })` with `DateField` as the
|
|
184
|
+
control, `defaultOpen`, `modal={false}`, `onChange={(day) =>
|
|
185
|
+
props.commit(day)}` and `onOpenChange` cancelling on close, as a
|
|
186
|
+
Select is (Storybook `app/DateField` EditInPlace). The backend column
|
|
187
|
+
is a SQL `date` (`datetime.date` in the model).
|
|
188
|
+
|
|
173
189
|
**A `yes/no` marked `toggle`** copies In Stock's cell
|
|
174
190
|
(`in-stock-toggle.tsx`, cell pattern 15): a `Switch` in its own
|
|
175
191
|
component that PATCHes the one field through `useSaveWidgetField`, the
|
|
@@ -10,11 +10,13 @@ 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. Patterns 19 (stage circle)
|
|
14
|
-
|
|
15
|
-
of 4, the
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
view render them, and their specs pin them. Patterns 19 (stage circle),
|
|
14
|
+
20 (count linking to the related records) and 22 (a highlighted row),
|
|
15
|
+
the multi-value variant of 4, the step bar and star in 9, the dependency
|
|
16
|
+
list in 10, the ring in 13 and the pressed icon in 15 were each built
|
|
17
|
+
for a real app's screen first; the package's Storybook renders
|
|
18
|
+
them (`app/StageCircle`, `app/CountLink`, `patterns/CellPatterns`,
|
|
19
|
+
`patterns/Scales`, `app/DataTable`), and
|
|
18
20
|
its checks pin their accessibility and contrast in both themes. The
|
|
19
21
|
other patterns from 11 on are marked **Unproven**: written ahead of a
|
|
20
22
|
real column, so no app has tested them on real data yet. Prefer a proven
|
|
@@ -281,6 +283,38 @@ own state.
|
|
|
281
283
|
does not check icon contrast. That's a defensible choice — make it
|
|
282
284
|
deliberately, in a comment, not by accident.
|
|
283
285
|
|
|
286
|
+
### Two variants the package draws: a step bar and a star
|
|
287
|
+
|
|
288
|
+
Both are in the package's Storybook (`patterns/Scales`), every value on
|
|
289
|
+
the page and on a card, measured by axe and the glyph check in both
|
|
290
|
+
themes. Each is one image to a screen reader, named in words.
|
|
291
|
+
|
|
292
|
+
- **`StepBar`, an ordered enum of a few bands** (best to worst): a short
|
|
293
|
+
bar of steps, the first `value` filled in `foreground`, the rest the
|
|
294
|
+
track (`border`), with the band's word beside it in `muted-foreground`.
|
|
295
|
+
Narrower than five stars, and it reads as a scale, not a rating. Its
|
|
296
|
+
name says both: `label="Quality: High, 4 of 5"` (the default is
|
|
297
|
+
"High, 4 of 5", enough where the column header names the field).
|
|
298
|
+
|
|
299
|
+
```tsx
|
|
300
|
+
const QUALITY: Record<Quality, { word: string; value: number }> = {
|
|
301
|
+
best: { word: 'Best', value: 5 }, high: { word: 'High', value: 4 }, /* … */
|
|
302
|
+
}
|
|
303
|
+
<StepBar value={QUALITY[q].value} word={QUALITY[q].word} />
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
- **`RatingStar`, a three-value enum** (yes / partly / no): a full, half
|
|
307
|
+
or empty star with the word beside it. The shape carries the value, so
|
|
308
|
+
it survives greyscale. The half is the left half filled over the whole
|
|
309
|
+
outline, never half a star: alone, half a star reads as broken.
|
|
310
|
+
|
|
311
|
+
```tsx
|
|
312
|
+
<RatingStar value={OWNED[o].value /* 'full' | 'half' | 'empty' */} word={OWNED[o].word} />
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
- Both draw in `foreground`. Tone one only as pattern 5 would (a `*-text`
|
|
316
|
+
class in `className`), never by band alone.
|
|
317
|
+
|
|
284
318
|
## 10. Summary in the cell, detail in a popover
|
|
285
319
|
|
|
286
320
|
A one-to-many value (`★ 4.7 (3)` opening a per-person breakdown).
|
|
@@ -414,6 +448,24 @@ A percentage, or a count out of a total (`7 of 12 done`).
|
|
|
414
448
|
- **Cost:** a bar takes far more width than the number it replaces. On a
|
|
415
449
|
wide table, the plain number from pattern 11 usually wins.
|
|
416
450
|
|
|
451
|
+
### The compact form: `ProgressRing`
|
|
452
|
+
|
|
453
|
+
Done out of total (a checklist, how far through an ordered list, the
|
|
454
|
+
steps of a process) as a small ring filling clockwise beside the count
|
|
455
|
+
("4/6"). Proven in an app's table cells and on its view; the package
|
|
456
|
+
ships it, so this needs no `progress` install.
|
|
457
|
+
|
|
458
|
+
```tsx
|
|
459
|
+
<ProgressRing done={row.original.doneCount} total={row.original.itemCount} />
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
- The arc is `foreground` while under way and `success-text` once
|
|
463
|
+
complete, on a `border` track; the count is in tabular numbers. Zero
|
|
464
|
+
of zero shows the empty ring and reads "Not started".
|
|
465
|
+
- Its name is "4 of 6 done" unless `label` says better ("4 of 6 chapters
|
|
466
|
+
read"). The count beside it is `aria-hidden`: the name already says it.
|
|
467
|
+
- Prefer it to the bar in a table: it's as narrow as the count.
|
|
468
|
+
|
|
417
469
|
## 14. Avatar and name — *Unproven*
|
|
418
470
|
|
|
419
471
|
A person: an assignee or an owner.
|
|
@@ -1155,3 +1207,34 @@ const { page, filters, setFilter, setPage } = useTableUrlState(['kind'])
|
|
|
1155
1207
|
filter, with a button to take it off. An empty group never renders:
|
|
1156
1208
|
groups come from the rows.
|
|
1157
1209
|
- **Not built:** collapsing groups, grouping by more than one key.
|
|
1210
|
+
|
|
1211
|
+
## 22. A highlighted row, and rows that are done
|
|
1212
|
+
|
|
1213
|
+
One row of a table marked as **the one to look at**: next up, the
|
|
1214
|
+
current one. `DataTable`'s `highlightRow` returns true for it.
|
|
1215
|
+
|
|
1216
|
+
```tsx
|
|
1217
|
+
<DataTable highlightRow={(book) => book.id === nextUpId} … />
|
|
1218
|
+
```
|
|
1219
|
+
|
|
1220
|
+
- **A wash of info on every cell**, over the zebra stripe and the pinned
|
|
1221
|
+
columns' opaque backgrounds (`--table-highlight`, pre-mixed onto the
|
|
1222
|
+
page), and a bar in `info-text` at the row's start, so it reads without
|
|
1223
|
+
colour too. Still **say why in the row** (a "Next up" word or badge):
|
|
1224
|
+
the wash says "look here", not what it is.
|
|
1225
|
+
- **Muted text inside it is darkened for you.** `muted-foreground`
|
|
1226
|
+
measures 4.09:1 on the wash, under 4.5:1, and no lighter wash fixes it
|
|
1227
|
+
(4.47:1 even at 4%). So the row redefines `--muted-foreground` as
|
|
1228
|
+
`--table-highlight-muted-foreground`: 6.75:1 in light, the usual grey in
|
|
1229
|
+
dark (6.61:1). Use `text-muted-foreground` as anywhere else.
|
|
1230
|
+
- **At most a row or two.** A highlight on every other row highlights
|
|
1231
|
+
nothing; a state many rows share is a column (pattern 5).
|
|
1232
|
+
- **Rows that are done: colour, not opacity.** Greying finished rows with
|
|
1233
|
+
`opacity-50` (or `text-foreground/60`) drops their text under 4.5:1 the
|
|
1234
|
+
same way. Render their text in `text-muted-foreground`, which is
|
|
1235
|
+
measured, and keep the word that says done.
|
|
1236
|
+
- Storybook's `app/DataTable` Highlighted story has two highlighted rows
|
|
1237
|
+
(one striped), pinned columns and muted text inside them, under axe and
|
|
1238
|
+
the token check in both themes; `e2e/data-table.spec.ts` checks every
|
|
1239
|
+
cell wears the wash.
|
|
1240
|
+
|
|
@@ -47,7 +47,10 @@ One or two sentences: what one record is, and who uses the screens.
|
|
|
47
47
|
(cell pattern 20) and which of them it counts (`open tasks`).
|
|
48
48
|
- *Close variants* (built by a small, stated change to the nearest
|
|
49
49
|
pattern): `integer` (from decimal), `url` (from email — a format
|
|
50
|
-
check)
|
|
50
|
+
check).
|
|
51
|
+
- *From the package, no Widget example:* `date` (a calendar day, no
|
|
52
|
+
time: OpenAPI `format: date`; the foundation's `DateField`; say
|
|
53
|
+
whether it's optional).
|
|
51
54
|
- *Not supported yet:* a yes/no that can also be unset (three states),
|
|
52
55
|
file, and anything else.
|
|
53
56
|
Write it anyway with a note; the playbook will stop and raise it
|
|
@@ -90,6 +90,13 @@ export type DataTableProps<TData extends Record<string, unknown>> = {
|
|
|
90
90
|
* still get the column names; sorting moves to the toolbar.
|
|
91
91
|
*/
|
|
92
92
|
header?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* The one row to look at (next up, the current one): tinted with info's
|
|
95
|
+
* wash, a bar in `info-text` at its start, and muted text inside it
|
|
96
|
+
* darkened so it still clears 4.5:1. Return true for at most a row or two.
|
|
97
|
+
* Colour isn't the only signal: say why in the row too (a badge, a word).
|
|
98
|
+
*/
|
|
99
|
+
highlightRow?: (row: TData) => boolean;
|
|
93
100
|
};
|
|
94
101
|
export type DataTableWidth = 'full' | 'content';
|
|
95
|
-
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, width, groupBy, groupSummary, header: showHeader, }: DataTableProps<TData>): import("react").JSX.Element;
|
|
102
|
+
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, width, groupBy, groupSummary, header: showHeader, highlightRow, }: DataTableProps<TData>): import("react").JSX.Element;
|
|
@@ -75,6 +75,13 @@ const PINNED_FIRST_CONTENT_CLASS = 'max-md:max-w-[45cqw] max-md:whitespace-norma
|
|
|
75
75
|
// background so it reads identically on striped and unstriped rows, and it
|
|
76
76
|
// stays on screen when the table is scrolled right.
|
|
77
77
|
const ROW_GROUP_CLASS = 'group/row hover:bg-transparent';
|
|
78
|
+
// `highlightRow`: the wash on every cell, so it covers the zebra stripe
|
|
79
|
+
// (on the row) and the pinned cells' opaque backgrounds (on the cell, and
|
|
80
|
+
// one class weaker than this), while a pinned cell's hover still wins. A
|
|
81
|
+
// bar on the first cell, as hover's is, so it reads without colour too.
|
|
82
|
+
// Inside, muted text takes the darker grey the wash needs (theme.css).
|
|
83
|
+
const HIGHLIGHTED_ROW_CLASS = '[--muted-foreground:var(--table-highlight-muted-foreground)] [&>td]:bg-(--table-highlight) ' +
|
|
84
|
+
'[&>td:first-child]:shadow-[inset_3px_0_0_0_var(--info-text)]';
|
|
78
85
|
// `header={false}`: the header row stays in the table, so every cell keeps
|
|
79
86
|
// its column header for a screen reader, but takes no room: each header
|
|
80
87
|
// is its name in an sr-only span, in a cell with no height or padding.
|
|
@@ -150,7 +157,7 @@ function useBottomScrollbar(containerRef, isSuccessView) {
|
|
|
150
157
|
}, [containerRef, isSuccessView, isOverflowing]);
|
|
151
158
|
return { barRef, isOverflowing, scrollWidth };
|
|
152
159
|
}
|
|
153
|
-
export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width = 'full', groupBy, groupSummary, header: showHeader = true, }) {
|
|
160
|
+
export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width = 'full', groupBy, groupSummary, header: showHeader = true, highlightRow, }) {
|
|
154
161
|
// Editing in place in the rows (cell pattern 18, "In a table's rows"):
|
|
155
162
|
// a cell that renders an <EditableValue> edits where it's shown, one at
|
|
156
163
|
// a time, with the leave-page prompt while one is open, the same rules
|
|
@@ -211,7 +218,7 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
|
|
|
211
218
|
const rows = table.getRowModel().rows;
|
|
212
219
|
const visibleColumnCount = table.getVisibleLeafColumns().length;
|
|
213
220
|
// One body row, the same in a plain table and a grouped one.
|
|
214
|
-
const renderRow = (row) => (_jsx(TableRow, { className: cn(pinFirstColumn && ROW_GROUP_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn && index === row.getVisibleCells().length - 1 && 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));
|
|
221
|
+
const renderRow = (row) => (_jsx(TableRow, { "data-highlighted": highlightRow?.(row.original) || undefined, className: cn(pinFirstColumn && ROW_GROUP_CLASS, highlightRow?.(row.original) && HIGHLIGHTED_ROW_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn && index === row.getVisibleCells().length - 1 && 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));
|
|
215
222
|
const content = (_jsxs("div", { ref: root, className: cn('flex flex-col gap-4', width === 'content' && CONTENT_WIDTH_CLASS), "data-width": width, style: width === 'content' && !isSuccessView && lastWidth !== null
|
|
216
223
|
? { '--data-table-last-width': `${lastWidth}px` }
|
|
217
224
|
: undefined, 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,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type PlainDate } from '../../lib/plain-date.js';
|
|
2
|
+
export type DateFieldProps = {
|
|
3
|
+
/** The trigger button's id, for the field's `<FieldLabel htmlFor>`. */
|
|
4
|
+
id?: string;
|
|
5
|
+
/** `'YYYY-MM-DD'`, or `''` for not set. */
|
|
6
|
+
value: PlainDate;
|
|
7
|
+
/** Called with the picked day as `'YYYY-MM-DD'`, or `''` when cleared. */
|
|
8
|
+
onChange: (value: PlainDate) => void;
|
|
9
|
+
/** What the button says with no day set. Defaults to "Pick a date". */
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
/** Offer Clear in the calendar, for an optional field. */
|
|
12
|
+
clearable?: boolean;
|
|
13
|
+
/** The value was refused: `aria-invalid` on the button. */
|
|
14
|
+
invalid?: boolean;
|
|
15
|
+
/** Saving: the button shows the day but won't open. */
|
|
16
|
+
readOnly?: boolean;
|
|
17
|
+
/** Open on mount: editing in place, where the calendar opens with the field. */
|
|
18
|
+
defaultOpen?: boolean;
|
|
19
|
+
/** Passed to the Popover; `false` when editing in place, as for a Select. */
|
|
20
|
+
modal?: boolean;
|
|
21
|
+
/** Told when the calendar opens or closes; editing in place gives up on a close with nothing picked. */
|
|
22
|
+
onOpenChange?: (open: boolean) => void;
|
|
23
|
+
/** How the day is shown on the button. Defaults to `{ dateStyle: 'medium' }`. */
|
|
24
|
+
format?: Intl.DateTimeFormatOptions;
|
|
25
|
+
locale?: string;
|
|
26
|
+
className?: string;
|
|
27
|
+
'aria-label'?: string;
|
|
28
|
+
'aria-labelledby'?: string;
|
|
29
|
+
'aria-describedby'?: string;
|
|
30
|
+
};
|
|
31
|
+
export declare function DateField({ id, value, onChange, placeholder, clearable, invalid, readOnly, defaultOpen, modal, onOpenChange, format, locale, className, ...aria }: DateFieldProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A plain date, no time: when something was finished, a due day. The value
|
|
3
|
+
// is the wire's own `'YYYY-MM-DD'` (OpenAPI `format: date`), or `''` for
|
|
4
|
+
// not set, and it's read as a calendar day, never an instant: nothing here
|
|
5
|
+
// goes through the browser's time zone, so Sep 2 is Sep 2 everywhere.
|
|
6
|
+
//
|
|
7
|
+
// A button showing the day, opening the package's Calendar in a Popover.
|
|
8
|
+
// One control for the form (a react-hook-form `Controller`) and for
|
|
9
|
+
// editing in place (`editInPlace({ kind: 'choice' })`: picking a day saves,
|
|
10
|
+
// and the calendar opens with the field). `clearable` adds Clear for an
|
|
11
|
+
// optional field.
|
|
12
|
+
import { useRef, useState } from 'react';
|
|
13
|
+
import { CalendarIcon } from 'lucide-react';
|
|
14
|
+
import { cn } from 'cn';
|
|
15
|
+
import { Button } from '../ui/button.js';
|
|
16
|
+
import { Calendar } from '../ui/calendar.js';
|
|
17
|
+
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover.js';
|
|
18
|
+
import { formatPlainDate, localToPlainDate, plainDateToLocal } from '../../lib/plain-date.js';
|
|
19
|
+
export function DateField({ id, value, onChange, placeholder = 'Pick a date', clearable = false, invalid = false, readOnly = false, defaultOpen = false, modal, onOpenChange, format, locale, className, ...aria }) {
|
|
20
|
+
const [open, setOpen] = useState(defaultOpen && !readOnly);
|
|
21
|
+
const popup = useRef(null);
|
|
22
|
+
const selected = plainDateToLocal(value);
|
|
23
|
+
const shown = formatPlainDate(value, format, locale);
|
|
24
|
+
const changeOpen = (next) => {
|
|
25
|
+
if (next && readOnly)
|
|
26
|
+
return;
|
|
27
|
+
setOpen(next);
|
|
28
|
+
onOpenChange?.(next);
|
|
29
|
+
};
|
|
30
|
+
const pick = (next) => {
|
|
31
|
+
onChange(next);
|
|
32
|
+
changeOpen(false);
|
|
33
|
+
};
|
|
34
|
+
return (_jsxs(Popover, { open: open, onOpenChange: changeOpen, modal: modal, children: [_jsxs(PopoverTrigger, { render: _jsx(Button, { id: id, type: "button", variant: "outline", "aria-invalid": invalid || undefined, "aria-readonly": readOnly || undefined, className: cn('w-full justify-start font-normal', !shown && 'text-muted-foreground', className), ...aria }), children: [_jsx(CalendarIcon, { "aria-hidden": "true", className: "size-4" }), shown || placeholder] }), _jsxs(PopoverContent, { ref: popup, className: "w-auto p-0", "data-slot": "date-field-calendar",
|
|
35
|
+
// The day the keyboard starts from (the chosen one, else today):
|
|
36
|
+
// the one day react-day-picker makes tabbable. The popup would
|
|
37
|
+
// otherwise take focus itself, past the Calendar's autoFocus.
|
|
38
|
+
initialFocus: () => popup.current?.querySelector('button[data-day][tabindex="0"]') ?? true, children: [_jsx(Calendar, { mode: "single",
|
|
39
|
+
// Picking the chosen day again keeps it (and closes), never unsets it: Clear does that.
|
|
40
|
+
required: true, selected: selected, defaultMonth: selected, onSelect: (date) => pick(localToPlainDate(date)) }), clearable && value && (_jsx("div", { className: "border-t border-border p-2", children: _jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "w-full", onClick: () => pick(''), children: "Clear" }) }))] })] }));
|
|
41
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type ProgressRingProps = {
|
|
2
|
+
/** How many are done. Clamped to 0…total. */
|
|
3
|
+
done: number;
|
|
4
|
+
/** How many there are. Zero of zero reads as not started. */
|
|
5
|
+
total: number;
|
|
6
|
+
/** What a screen reader hears. Defaults to "4 of 6 done", or "Not started" for zero of zero. */
|
|
7
|
+
label?: string;
|
|
8
|
+
/** Hide the count beside the ring (the ring's name still says it). */
|
|
9
|
+
hideCount?: boolean;
|
|
10
|
+
className?: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function ProgressRing({ done, total, label, hideCount, className }: ProgressRingProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Done out of total (cell pattern 13's compact form): a small ring that
|
|
3
|
+
// fills clockwise from twelve o'clock, with the count beside it ("4/6").
|
|
4
|
+
// Narrow enough for a table column, readable on a view: a checklist, how
|
|
5
|
+
// far through an ordered list someone is, the steps of a process.
|
|
6
|
+
//
|
|
7
|
+
// The arc is the ink (currentColor: `foreground` while under way,
|
|
8
|
+
// `success-text` once complete), so it clears 3:1 on the page in both
|
|
9
|
+
// themes; the track is `border`, decoration only, the same ring empty.
|
|
10
|
+
// The count carries the value exactly, the ring roughly, and the whole is
|
|
11
|
+
// one image to a screen reader, named in words.
|
|
12
|
+
import { cn } from 'cn';
|
|
13
|
+
// 16×16, the stage circle's grid: a thick ring of radius 6 so the arc
|
|
14
|
+
// reads at 16px.
|
|
15
|
+
const R = 6;
|
|
16
|
+
const CIRCUMFERENCE = 2 * Math.PI * R;
|
|
17
|
+
const STROKE = 2.5;
|
|
18
|
+
export function ProgressRing({ done, total, label, hideCount = false, className }) {
|
|
19
|
+
const safeTotal = Math.max(0, total);
|
|
20
|
+
const safeDone = Math.min(Math.max(0, done), safeTotal);
|
|
21
|
+
const complete = safeTotal > 0 && safeDone === safeTotal;
|
|
22
|
+
const fraction = safeTotal === 0 ? 0 : safeDone / safeTotal;
|
|
23
|
+
const name = label ?? (safeTotal === 0 ? 'Not started' : `${safeDone} of ${safeTotal} done`);
|
|
24
|
+
return (_jsxs("span", { role: "img", "aria-label": name, "data-slot": "progress-ring", "data-complete": complete || undefined, className: cn('inline-flex shrink-0 items-center gap-1.5 tabular-nums', complete ? 'text-success-text' : 'text-foreground', className), children: [_jsxs("svg", { viewBox: "0 0 16 16", "aria-hidden": "true", className: "size-4 shrink-0 -rotate-90", children: [_jsx("circle", { cx: "8", cy: "8", r: R, strokeWidth: STROKE, className: "fill-none stroke-border" }), fraction > 0 && (_jsx("circle", { cx: "8", cy: "8", r: R, strokeWidth: STROKE, strokeDasharray: `${fraction * CIRCUMFERENCE} ${CIRCUMFERENCE}`,
|
|
25
|
+
// A full ring with a round cap would show a seam; butt it then.
|
|
26
|
+
strokeLinecap: complete ? 'butt' : 'round', className: "fill-none stroke-current" }))] }), !hideCount && safeTotal > 0 && (_jsxs("span", { "aria-hidden": "true", className: "type-caption", children: [safeDone, "/", safeTotal] }))] }));
|
|
27
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type RatingStarValue = 'full' | 'half' | 'empty';
|
|
2
|
+
export type RatingStarProps = {
|
|
3
|
+
value: RatingStarValue;
|
|
4
|
+
/** The value's word, shown beside the star ("Partly"). */
|
|
5
|
+
word?: string;
|
|
6
|
+
/** What a screen reader hears. Defaults to the word, else "Full star", "Half star" or "Empty star". */
|
|
7
|
+
label?: string;
|
|
8
|
+
className?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function RatingStar({ value, word, label, className }: RatingStarProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A three-value enum as one star (cell pattern 9's variant): yes, partly,
|
|
3
|
+
// no as a full, half or empty star, with the word beside it. The shape
|
|
4
|
+
// carries the value, so it survives greyscale. A half star alone reads as
|
|
5
|
+
// broken, so it's drawn as the left half filled over the whole outline.
|
|
6
|
+
//
|
|
7
|
+
// The star is the ink (currentColor, `foreground` by default; pass a
|
|
8
|
+
// `*-text` tone in `className` to tone it), clearing 3:1 on the page in
|
|
9
|
+
// both themes. One image to a screen reader, named in words.
|
|
10
|
+
import { useId } from 'react';
|
|
11
|
+
import { cn } from 'cn';
|
|
12
|
+
// Lucide's star (24×24), so it matches the icons around it.
|
|
13
|
+
const STAR = 'M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z';
|
|
14
|
+
const DEFAULT_NAME = {
|
|
15
|
+
full: 'Full star',
|
|
16
|
+
half: 'Half star',
|
|
17
|
+
empty: 'Empty star',
|
|
18
|
+
};
|
|
19
|
+
export function RatingStar({ value, word, label, className }) {
|
|
20
|
+
const clipId = useId();
|
|
21
|
+
return (_jsxs("span", { role: "img", "aria-label": label ?? word ?? DEFAULT_NAME[value], "data-slot": "rating-star", "data-value": value, className: cn('inline-flex shrink-0 items-center gap-1.5 text-foreground', className), children: [_jsxs("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", className: "size-4 shrink-0", children: [value === 'half' && (_jsx("defs", { children: _jsx("clipPath", { id: clipId, children: _jsx("rect", { x: "0", y: "0", width: "12", height: "24" }) }) })), value !== 'empty' && (_jsx("path", { d: STAR, className: "fill-current", clipPath: value === 'half' ? `url(#${clipId})` : undefined })), _jsx("path", { d: STAR, className: "fill-none stroke-current", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" })] }), word && _jsx("span", { "aria-hidden": "true", children: word })] }));
|
|
22
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type StepBarProps = {
|
|
2
|
+
/** How many steps are filled, 0…steps. */
|
|
3
|
+
value: number;
|
|
4
|
+
/** How many steps the scale has. Defaults to 5. */
|
|
5
|
+
steps?: number;
|
|
6
|
+
/** The band's word, shown beside the bar ("High"). */
|
|
7
|
+
word?: string;
|
|
8
|
+
/**
|
|
9
|
+
* What a screen reader hears. Defaults to the word and the count
|
|
10
|
+
* ("High, 4 of 5"); pass the field's name in front when the cell
|
|
11
|
+
* doesn't give it ("Quality: High, 4 of 5").
|
|
12
|
+
*/
|
|
13
|
+
label?: string;
|
|
14
|
+
className?: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function StepBar({ value, steps, word, label, className }: StepBarProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// An ordered enum of a few bands, best to worst (cell pattern 9's
|
|
3
|
+
// variant): a short bar of steps, the first `value` filled, with the
|
|
4
|
+
// band's word beside it. Narrower than five stars, and reads as a scale,
|
|
5
|
+
// not a rating. "Quality: High" as ■■■■□ High.
|
|
6
|
+
//
|
|
7
|
+
// The filled steps are the ink (currentColor, `foreground`), clearing 3:1
|
|
8
|
+
// on the page in both themes; the empty steps are the track (`border`),
|
|
9
|
+
// deliberately faint and carrying nothing on their own: the count of
|
|
10
|
+
// filled steps and the word carry the value. One image to a screen
|
|
11
|
+
// reader, named with both ("Quality: High, 4 of 5").
|
|
12
|
+
import { cn } from 'cn';
|
|
13
|
+
// Each step 4×8 with a 1.5 gap, in a 16-high box, so the bar sits on the
|
|
14
|
+
// text's line like the other glyphs.
|
|
15
|
+
const STEP_WIDTH = 4;
|
|
16
|
+
const STEP_GAP = 1.5;
|
|
17
|
+
const STEP_HEIGHT = 8;
|
|
18
|
+
export function StepBar({ value, steps = 5, word, label, className }) {
|
|
19
|
+
const filled = Math.min(Math.max(0, Math.round(value)), steps);
|
|
20
|
+
const width = steps * STEP_WIDTH + (steps - 1) * STEP_GAP;
|
|
21
|
+
const name = label ?? `${word ? `${word}, ` : ''}${filled} of ${steps}`;
|
|
22
|
+
return (_jsxs("span", { role: "img", "aria-label": name, "data-slot": "step-bar", "data-value": filled, className: cn('inline-flex shrink-0 items-center gap-1.5 text-foreground', className), children: [_jsx("svg", { viewBox: `0 0 ${width} 16`, "aria-hidden": "true", className: "h-4 shrink-0", style: { width: `${width / 16}rem` }, children: Array.from({ length: steps }, (_, step) => (_jsx("rect", { x: step * (STEP_WIDTH + STEP_GAP), y: (16 - STEP_HEIGHT) / 2, width: STEP_WIDTH, height: STEP_HEIGHT, rx: "1", className: step < filled ? 'fill-current' : 'fill-border' }, step))) }), word && (_jsx("span", { "aria-hidden": "true", className: "text-muted-foreground", children: word }))] }));
|
|
23
|
+
}
|
|
@@ -68,7 +68,15 @@ function CalendarDayButton({ className, day, modifiers, locale, ...props }) {
|
|
|
68
68
|
if (modifiers.focused)
|
|
69
69
|
ref.current?.focus();
|
|
70
70
|
}, [modifiers.focused]);
|
|
71
|
-
return (_jsx(Button
|
|
71
|
+
return (_jsx(Button
|
|
72
|
+
// Patched (ui-foundation, #157): upstream makes this ref but never
|
|
73
|
+
// passes it, so the effect above focused nothing and the arrow keys
|
|
74
|
+
// moved react-day-picker's focused day without moving focus.
|
|
75
|
+
, {
|
|
76
|
+
// Patched (ui-foundation, #157): upstream makes this ref but never
|
|
77
|
+
// passes it, so the effect above focused nothing and the arrow keys
|
|
78
|
+
// moved react-day-picker's focused day without moving focus.
|
|
79
|
+
ref: ref, variant: "ghost", size: "icon", "data-day": day.date.toLocaleDateString(locale?.code), "data-selected-single": modifiers.selected &&
|
|
72
80
|
!modifiers.range_start &&
|
|
73
81
|
!modifiers.range_end &&
|
|
74
82
|
!modifiers.range_middle, "data-range-start": modifiers.range_start, "data-range-end": modifiers.range_end, "data-range-middle": modifiers.range_middle, className: cn("relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70", defaultClassNames.day, className), ...props }));
|
package/dist/index.d.ts
CHANGED
|
@@ -24,6 +24,11 @@ export { SecondaryFilters, type ActiveFilter, type SecondaryFiltersProps } from
|
|
|
24
24
|
export { StageCircle, type StageCircleProps, type StageCircleStage, type StageCircleTone, } from './components/app/stage-circle.js';
|
|
25
25
|
export { Notice, type NoticeProps, type NoticeTone } from './components/app/notice.js';
|
|
26
26
|
export { SourceCredit, type Source, type SourceCreditProps } from './components/app/source-credit.js';
|
|
27
|
+
export { ProgressRing, type ProgressRingProps } from './components/app/progress-ring.js';
|
|
28
|
+
export { StepBar, type StepBarProps } from './components/app/step-bar.js';
|
|
29
|
+
export { RatingStar, type RatingStarProps, type RatingStarValue } from './components/app/rating-star.js';
|
|
30
|
+
export { DateField, type DateFieldProps } from './components/app/date-field.js';
|
|
31
|
+
export { formatPlainDate, type PlainDate } from './lib/plain-date.js';
|
|
27
32
|
export { WidgetCard, WidgetList, WidgetListItem, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
|
|
28
33
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
29
34
|
export { LoginRoute, type LoginRouteProps } from './routes/login.js';
|
package/dist/index.js
CHANGED
|
@@ -29,6 +29,11 @@ export { SecondaryFilters } from './components/app/secondary-filters.js';
|
|
|
29
29
|
export { StageCircle, } from './components/app/stage-circle.js';
|
|
30
30
|
export { Notice } from './components/app/notice.js';
|
|
31
31
|
export { SourceCredit } from './components/app/source-credit.js';
|
|
32
|
+
export { ProgressRing } from './components/app/progress-ring.js';
|
|
33
|
+
export { StepBar } from './components/app/step-bar.js';
|
|
34
|
+
export { RatingStar } from './components/app/rating-star.js';
|
|
35
|
+
export { DateField } from './components/app/date-field.js';
|
|
36
|
+
export { formatPlainDate } from './lib/plain-date.js';
|
|
32
37
|
export { WidgetCard, WidgetList, WidgetListItem, } from './components/app/widget-card.js';
|
|
33
38
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
34
39
|
// Screens outside the app shell
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** A calendar day as the wire writes it, `'YYYY-MM-DD'`, or `''` for not set. */
|
|
2
|
+
export type PlainDate = string;
|
|
3
|
+
/**
|
|
4
|
+
* Shows a plain date ("Sep 2, 2026") without ever shifting it by the
|
|
5
|
+
* time zone: the day is formatted as UTC midnight, in UTC. `''` and
|
|
6
|
+
* anything that isn't a real `'YYYY-MM-DD'` day give `''`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function formatPlainDate(value: PlainDate, options?: Intl.DateTimeFormatOptions, locale?: string): string;
|
|
9
|
+
/** The day as a local Date at midnight (what a date picker works in), or undefined when not set. */
|
|
10
|
+
export declare function plainDateToLocal(value: PlainDate): Date | undefined;
|
|
11
|
+
/** A local Date's own calendar day, as `'YYYY-MM-DD'`. */
|
|
12
|
+
export declare function localToPlainDate(date: Date): PlainDate;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// A plain date: a calendar day with no time, as the wire writes it
|
|
2
|
+
// (OpenAPI `format: date`, `'YYYY-MM-DD'`), or `''` for not set. It's a
|
|
3
|
+
// day, never an instant, so nothing here goes through the browser's time
|
|
4
|
+
// zone: Sep 2 is Sep 2 everywhere. DateField edits one.
|
|
5
|
+
const PLAIN_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
6
|
+
function parts(value) {
|
|
7
|
+
const match = PLAIN_DATE.exec(value);
|
|
8
|
+
if (!match)
|
|
9
|
+
return null;
|
|
10
|
+
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
11
|
+
// Reject what Date would roll over (Feb 30 → Mar 2).
|
|
12
|
+
const check = new Date(Date.UTC(year, month - 1, day));
|
|
13
|
+
if (check.getUTCFullYear() !== year || check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day)
|
|
14
|
+
return null;
|
|
15
|
+
return [year, month, day];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Shows a plain date ("Sep 2, 2026") without ever shifting it by the
|
|
19
|
+
* time zone: the day is formatted as UTC midnight, in UTC. `''` and
|
|
20
|
+
* anything that isn't a real `'YYYY-MM-DD'` day give `''`.
|
|
21
|
+
*/
|
|
22
|
+
export function formatPlainDate(value, options = { dateStyle: 'medium' }, locale) {
|
|
23
|
+
const day = parts(value);
|
|
24
|
+
if (!day)
|
|
25
|
+
return '';
|
|
26
|
+
return new Intl.DateTimeFormat(locale, { ...options, timeZone: 'UTC' }).format(Date.UTC(day[0], day[1] - 1, day[2]));
|
|
27
|
+
}
|
|
28
|
+
/** The day as a local Date at midnight (what a date picker works in), or undefined when not set. */
|
|
29
|
+
export function plainDateToLocal(value) {
|
|
30
|
+
const day = parts(value);
|
|
31
|
+
return day ? new Date(day[0], day[1] - 1, day[2]) : undefined;
|
|
32
|
+
}
|
|
33
|
+
/** A local Date's own calendar day, as `'YYYY-MM-DD'`. */
|
|
34
|
+
export function localToPlainDate(date) {
|
|
35
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
36
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.12.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
|
@@ -229,6 +229,13 @@
|
|
|
229
229
|
matches the row without letting scrolled columns show through. Mixed
|
|
230
230
|
in sRGB, as the browser composites the translucent stripe. */
|
|
231
231
|
--table-stripe: color-mix(in srgb, var(--muted) 40%, var(--background));
|
|
232
|
+
/* A highlighted row (DataTable's highlightRow, #156): info's wash
|
|
233
|
+
pre-mixed onto the page, opaque so pinned cells can wear it. Muted
|
|
234
|
+
text measures 4.09:1 on it (below 4.5 at 12px; no lighter tint gets
|
|
235
|
+
there: 4.47 at 4%), so inside the row muted text takes a darker grey:
|
|
236
|
+
6.75:1. In dark the usual muted already clears it (6.61:1). */
|
|
237
|
+
--table-highlight: color-mix(in srgb, var(--info) 10%, var(--background));
|
|
238
|
+
--table-highlight-muted-foreground: var(--gray-600);
|
|
232
239
|
--border: var(--gray-200);
|
|
233
240
|
--input: var(--gray-200);
|
|
234
241
|
--ring: var(--gray-400);
|
|
@@ -358,6 +365,8 @@
|
|
|
358
365
|
--category-11: var(--category-11-400);
|
|
359
366
|
--category-12: var(--category-12-400);
|
|
360
367
|
--table-stripe: color-mix(in srgb, var(--muted) 40%, var(--background));
|
|
368
|
+
--table-highlight: color-mix(in srgb, var(--info) 20%, var(--background));
|
|
369
|
+
--table-highlight-muted-foreground: var(--gray-400); /* the usual muted: not var(--muted-foreground), which the row redefines from this */
|
|
361
370
|
--border: var(--white-a10);
|
|
362
371
|
--input: var(--white-a15);
|
|
363
372
|
--ring: var(--gray-500);
|