@tristan2828/ui-foundation 4.11.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/entity-plan-template.md +4 -1
- package/dist/components/app/date-field.d.ts +31 -0
- package/dist/components/app/date-field.js +41 -0
- package/dist/components/ui/calendar.js +9 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/lib/plain-date.d.ts +12 -0
- package/dist/lib/plain-date.js +37 -0
- package/package.json +1 -1
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
+
}
|
|
@@ -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
|
@@ -27,6 +27,8 @@ 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';
|
|
30
32
|
export { WidgetCard, WidgetList, WidgetListItem, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
|
|
31
33
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
32
34
|
export { LoginRoute, type LoginRouteProps } from './routes/login.js';
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,8 @@ 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';
|
|
35
37
|
export { WidgetCard, WidgetList, WidgetListItem, } from './components/app/widget-card.js';
|
|
36
38
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
37
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": {
|