@tristan2828/ui-foundation 4.11.0 → 4.13.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.
@@ -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 `yes/no` copies In Stock — a `Switch`
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
@@ -934,15 +934,26 @@ row`), every editable cell is an editable value, exactly as on a view.
934
934
  one is open, "Saving…" and a refusal in the cell. There's no pencil
935
935
  column and no edit route.
936
936
 
937
- - **A cell** is `<EditableValue label="<Column> of <name>" layout="inline"
938
- edit={editInPlace({ ... })}>`, its shown value the cell as it always
939
- was. The label names the column and the row, so its pencil reads "Edit
940
- Colour of Work" and its control "Colour of Work". The kinds are the
941
- same: `text` (Enter saves), `choice` (the list opens with it, non-modal,
942
- a pick saves), `multi`.
937
+ - **A cell is one of the package's three row-edit cells**, or an
938
+ `<EditableValue label="<Column> of <name>" layout="inline"
939
+ edit={editInPlace({ ... })}>` for anything else. The label names the
940
+ column and the row, so its pencil reads "Edit Colour of Work" and its
941
+ control "Colour of Work".
942
+ - **`NameCell`**: one line; Enter saves, the form's rule (`schema`)
943
+ runs first, a taken name's 422 shows in the cell. `children` is how
944
+ the name shows at rest (an icon beside it).
945
+ - **`ChoiceCell<T>`** (`options`, `render`): the list opens with the
946
+ cell, non-modal; a pick saves, closing it with no pick gives up.
947
+ `render` draws a value at rest, on the trigger and in the list.
948
+ - **`LinkCell`** (`linkText`, `emptyLabel`, `save(link | null)`): a
949
+ link opening in a new tab (said to screen readers), or the not-set
950
+ label; edited as one line. A bare `example.com` gains `https://`,
951
+ another scheme (`javascript:`) is refused in the cell, and a blank
952
+ box saves `null`. Clicking the link follows it; the pencil edits.
943
953
  - **A cell is a component** (`<Entity><Field>Cell`) that calls
944
954
  `useEdit<Entity>Field(row.id)` for its save, the view's single-field
945
- save. A hook can't be called inside a column definition.
955
+ save, and renders the package's cell with it. A hook can't be called
956
+ inside a column definition.
946
957
  - **Stable columns and row ids.** Make the column definitions once
947
958
  (at module level, or `useMemo`) and give
948
959
  `DataTable` a `getRowId`: new definitions every render, or rows keyed
@@ -958,24 +969,29 @@ column and no edit route.
958
969
  and its delete together on a wide screen.
959
970
 
960
971
  ```tsx
961
- // A cell is its own component, so it can call the record's save hook.
972
+ import { ChoiceCell, InlineCreate, LinkCell, NameCell } from '@tristan2828/ui-foundation'
973
+
974
+ // Each cell is its own component, so it can call the record's save hook.
962
975
  function CategoryColourCell({ category }: { category: Category }) {
963
976
  const editField = useEditCategoryField(category.id) // optimistic: false, toastOnError: false
964
977
  return (
965
- <EditableValue label={`Colour of ${category.name}`} layout="inline"
966
- edit={editInPlace({
967
- kind: 'choice',
968
- value: category.colour,
969
- schema: categoryFormSchema.shape.colour,
970
- save: (colour) => editField.mutateAsync({ colour }),
971
- control: (props) => (
972
- <CategoryColourSelect id={props.id} value={props.value} onChange={(colour) => props.commit(colour)}
973
- readOnly={props.disabled} defaultOpen modal={false} onOpenChange={(open) => !open && props.cancel()}
974
- aria-label={props.label} aria-describedby={props.describedBy} />
975
- ),
976
- })}>
977
- <ColourSwatch colour={category.colour} />
978
- </EditableValue>
978
+ <ChoiceCell label={`Colour of ${category.name}`} value={category.colour} options={CATEGORY_COLOURS}
979
+ render={(colour) => <ColourSwatch colour={colour} />}
980
+ save={(colour) => editField.mutateAsync({ colour })} />
981
+ )
982
+ }
983
+ function CategoryNameCell({ category }: { category: Category }) {
984
+ const editField = useEditCategoryField(category.id)
985
+ return (
986
+ <NameCell label={`Name of ${category.name}`} value={category.name} schema={categoryFormSchema.shape.name}
987
+ save={(name) => editField.mutateAsync({ name })} />
988
+ )
989
+ }
990
+ function CategoryWebsiteCell({ category }: { category: Category }) {
991
+ const editField = useEditCategoryField(category.id)
992
+ return (
993
+ <LinkCell label={`Website of ${category.name}`} value={category.website} emptyLabel="No website"
994
+ save={(website) => editField.mutateAsync({ website })} />
979
995
  )
980
996
  }
981
997
 
@@ -983,6 +999,7 @@ function CategoryColourCell({ category }: { category: Category }) {
983
999
  const CATEGORY_COLUMNS: LegacyColumnDef<Category, unknown>[] = [
984
1000
  { id: 'name', header: 'Name', cell: ({ row }) => <CategoryNameCell category={row.original} /> },
985
1001
  { id: 'colour', header: 'Colour', cell: ({ row }) => <CategoryColourCell category={row.original} /> },
1002
+ { id: 'website', header: 'Website', cell: ({ row }) => <CategoryWebsiteCell category={row.original} /> },
986
1003
  { id: 'actions', header: () => <span className="sr-only">Actions</span>, cell: ({ row }) => <DeleteCategoryAction category={row.original} /> },
987
1004
  ]
988
1005
 
@@ -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), `date` (from date-time — no time part).
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
@@ -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,52 @@
1
+ import type { ReactNode } from 'react';
2
+ import { type EditInPlaceOptions } from './edit-in-place.js';
3
+ type Schema<TValue> = EditInPlaceOptions<unknown, TValue>['schema'];
4
+ export type NameCellProps = {
5
+ /** The cell's name, column and row: "Name of Home". */
6
+ label: string;
7
+ /** The saved name. */
8
+ value: string;
9
+ /** Saves the new name (already checked by `schema`). Rejects with the AppError. */
10
+ save: (name: string) => Promise<unknown>;
11
+ /** The form's rule for the name (`schema.shape.name`): checked before anything is sent. */
12
+ schema?: Schema<string>;
13
+ /** How the name shows at rest (an icon beside it). Defaults to the name. */
14
+ children?: ReactNode;
15
+ /** The input's width class. Defaults to `w-48`. */
16
+ inputClassName?: string;
17
+ };
18
+ /** A name in one line: Enter saves, the form's rule runs first, a taken name's 422 shows in the cell. */
19
+ export declare function NameCell({ label, value, save, schema, children, inputClassName }: NameCellProps): import("react").JSX.Element;
20
+ export type ChoiceCellProps<T extends string> = {
21
+ /** The cell's name, column and row: "Colour of Home". */
22
+ label: string;
23
+ /** The saved value. */
24
+ value: T;
25
+ /** Every value, in the order the list shows them. */
26
+ options: readonly T[];
27
+ /** How a value shows: at rest, on the open trigger and in the list (a swatch and its word). */
28
+ render: (value: T) => ReactNode;
29
+ /** Saves the picked value. Rejects with the AppError. */
30
+ save: (value: T) => Promise<unknown>;
31
+ /** The trigger's width class. Defaults to `w-40`. */
32
+ triggerClassName?: string;
33
+ };
34
+ /** A single choice: its list opens with the cell, a pick saves, closing it with no pick gives up. */
35
+ export declare function ChoiceCell<T extends string>({ label, value, options, render, save, triggerClassName, }: ChoiceCellProps<T>): import("react").JSX.Element;
36
+ export type LinkCellProps = {
37
+ /** The cell's name, column and row: "Website of Home". */
38
+ label: string;
39
+ /** The saved address, or null when there's none. */
40
+ value: string | null;
41
+ /** Saves the address, or null to clear it (the box left blank). Rejects with the AppError. */
42
+ save: (link: string | null) => Promise<unknown>;
43
+ /** The link's text at rest. Defaults to the address without its scheme. */
44
+ linkText?: string | ((href: string) => ReactNode);
45
+ /** What the cell says with no address. Defaults to "Not set". */
46
+ emptyLabel?: string;
47
+ /** The input's width class. Defaults to `w-64`. */
48
+ inputClassName?: string;
49
+ };
50
+ /** An optional link: shown as a link out (or the not-set label), edited as one line; blank clears it. */
51
+ export declare function LinkCell({ label, value, save, linkText, emptyLabel, inputClassName }: LinkCellProps): import("react").JSX.Element;
52
+ export {};
@@ -0,0 +1,63 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ExternalLinkIcon } from 'lucide-react';
3
+ import { Input } from '../ui/input.js';
4
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select.js';
5
+ import { linkHref } from '../../lib/link-href.js';
6
+ import { editInPlace } from './edit-in-place.js';
7
+ import { EditableValue } from './editable-value.js';
8
+ /** A name in one line: Enter saves, the form's rule runs first, a taken name's 422 shows in the cell. */
9
+ export function NameCell({ label, value, save, schema, children, inputClassName = 'w-48' }) {
10
+ return (_jsx(EditableValue, { label: label, layout: "inline", edit: editInPlace({
11
+ kind: 'text',
12
+ value,
13
+ schema,
14
+ save,
15
+ control: (props) => (_jsx(Input, { id: props.id, className: inputClassName, value: props.value, readOnly: props.disabled, "aria-invalid": props.invalid || undefined, "aria-label": props.label, "aria-describedby": props.describedBy, onChange: (event) => props.onChange(event.target.value) })),
16
+ }), children: children ?? value }));
17
+ }
18
+ /** A single choice: its list opens with the cell, a pick saves, closing it with no pick gives up. */
19
+ export function ChoiceCell({ label, value, options, render, save, triggerClassName = 'w-40', }) {
20
+ return (_jsx(EditableValue, { label: label, layout: "inline", edit: editInPlace({
21
+ kind: 'choice',
22
+ value,
23
+ save,
24
+ control: (props) => (_jsxs(Select, { value: props.value, onValueChange: (next) => {
25
+ if (next)
26
+ props.commit(next);
27
+ }, disabled: props.disabled, defaultOpen: true, modal: false, onOpenChange: (open) => {
28
+ if (!open)
29
+ props.cancel();
30
+ }, children: [_jsx(SelectTrigger, { id: props.id, className: triggerClassName, "aria-invalid": props.invalid || undefined, "aria-label": props.label, "aria-describedby": props.describedBy, children: _jsx(SelectValue, { children: (shown) => render(shown) }) }), _jsx(SelectContent, { children: options.map((option) => (_jsx(SelectItem, { value: option, children: render(option) }, option))) })] })),
31
+ }), children: render(value) }));
32
+ }
33
+ // A web or email address (`example.com` gains https://), blank for none.
34
+ // Another scheme (`javascript:`) is refused before anything is sent.
35
+ const LINK_RULE = {
36
+ safeParse: (value) => {
37
+ const href = linkHref(String(value ?? ''));
38
+ if (href === '')
39
+ return { success: true, data: null };
40
+ if (href === null) {
41
+ return { success: false, error: { issues: [{ message: 'Use a web or email address' }] } };
42
+ }
43
+ return { success: true, data: href };
44
+ },
45
+ };
46
+ function shownLinkText(href, linkText) {
47
+ if (typeof linkText === 'function')
48
+ return linkText(href);
49
+ return linkText ?? href.replace(/^(https?:\/\/|mailto:)/i, '').replace(/\/$/, '');
50
+ }
51
+ /** An optional link: shown as a link out (or the not-set label), edited as one line; blank clears it. */
52
+ export function LinkCell({ label, value, save, linkText, emptyLabel = 'Not set', inputClassName = 'w-64' }) {
53
+ // Only an address the editor would have saved is shown as a link: a
54
+ // stored `javascript:` reads as text, never as something to click.
55
+ const href = value ? linkHref(value) : null;
56
+ return (_jsx(EditableValue, { label: label, layout: "inline", edit: editInPlace({
57
+ kind: 'text',
58
+ value: value ?? '',
59
+ schema: LINK_RULE,
60
+ save,
61
+ control: (props) => (_jsx(Input, { id: props.id, type: "url", inputMode: "url", className: inputClassName, value: props.value, placeholder: "https://", readOnly: props.disabled, "aria-invalid": props.invalid || undefined, "aria-label": props.label, "aria-describedby": props.describedBy, onChange: (event) => props.onChange(event.target.value) })),
62
+ }), children: !value ? (_jsx("span", { className: "text-muted-foreground", children: emptyLabel })) : href ? (_jsxs("a", { href: href, target: "_blank", rel: "noreferrer", className: "rounded-sm text-link underline underline-offset-4 outline-none focus-visible:ring-3 focus-visible:ring-ring/50", children: [shownLinkText(href, linkText), _jsx(ExternalLinkIcon, { "aria-hidden": "true", className: "ml-0.5 inline size-3 align-baseline" }), _jsx("span", { className: "sr-only", children: " (opens in a new tab)" })] })) : (value) }));
63
+ }
@@ -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, { variant: "ghost", size: "icon", "data-day": day.date.toLocaleDateString(locale?.code), "data-selected-single": modifiers.selected &&
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
@@ -27,6 +27,9 @@ export { SourceCredit, type Source, type SourceCreditProps } from './components/
27
27
  export { ProgressRing, type ProgressRingProps } from './components/app/progress-ring.js';
28
28
  export { StepBar, type StepBarProps } from './components/app/step-bar.js';
29
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';
32
+ export { ChoiceCell, LinkCell, NameCell, type ChoiceCellProps, type LinkCellProps, type NameCellProps, } from './components/app/row-edit-cells.js';
30
33
  export { WidgetCard, WidgetList, WidgetListItem, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
31
34
  export { ThemeProvider } from './components/theme-provider.js';
32
35
  export { LoginRoute, type LoginRouteProps } from './routes/login.js';
package/dist/index.js CHANGED
@@ -32,6 +32,9 @@ export { SourceCredit } from './components/app/source-credit.js';
32
32
  export { ProgressRing } from './components/app/progress-ring.js';
33
33
  export { StepBar } from './components/app/step-bar.js';
34
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';
37
+ export { ChoiceCell, LinkCell, NameCell, } from './components/app/row-edit-cells.js';
35
38
  export { WidgetCard, WidgetList, WidgetListItem, } from './components/app/widget-card.js';
36
39
  export { ThemeProvider } from './components/theme-provider.js';
37
40
  // 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.11.0",
3
+ "version": "4.13.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": {