@vvedantb/ui 0.13.0 → 0.14.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/README.md CHANGED
@@ -4,7 +4,7 @@ Shared React 19 web UI. Source-exported: no build step.
4
4
 
5
5
  | Import | What it is |
6
6
  | --- | --- |
7
- | `@vvedantb/ui` | The only JavaScript entry. Primitives (Radix-based), AI elements, audio (`playChime`, `useReplyChime`, `playTone`), `toast` and `SonnerToaster`, the composer (`InputArea`, deprecated 0.3 `Composer`), shell (`SidebarHeader`, `PillTabs`, `SessionsHoverCard`, and the app shell: `AppShell`, `AppSidebar`, `SidebarRail`, `RailTile`, `SidebarPanel`, `SidebarAccountMenu`, `SignOutDialog` and related helpers), app helpers (confirm skip, update watcher, settings chrome, `useStableAuth`, and from 0.8.0 `createThemeProvider`, `createPageTitleContext`, `PageMotionProvider` and `formatCompactRelativeTime`), marketing pieces from 0.9.0 (`createLandingKit` and the `Landing*` parts, `SiteFooter`, `PricingModal`), animated nav icons from 0.11.0 (`MemoryIcon`, `BoardIcon`, ... and `NavIconBase`; `SettingsIcon` `variant` from 0.12.0), artifact viewers from 0.13.0 (`ArtifactFrame`, `ArtifactList`, `ArtifactViewer`, ...), `cn`, and the native tokens `tokens` and `Tokens`. See `app/README.md` in the repository for the app helpers. |
7
+ | `@vvedantb/ui` | The only JavaScript entry. Primitives (Radix-based), AI elements, audio (`playChime`, `useReplyChime`, `playTone`), `toast` and `SonnerToaster`, the composer (`InputArea`, deprecated 0.3 `Composer`), shell (`SidebarHeader`, `PillTabs`, `SessionsHoverCard`, and the app shell: `AppShell`, `AppSidebar`, `SidebarRail`, `RailTile`, `SidebarPanel`, `SidebarAccountMenu`, `SignOutDialog` and related helpers), app helpers (confirm skip, update watcher, settings chrome, `useStableAuth`, and from 0.8.0 `createThemeProvider`, `createPageTitleContext`, `PageMotionProvider` and `formatCompactRelativeTime`), marketing pieces from 0.9.0 (`createLandingKit` and the `Landing*` parts, `SiteFooter`, `PricingModal`), animated nav icons from 0.11.0 (`MemoryIcon`, `BoardIcon`, ... and `NavIconBase`; `SettingsIcon` `variant` from 0.12.0), artifact viewers from 0.13.0 (`ArtifactFrame`, `ArtifactList`, `ArtifactViewer`, ...), date pickers from 0.14.0 (`Calendar`, `DatePicker`, `DateTimePicker`, `MonthPicker`), `cn`, and the native tokens `tokens` and `Tokens`. See `app/README.md` in the repository for the app helpers. |
8
8
  | `@vvedantb/ui/tokens` | `tokens.css`: colour, radius, shadow, ease and font tokens for Tailwind v4. CSS. |
9
9
  | `@vvedantb/ui/tokens/tokens.css` | Same file as `@vvedantb/ui/tokens`. CSS. |
10
10
  | `@vvedantb/ui/tokens/css/*.css` | `index`, `base`, `dialog`, `modal`, `tabs`, `tabs-pills`, `utilities`, `scrollbar-thin`, `animations`, `nav-icons`, `compat-shadcn-rgb`. CSS. |
@@ -982,9 +982,26 @@ const store: ArtifactStateStore = {
982
982
  - `ArtifactListToolbar`: controlled `ToolbarSearchInput` (`query`, `onQueryChange`), a type filter menu (`type`, `onTypeChange`, `types`; `undefined` is "All types") and a grid or list toggle (`layout`, `onLayoutChange`). The filter and the toggle show only with their callbacks. `children` go at the end.
983
983
  - `ArtifactViewer`: the ai-elements `Artifact` container with a header (`headerStart`, type icon, title) and actions: versions menu (`versions`), Rename, Copy, Download (the format's extension and MIME type from `ARTIFACT_FORMAT_FILES`), Open in new tab (`href`), your `actions`, and Delete. Below it, `ArtifactRender` of `artifact.body`. Pass the body of the version on screen.
984
984
  - `ArtifactVersionsMenu`: a "vN" button listing `versions` newest first, with the current one marked. With `onViewVersion`, picking a version shows it, and "Restore vN" (`onRestoreVersion`) appears while an older version is on screen. Without it, picking an older version restores it.
985
- - Rename and delete use `ConfirmDialog` (`ArtifactRenameDialog`, `ArtifactDeleteDialog`, and `useArtifactEditDialogs` for custom surfaces). Rename sends the trimmed title, and does nothing when it is unchanged. Alt-click on Delete skips the dialog, as with `useRequestConfirm`. A callback that rejects keeps its dialog open and shows an error toast; copy and load failures toast too, so mount `SonnerToaster`.
985
+ - Rename and delete use `ConfirmDialog` (`ArtifactRenameDialog`, `ArtifactDeleteDialog`, and `useArtifactEditDialogs` for custom surfaces). Rename sends the trimmed title, and does nothing when it is unchanged. Alt-click on Delete skips the dialog, as with `useRequestConfirm`. The delete dialog says "This cannot be undone."; pass `deleteDescription` to `ArtifactViewer`, `ArtifactCard` or `ArtifactList` to change it (0.13.1), for example when your delete can be undone. A callback that rejects keeps its dialog open and shows an error toast; copy and load failures toast too, so mount `SonnerToaster`.
986
986
  - `ArtifactTypeIcon` (`type`, `size`, `stroke`) and `ARTIFACT_TYPE_LABELS` (`mindmap` is "Mind map"). Helpers `downloadArtifactBody(title, format, body)`, `copyArtifactBody(body)`, `openArtifactInNewTab(href)`. Row types: `ArtifactSummary` (any `ArtifactListRow` fits), `ArtifactDocument`, `ArtifactVersionSummary`.
987
987
 
988
+ ## Date pickers (0.14.0)
989
+
990
+ Controlled pickers that read and write the native input strings, as local calendar dates with no timezone logic. They need `react-day-picker` 9, a dependency of this package.
991
+
992
+ ```tsx
993
+ import { DatePicker, DateTimePicker, MonthPicker } from "@vvedantb/ui";
994
+
995
+ <DatePicker value={due} onChange={setDue} min="2026-01-01" clearable name="due" ariaLabel="Due date" />
996
+ <DateTimePicker value={startsAt} onChange={setStartsAt} defaultTime="09:00" name="startsAt" />
997
+ <MonthPicker value={month} onChange={setMonth} max="2026-12" />
998
+ ```
999
+
1000
+ - `DatePicker`: `value` "YYYY-MM-DD" or "". The trigger matches `TimePicker` and shows the date with `dateStyle: "medium"` (`locale`) or `placeholder` ("Pick a date"). `min` and `max` disable days; `clearable` adds a clear control (`onChange("")`); `name` adds a hidden input. `className` goes on the wrapper, `triggerClassName` merges onto the button (default `w-40`). A width on either sizes the field, and `w-full` on either fills the row; pill inputs can pass `triggerClassName="h-10 w-full rounded-full px-4 text-sm"`. `ariaLabel` names the trigger, followed by the shown text.
1001
+ - `DateTimePicker`: `value` "YYYY-MM-DDTHH:mm" or "". A date with no time uses `defaultTime` ("00:00"); a time with no date uses today. Only the date part of `min` and `max` applies. `triggerClassName` goes to both triggers, a width only to the date.
1002
+ - `MonthPicker`: `value` "YYYY-MM" or "", with year navigation and a month grid. `min` and `max` are "YYYY-MM".
1003
+ - `Calendar`: the styled `DayPicker` for custom layouts (every `DayPicker` prop). `parseDateValue` and `formatDateValue` convert between "YYYY-MM-DD" and a local `Date`.
1004
+
988
1005
  ## Toaster
989
1006
 
990
1007
  ```tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/ui",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "license": "MIT",
5
5
  "description": "Shared React 19 UI for web apps: one JavaScript entry (primitives, shell pieces, app helpers including the Convex and Clerk provider, VITE env parsing and ensure-user, artifact viewers over the @vvedantb/server artifacts core, native tokens), design-token CSS, shared Tailwind v4 CSS and a Tailwind preset.",
6
6
  "type": "module",
@@ -64,6 +64,9 @@
64
64
  "analyze": "node tokens/verify/analyze.mjs",
65
65
  "prepack": "node tokens/scripts/check-exports.mjs"
66
66
  },
67
+ "dependencies": {
68
+ "react-day-picker": "^9.14.0"
69
+ },
67
70
  "peerDependencies": {
68
71
  "@clerk/clerk-react": ">=5",
69
72
  "@radix-ui/react-accordion": "^1.2.12",
@@ -33,6 +33,8 @@ export type ArtifactCardActions = {
33
33
  onRename?: (artifact: ArtifactSummary, title: string) => void | Promise<void>;
34
34
  /** Called after the delete dialog, or straight away on an Alt-click. */
35
35
  onDelete?: (artifact: ArtifactSummary) => void | Promise<void>;
36
+ /** Text under the delete dialog's title, e.g. for a host that can undo. Defaults to "This cannot be undone." */
37
+ deleteDescription?: ReactNode;
36
38
  };
37
39
 
38
40
  export type ArtifactCardProps = ArtifactCardActions & {
@@ -69,12 +71,14 @@ export function ArtifactCard({
69
71
  getBody,
70
72
  onRename,
71
73
  onDelete,
74
+ deleteDescription,
72
75
  className,
73
76
  }: ArtifactCardProps) {
74
77
  const edit = useArtifactEditDialogs({
75
78
  title: artifact.title,
76
79
  onRename: onRename && ((title) => onRename(artifact, title)),
77
80
  onDelete: onDelete && (() => onDelete(artifact)),
81
+ deleteDescription,
78
82
  });
79
83
  const href = getHref?.(artifact);
80
84
 
@@ -51,16 +51,25 @@ export type ArtifactDeleteDialogProps = {
51
51
  title: string;
52
52
  onConfirm: () => void;
53
53
  pending?: boolean;
54
+ /** Text under the title. Defaults to "This cannot be undone." */
55
+ description?: ReactNode;
54
56
  };
55
57
 
56
58
  /** "Delete <title>?" confirmation. Cancel is focused on open. */
57
- export function ArtifactDeleteDialog({ open, onOpenChange, title, onConfirm, pending }: ArtifactDeleteDialogProps) {
59
+ export function ArtifactDeleteDialog({
60
+ open,
61
+ onOpenChange,
62
+ title,
63
+ onConfirm,
64
+ pending,
65
+ description = "This cannot be undone.",
66
+ }: ArtifactDeleteDialogProps) {
58
67
  return (
59
68
  <ConfirmDialog.Root alert={false} focusCancel pending={pending} open={open} onOpenChange={onOpenChange}>
60
69
  <ConfirmDialog.Content className="max-w-sm" role="alertdialog">
61
70
  <ConfirmDialog.Header>
62
71
  <ConfirmDialog.Title>Delete &quot;{title}&quot;?</ConfirmDialog.Title>
63
- <ConfirmDialog.Description>This cannot be undone.</ConfirmDialog.Description>
72
+ <ConfirmDialog.Description>{description}</ConfirmDialog.Description>
64
73
  </ConfirmDialog.Header>
65
74
  <ConfirmDialog.Footer>
66
75
  <ConfirmDialog.Cancel variant="secondary" />
@@ -78,13 +87,15 @@ export type ArtifactEditCallbacks = {
78
87
  onRename?: (title: string) => void | Promise<void>;
79
88
  /** Called after the delete dialog, or straight away on an Alt-click. */
80
89
  onDelete?: () => void | Promise<void>;
90
+ /** Text under the delete dialog's title, e.g. for a host that can undo. Defaults to "This cannot be undone." */
91
+ deleteDescription?: ReactNode;
81
92
  };
82
93
 
83
94
  /**
84
95
  * Rename and delete dialog state shared by the card and the viewer. `dialogs`
85
96
  * must be rendered; `requestDelete(event)` skips the dialog on an Alt-click.
86
97
  */
87
- export function useArtifactEditDialogs({ title, onRename, onDelete }: ArtifactEditCallbacks & { title: string }) {
98
+ export function useArtifactEditDialogs({ title, onRename, onDelete, deleteDescription }: ArtifactEditCallbacks & { title: string }) {
88
99
  const [renameOpen, setRenameOpen] = useState(false);
89
100
  const [renameValue, setRenameValue] = useState(title);
90
101
  const [deleteOpen, setDeleteOpen] = useState(false);
@@ -137,6 +148,7 @@ export function useArtifactEditDialogs({ title, onRename, onDelete }: ArtifactEd
137
148
  title={title}
138
149
  onConfirm={remove}
139
150
  pending={pending}
151
+ description={deleteDescription}
140
152
  />
141
153
  ) : null}
142
154
  </>
@@ -45,11 +45,12 @@ export function ArtifactViewer({
45
45
  href,
46
46
  onRename,
47
47
  onDelete,
48
+ deleteDescription,
48
49
  headerStart,
49
50
  actions,
50
51
  className,
51
52
  }: ArtifactViewerProps) {
52
- const edit = useArtifactEditDialogs({ title: artifact.title, onRename, onDelete });
53
+ const edit = useArtifactEditDialogs({ title: artifact.title, onRename, onDelete, deleteDescription });
53
54
  const extension = ARTIFACT_FORMAT_FILES[artifact.format].extension;
54
55
  const actionClass = "h-8 min-w-8";
55
56
 
package/src/index.ts CHANGED
@@ -55,6 +55,8 @@ export * from "./ui/tabs";
55
55
  export * from "./ui/textarea";
56
56
  export * from "./ui/toolbar-search-input";
57
57
  export * from "./ui/time-picker";
58
+ export * from "./ui/calendar";
59
+ export * from "./ui/date-picker";
58
60
  export * from "./ui/tooltip";
59
61
  export * from "./ui/kbd";
60
62
  export * from "./ui/sheet";
@@ -0,0 +1,18 @@
1
+ // input-styled trigger shared by TimePicker and the date pickers
2
+ export const pickerTriggerClass =
3
+ "inline-flex h-8 items-center justify-between gap-2 rounded-field border border-border bg-field-background px-2.5 text-xs font-medium tabular-nums transition-[border-color,box-shadow,background-color] duration-200 ease-smooth hover:bg-field-background/90 focus-visible:border-focus-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50";
4
+
5
+ // selected item in a picker popover (TimePicker columns, calendar days, months)
6
+ export const pickerSelectedClass = "bg-primary text-primary-foreground";
7
+
8
+ export const pickerItemHoverClass = "hover:bg-surface-tertiary/50";
9
+
10
+ export const pickerFocusClass =
11
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring";
12
+
13
+ // wrapper around a trigger and its siblings (clear control, second picker). it
14
+ // shrinks to the trigger, grows with a `w-*` className, and goes full width
15
+ // when a trigger inside is `w-full` (a percent width cannot resolve against a
16
+ // shrink-to-fit wrapper)
17
+ export const pickerFieldClass =
18
+ "relative inline-flex max-w-full align-middle has-[.w-full]:flex has-[.w-full]:w-full";
@@ -0,0 +1,161 @@
1
+ import * as React from "react";
2
+ import {
3
+ IconChevronDown,
4
+ IconChevronLeft,
5
+ IconChevronRight,
6
+ } from "@tabler/icons-react";
7
+ import {
8
+ type Chevron,
9
+ DayPicker,
10
+ type Root,
11
+ getDefaultClassNames,
12
+ } from "react-day-picker";
13
+ import { cn } from "../utils/cn";
14
+ import {
15
+ pickerFocusClass,
16
+ } from "./_picker-classes";
17
+
18
+ // shadcn's v9 calendar on package tokens: no background of its own (it takes
19
+ // the popover or card surface it sits on), selected days use the picker
20
+ // active treatment, today gets a dot, outside days are muted and disabled
21
+ // days dimmed. day state lives on the cell (`data-selected`, `data-today`,
22
+ // ...), so the day button reads it through `group/day`
23
+ const navButtonClass = cn(
24
+ "inline-flex size-(--cell-size) select-none items-center justify-center rounded-lg p-0 text-muted-foreground transition-colors hover:bg-surface-tertiary hover:text-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50",
25
+ pickerFocusClass,
26
+ );
27
+
28
+ function CalendarRoot({
29
+ className,
30
+ rootRef,
31
+ ...props
32
+ }: React.ComponentProps<typeof Root>) {
33
+ return (
34
+ <div data-slot="calendar" ref={rootRef} className={className} {...props} />
35
+ );
36
+ }
37
+
38
+ function CalendarChevron({
39
+ className,
40
+ orientation,
41
+ }: React.ComponentProps<typeof Chevron>) {
42
+ const iconClass = cn("size-4", className);
43
+ if (orientation === "left") return <IconChevronLeft className={iconClass} />;
44
+ if (orientation === "right") {
45
+ return <IconChevronRight className={iconClass} />;
46
+ }
47
+ return <IconChevronDown className={iconClass} />;
48
+ }
49
+
50
+ function Calendar({
51
+ className,
52
+ classNames,
53
+ showOutsideDays = true,
54
+ captionLayout = "label",
55
+ formatters,
56
+ components,
57
+ ...props
58
+ }: React.ComponentProps<typeof DayPicker>) {
59
+ const defaultClassNames = getDefaultClassNames();
60
+
61
+ return (
62
+ <DayPicker
63
+ showOutsideDays={showOutsideDays}
64
+ className={cn(
65
+ "group/calendar p-1 text-foreground [--cell-size:--spacing(8)]",
66
+ String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
67
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
68
+ className,
69
+ )}
70
+ captionLayout={captionLayout}
71
+ formatters={{
72
+ formatMonthDropdown: (date) =>
73
+ date.toLocaleString("default", { month: "short" }),
74
+ ...formatters,
75
+ }}
76
+ classNames={{
77
+ root: cn("w-fit", defaultClassNames.root),
78
+ months: cn(
79
+ "relative flex flex-col gap-4 md:flex-row",
80
+ defaultClassNames.months,
81
+ ),
82
+ month: cn("flex w-full flex-col gap-2", defaultClassNames.month),
83
+ nav: cn(
84
+ "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
85
+ defaultClassNames.nav,
86
+ ),
87
+ button_previous: cn(navButtonClass, defaultClassNames.button_previous),
88
+ button_next: cn(navButtonClass, defaultClassNames.button_next),
89
+ month_caption: cn(
90
+ "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
91
+ defaultClassNames.month_caption,
92
+ ),
93
+ dropdowns: cn(
94
+ "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-xs font-medium",
95
+ defaultClassNames.dropdowns,
96
+ ),
97
+ dropdown_root: cn(
98
+ "relative rounded-field border border-border has-focus:border-focus-border has-focus:ring-2 has-focus:ring-focus-ring",
99
+ defaultClassNames.dropdown_root,
100
+ ),
101
+ dropdown: cn(
102
+ "absolute inset-0 bg-overlay opacity-0",
103
+ defaultClassNames.dropdown,
104
+ ),
105
+ caption_label: cn(
106
+ "select-none text-xs font-medium",
107
+ captionLayout !== "label" &&
108
+ "flex h-7 items-center gap-1 rounded-field pl-2 pr-1 [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
109
+ defaultClassNames.caption_label,
110
+ ),
111
+ month_grid: "w-full border-collapse",
112
+ weekdays: cn("flex", defaultClassNames.weekdays),
113
+ weekday: cn(
114
+ "flex-1 select-none text-[10px] font-normal uppercase tracking-wider text-muted-foreground",
115
+ defaultClassNames.weekday,
116
+ ),
117
+ week: cn("mt-1 flex w-full", defaultClassNames.week),
118
+ week_number_header: cn(
119
+ "w-(--cell-size) select-none",
120
+ defaultClassNames.week_number_header,
121
+ ),
122
+ week_number: cn(
123
+ "select-none text-[10px] text-muted-foreground",
124
+ defaultClassNames.week_number,
125
+ ),
126
+ day: cn(
127
+ "group/day relative aspect-square h-full w-full select-none p-0 text-center",
128
+ defaultClassNames.day,
129
+ ),
130
+ day_button: cn(
131
+ "relative inline-flex size-(--cell-size) items-center justify-center rounded-lg text-xs tabular-nums text-foreground/80 transition-colors disabled:cursor-not-allowed disabled:opacity-40",
132
+ pickerFocusClass,
133
+ // today, outside and hover rules exclude selected days, so the
134
+ // selected tone never depends on the order Tailwind emits rules in.
135
+ // the today dot is `bg-current`: foreground, or primary-foreground
136
+ // when selected
137
+ "not-group-data-selected/day:enabled:hover:bg-surface-tertiary/50",
138
+ "group-data-today/day:font-semibold group-data-today/day:not-group-data-selected/day:text-foreground group-data-today/day:after:absolute group-data-today/day:after:bottom-1 group-data-today/day:after:left-1/2 group-data-today/day:after:size-1 group-data-today/day:after:-translate-x-1/2 group-data-today/day:after:rounded-full group-data-today/day:after:bg-current",
139
+ "group-data-outside/day:not-group-data-selected/day:text-muted-foreground",
140
+ // spelled out (not built from pickerSelectedClass) so Tailwind's
141
+ // scanner sees it
142
+ "group-data-selected/day:bg-primary group-data-selected/day:text-primary-foreground",
143
+ defaultClassNames.day_button,
144
+ ),
145
+ range_start: cn("rounded-l-lg", defaultClassNames.range_start),
146
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
147
+ range_end: cn("rounded-r-lg", defaultClassNames.range_end),
148
+ hidden: cn("invisible", defaultClassNames.hidden),
149
+ ...classNames,
150
+ }}
151
+ components={{
152
+ Root: CalendarRoot,
153
+ Chevron: CalendarChevron,
154
+ ...components,
155
+ }}
156
+ {...props}
157
+ />
158
+ );
159
+ }
160
+
161
+ export { Calendar };
@@ -0,0 +1,545 @@
1
+ import * as React from "react";
2
+ import {
3
+ IconCalendar,
4
+ IconCalendarMonth,
5
+ IconChevronLeft,
6
+ IconChevronRight,
7
+ IconX,
8
+ } from "@tabler/icons-react";
9
+ import type { Matcher } from "react-day-picker";
10
+ import { cn } from "../utils/cn";
11
+ import { assignRef } from "../utils/ref";
12
+ import {
13
+ pickerFocusClass,
14
+ pickerFieldClass,
15
+ pickerItemHoverClass,
16
+ pickerSelectedClass,
17
+ pickerTriggerClass,
18
+ } from "./_picker-classes";
19
+ import { Calendar } from "./calendar";
20
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover";
21
+ import { TimePicker } from "./time-picker";
22
+
23
+ // controlled replacements for `<input type="date">`, `"datetime-local"` and
24
+ // `"month">`: the values are the native strings ("YYYY-MM-DD",
25
+ // "YYYY-MM-DDTHH:mm", "YYYY-MM", "" when empty), read and written as local
26
+ // calendar dates. there is no timezone logic; like TimePicker, the consumer
27
+ // owns any local↔utc conversion
28
+
29
+ const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
30
+ const MONTH_RE = /^(\d{4})-(\d{2})$/;
31
+ const DATE_TIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})$/;
32
+
33
+ const pad = (n: number, width = 2) => String(n).padStart(width, "0");
34
+
35
+ // "YYYY-MM-DD" -> local midnight, or undefined for "" and invalid dates
36
+ // (2025-02-30). never `new Date("YYYY-MM-DD")`, which parses as utc midnight
37
+ // and lands on the previous day west of greenwich
38
+ function parseDateValue(value: string): Date | undefined {
39
+ const match = DATE_RE.exec(value);
40
+ if (!match) return undefined;
41
+ const year = Number(match[1]);
42
+ const month = Number(match[2]) - 1;
43
+ const day = Number(match[3]);
44
+ // setFullYear keeps years 0-99 literal (the Date constructor maps them to 19xx)
45
+ const date = new Date(2000, 0, 1);
46
+ date.setFullYear(year, month, day);
47
+ if (
48
+ date.getFullYear() !== year ||
49
+ date.getMonth() !== month ||
50
+ date.getDate() !== day
51
+ ) {
52
+ return undefined;
53
+ }
54
+ return date;
55
+ }
56
+
57
+ // local calendar date -> "YYYY-MM-DD" ("" for an invalid date)
58
+ function formatDateValue(date: Date): string {
59
+ if (Number.isNaN(date.getTime())) return "";
60
+ return `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
61
+ }
62
+
63
+ function parseMonthValue(
64
+ value: string,
65
+ ): { year: number; month: number } | undefined {
66
+ const match = MONTH_RE.exec(value);
67
+ if (!match) return undefined;
68
+ const year = Number(match[1]);
69
+ const month = Number(match[2]) - 1;
70
+ if (month < 0 || month > 11) return undefined;
71
+ return { year, month };
72
+ }
73
+
74
+ const formatMonthValue = (year: number, month: number) =>
75
+ `${pad(year, 4)}-${pad(month + 1)}`;
76
+
77
+ function localDate(year: number, month: number): Date {
78
+ const date = new Date(2000, 0, 1);
79
+ date.setFullYear(year, month, 1);
80
+ return date;
81
+ }
82
+
83
+ // wrapper, input-styled trigger, optional clear control and hidden input
84
+ // shared by DatePicker and MonthPicker. the clear control is a sibling of the
85
+ // trigger (buttons cannot nest), overlaid inside the field on the right, and
86
+ // hands focus back to the trigger. the trigger grows with the wrapper, so a
87
+ // width on either `className` or `triggerClassName` sizes the field
88
+ interface PickerFieldProps {
89
+ triggerRef: React.Ref<HTMLButtonElement>;
90
+ open: boolean;
91
+ onOpenChange: (open: boolean) => void;
92
+ value: string;
93
+ text: string | undefined;
94
+ placeholder: string;
95
+ icon: React.ReactNode;
96
+ clearLabel: string;
97
+ onClear: () => void;
98
+ clearable?: boolean;
99
+ disabled?: boolean;
100
+ ariaLabel?: string;
101
+ className?: string;
102
+ triggerClassName?: string;
103
+ id?: string;
104
+ name?: string;
105
+ children: React.ReactNode;
106
+ }
107
+
108
+ function PickerField({
109
+ triggerRef,
110
+ open,
111
+ onOpenChange,
112
+ value,
113
+ text,
114
+ placeholder,
115
+ icon,
116
+ clearLabel,
117
+ onClear,
118
+ clearable,
119
+ disabled,
120
+ ariaLabel,
121
+ className,
122
+ triggerClassName,
123
+ id,
124
+ name,
125
+ children,
126
+ }: PickerFieldProps) {
127
+ const buttonRef = React.useRef<HTMLButtonElement | null>(null);
128
+ const showClear = clearable && text !== undefined && !disabled;
129
+
130
+ return (
131
+ <div className={cn(pickerFieldClass, className)}>
132
+ <Popover open={open} onOpenChange={onOpenChange}>
133
+ <PopoverTrigger asChild>
134
+ <button
135
+ ref={(node) => {
136
+ buttonRef.current = node;
137
+ assignRef(triggerRef, node);
138
+ }}
139
+ id={id}
140
+ type="button"
141
+ disabled={disabled}
142
+ aria-label={
143
+ ariaLabel ? `${ariaLabel}, ${text ?? placeholder}` : undefined
144
+ }
145
+ className={cn(pickerTriggerClass, "w-40 min-w-0 grow", triggerClassName)}
146
+ >
147
+ <span
148
+ className={cn(
149
+ "truncate",
150
+ text === undefined && "text-field-placeholder",
151
+ showClear && "pr-7",
152
+ )}
153
+ >
154
+ {text ?? placeholder}
155
+ </span>
156
+ {icon}
157
+ </button>
158
+ </PopoverTrigger>
159
+ <PopoverContent align="start" sideOffset={4} className="w-auto p-2">
160
+ {children}
161
+ </PopoverContent>
162
+ </Popover>
163
+ {showClear ? (
164
+ <button
165
+ type="button"
166
+ aria-label={clearLabel}
167
+ onClick={() => {
168
+ onClear();
169
+ buttonRef.current?.focus();
170
+ }}
171
+ className={cn(
172
+ "absolute right-8 top-1/2 inline-flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-surface-tertiary hover:text-foreground",
173
+ pickerFocusClass,
174
+ )}
175
+ >
176
+ <IconX className="size-3" />
177
+ </button>
178
+ ) : null}
179
+ {name ? <input type="hidden" name={name} value={value} /> : null}
180
+ </div>
181
+ );
182
+ }
183
+
184
+ interface DatePickerProps {
185
+ // "YYYY-MM-DD" or "" (what `<input type="date">` uses)
186
+ value: string;
187
+ onChange: (value: string) => void;
188
+ // "YYYY-MM-DD"; days outside the range are disabled
189
+ min?: string;
190
+ max?: string;
191
+ placeholder?: string;
192
+ disabled?: boolean;
193
+ // names the trigger; the shown date or placeholder is appended
194
+ ariaLabel?: string;
195
+ // the wrapper (trigger plus clear control)
196
+ className?: string;
197
+ // merged onto the trigger (default width `w-40`); `w-full`, height, radius,
198
+ // padding and text size override cleanly
199
+ triggerClassName?: string;
200
+ // shows a clear control that calls `onChange("")`
201
+ clearable?: boolean;
202
+ // BCP 47 locale for the trigger text (default: the runtime's)
203
+ locale?: string;
204
+ id?: string;
205
+ // renders a hidden input with `value` so forms still submit it
206
+ name?: string;
207
+ }
208
+
209
+ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
210
+ (
211
+ {
212
+ value,
213
+ onChange,
214
+ min,
215
+ max,
216
+ placeholder = "Pick a date",
217
+ disabled,
218
+ ariaLabel,
219
+ className,
220
+ triggerClassName,
221
+ clearable,
222
+ locale,
223
+ id,
224
+ name,
225
+ },
226
+ ref,
227
+ ) => {
228
+ const [open, setOpen] = React.useState(false);
229
+ const selected = parseDateValue(value);
230
+ const minDate = min ? parseDateValue(min) : undefined;
231
+ const maxDate = max ? parseDateValue(max) : undefined;
232
+ const outOfRange: Matcher[] = [
233
+ ...(minDate ? [{ before: minDate }] : []),
234
+ ...(maxDate ? [{ after: maxDate }] : []),
235
+ ];
236
+
237
+ return (
238
+ <PickerField
239
+ triggerRef={ref}
240
+ open={open}
241
+ onOpenChange={setOpen}
242
+ value={value}
243
+ text={
244
+ selected
245
+ ? new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(
246
+ selected,
247
+ )
248
+ : undefined
249
+ }
250
+ placeholder={placeholder}
251
+ icon={<IconCalendar className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
252
+ clearLabel="Clear date"
253
+ onClear={() => onChange("")}
254
+ clearable={clearable}
255
+ disabled={disabled}
256
+ ariaLabel={ariaLabel}
257
+ className={className}
258
+ triggerClassName={triggerClassName}
259
+ id={id}
260
+ name={name}
261
+ >
262
+ <Calendar
263
+ mode="single"
264
+ required
265
+ selected={selected}
266
+ defaultMonth={selected ?? minDate}
267
+ startMonth={minDate}
268
+ endMonth={maxDate}
269
+ disabled={outOfRange}
270
+ autoFocus
271
+ onSelect={(date) => {
272
+ onChange(formatDateValue(date));
273
+ setOpen(false);
274
+ }}
275
+ />
276
+ </PickerField>
277
+ );
278
+ },
279
+ );
280
+ DatePicker.displayName = "DatePicker";
281
+
282
+ interface DateTimePickerProps {
283
+ // "YYYY-MM-DDTHH:mm" or "" (what `<input type="datetime-local">` uses)
284
+ value: string;
285
+ onChange: (value: string) => void;
286
+ // "YYYY-MM-DDTHH:mm"; only the date part limits the calendar
287
+ min?: string;
288
+ max?: string;
289
+ // time used when a date is picked before any time (default "00:00")
290
+ defaultTime?: string;
291
+ disabled?: boolean;
292
+ // names both triggers ("<label>, date" and "<label>, time")
293
+ ariaLabel?: string;
294
+ // the row; a width here (`w-full`) grows the date field, the time keeps 110px
295
+ className?: string;
296
+ // merged onto both triggers (height, radius, padding, text size); a width
297
+ // applies to the date trigger only, the time keeps 110px
298
+ triggerClassName?: string;
299
+ // on the date trigger
300
+ id?: string;
301
+ // renders a hidden input with the combined value
302
+ name?: string;
303
+ locale?: string;
304
+ }
305
+
306
+ function splitDateTime(value: string): { date: string; time: string } {
307
+ const match = DATE_TIME_RE.exec(value);
308
+ if (!match) return { date: "", time: "" };
309
+ return { date: match[1] ?? "", time: match[2] ?? "" };
310
+ }
311
+
312
+ const DateTimePicker = React.forwardRef<HTMLButtonElement, DateTimePickerProps>(
313
+ (
314
+ {
315
+ value,
316
+ onChange,
317
+ min,
318
+ max,
319
+ defaultTime = "00:00",
320
+ disabled,
321
+ ariaLabel,
322
+ className,
323
+ triggerClassName,
324
+ id,
325
+ name,
326
+ locale,
327
+ },
328
+ ref,
329
+ ) => {
330
+ const { date, time } = splitDateTime(value);
331
+
332
+ return (
333
+ <div className={cn(pickerFieldClass, "items-center gap-2", className)}>
334
+ <DatePicker
335
+ className="min-w-0 grow"
336
+ triggerClassName={triggerClassName}
337
+ ref={ref}
338
+ id={id}
339
+ value={date}
340
+ onChange={(next) => onChange(next ? `${next}T${time || defaultTime}` : "")}
341
+ min={min?.slice(0, 10)}
342
+ max={max?.slice(0, 10)}
343
+ disabled={disabled}
344
+ ariaLabel={ariaLabel ? `${ariaLabel}, date` : "Date"}
345
+ locale={locale}
346
+ />
347
+ <TimePicker
348
+ value={time || defaultTime}
349
+ onChange={(next) =>
350
+ onChange(`${date || formatDateValue(new Date())}T${next}`)
351
+ }
352
+ disabled={disabled}
353
+ ariaLabel={ariaLabel ? `${ariaLabel}, time` : "Time"}
354
+ className="shrink-0"
355
+ triggerClassName={cn(triggerClassName, "w-[110px]")}
356
+ />
357
+ {name ? <input type="hidden" name={name} value={value} /> : null}
358
+ </div>
359
+ );
360
+ },
361
+ );
362
+ DateTimePicker.displayName = "DateTimePicker";
363
+
364
+ interface MonthPickerProps {
365
+ // "YYYY-MM" or "" (what `<input type="month">` uses)
366
+ value: string;
367
+ onChange: (value: string) => void;
368
+ // "YYYY-MM"; months outside the range are disabled
369
+ min?: string;
370
+ max?: string;
371
+ placeholder?: string;
372
+ disabled?: boolean;
373
+ ariaLabel?: string;
374
+ className?: string;
375
+ // merged onto the trigger, as on DatePicker
376
+ triggerClassName?: string;
377
+ clearable?: boolean;
378
+ locale?: string;
379
+ id?: string;
380
+ name?: string;
381
+ }
382
+
383
+ const MonthPicker = React.forwardRef<HTMLButtonElement, MonthPickerProps>(
384
+ (
385
+ {
386
+ value,
387
+ onChange,
388
+ min,
389
+ max,
390
+ placeholder = "Pick a month",
391
+ disabled,
392
+ ariaLabel,
393
+ className,
394
+ triggerClassName,
395
+ clearable,
396
+ locale,
397
+ id,
398
+ name,
399
+ },
400
+ ref,
401
+ ) => {
402
+ const [open, setOpen] = React.useState(false);
403
+ const selected = parseMonthValue(value);
404
+
405
+ return (
406
+ <PickerField
407
+ triggerRef={ref}
408
+ open={open}
409
+ onOpenChange={setOpen}
410
+ value={value}
411
+ text={
412
+ selected
413
+ ? new Intl.DateTimeFormat(locale, {
414
+ month: "short",
415
+ year: "numeric",
416
+ }).format(localDate(selected.year, selected.month))
417
+ : undefined
418
+ }
419
+ placeholder={placeholder}
420
+ icon={
421
+ <IconCalendarMonth className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
422
+ }
423
+ clearLabel="Clear month"
424
+ onClear={() => onChange("")}
425
+ clearable={clearable}
426
+ disabled={disabled}
427
+ ariaLabel={ariaLabel}
428
+ className={className}
429
+ triggerClassName={triggerClassName}
430
+ id={id}
431
+ name={name}
432
+ >
433
+ <MonthGrid
434
+ value={value}
435
+ min={min}
436
+ max={max}
437
+ locale={locale}
438
+ onSelect={(next) => {
439
+ onChange(next);
440
+ setOpen(false);
441
+ }}
442
+ />
443
+ </PickerField>
444
+ );
445
+ },
446
+ );
447
+ MonthPicker.displayName = "MonthPicker";
448
+
449
+ interface MonthGridProps {
450
+ value: string;
451
+ min?: string;
452
+ max?: string;
453
+ locale?: string;
454
+ onSelect: (value: string) => void;
455
+ }
456
+
457
+ // mounted only while the popover is open, so the shown year starts at the
458
+ // selected one (else the current year, kept inside min/max) on every open
459
+ function MonthGrid({ value, min, max, locale, onSelect }: MonthGridProps) {
460
+ const minYear = parseMonthValue(min ?? "")?.year;
461
+ const maxYear = parseMonthValue(max ?? "")?.year;
462
+ const [year, setYear] = React.useState(() => {
463
+ const initial =
464
+ parseMonthValue(value)?.year ?? new Date().getFullYear();
465
+ if (maxYear !== undefined && initial > maxYear) return maxYear;
466
+ if (minYear !== undefined && initial < minYear) return minYear;
467
+ return initial;
468
+ });
469
+ const short = new Intl.DateTimeFormat(locale, { month: "short" });
470
+ const long = new Intl.DateTimeFormat(locale, {
471
+ month: "long",
472
+ year: "numeric",
473
+ });
474
+ const navClass = cn(
475
+ "inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-surface-tertiary hover:text-foreground disabled:pointer-events-none disabled:opacity-50",
476
+ pickerFocusClass,
477
+ );
478
+
479
+ return (
480
+ <div className="flex w-56 flex-col gap-2 p-1">
481
+ <div className="flex items-center justify-between">
482
+ <button
483
+ type="button"
484
+ aria-label="Previous year"
485
+ disabled={minYear !== undefined && year <= minYear}
486
+ onClick={() => setYear(year - 1)}
487
+ className={navClass}
488
+ >
489
+ <IconChevronLeft className="size-4" />
490
+ </button>
491
+ <div aria-live="polite" className="text-xs font-medium tabular-nums">
492
+ {year}
493
+ </div>
494
+ <button
495
+ type="button"
496
+ aria-label="Next year"
497
+ disabled={maxYear !== undefined && year >= maxYear}
498
+ onClick={() => setYear(year + 1)}
499
+ className={navClass}
500
+ >
501
+ <IconChevronRight className="size-4" />
502
+ </button>
503
+ </div>
504
+ <div role="group" aria-label={String(year)} className="grid grid-cols-3 gap-1">
505
+ {Array.from({ length: 12 }, (_, month) => {
506
+ const monthValue = formatMonthValue(year, month);
507
+ const date = localDate(year, month);
508
+ const isSelected = monthValue === value;
509
+ // "YYYY-MM" strings compare in calendar order
510
+ const outOfRange =
511
+ (!!min && monthValue < min) || (!!max && monthValue > max);
512
+ return (
513
+ <button
514
+ key={month}
515
+ type="button"
516
+ aria-label={long.format(date)}
517
+ aria-pressed={isSelected}
518
+ disabled={outOfRange}
519
+ data-month-value={monthValue}
520
+ onClick={() => onSelect(monthValue)}
521
+ className={cn(
522
+ "flex h-8 items-center justify-center rounded-lg text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-40",
523
+ pickerFocusClass,
524
+ isSelected
525
+ ? pickerSelectedClass
526
+ : cn("text-foreground/80", pickerItemHoverClass),
527
+ )}
528
+ >
529
+ {short.format(date)}
530
+ </button>
531
+ );
532
+ })}
533
+ </div>
534
+ </div>
535
+ );
536
+ }
537
+
538
+ export {
539
+ DatePicker,
540
+ DateTimePicker,
541
+ MonthPicker,
542
+ parseDateValue,
543
+ formatDateValue,
544
+ };
545
+ export type { DatePickerProps, DateTimePickerProps, MonthPickerProps };
package/src/ui/dialog.tsx CHANGED
@@ -211,6 +211,10 @@ const DialogBody = ({
211
211
  );
212
212
  DialogBody.displayName = "DialogBody";
213
213
 
214
+ // no fill of its own: the footer is not sticky, so it shows the content's
215
+ // surface. a token here (it was `bg-overlay`) showed as an inset block when
216
+ // that surface differs (`glass-panel-strong` from the app, or `bg-*` on the
217
+ // content)
214
218
  const DialogFooter = ({
215
219
  className,
216
220
  ...props
@@ -218,7 +222,7 @@ const DialogFooter = ({
218
222
  <div
219
223
  data-slot="dialog-footer"
220
224
  className={cn(
221
- "mt-auto flex flex-col-reverse gap-2 bg-overlay pt-2 sm:flex-row sm:justify-end",
225
+ "mt-auto flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end",
222
226
  "pb-[max(0px,env(safe-area-inset-bottom,0px))]",
223
227
  "[&_button]:max-sm:min-h-11",
224
228
  className,
@@ -2,6 +2,11 @@ import * as React from "react";
2
2
  import { IconClock } from "@tabler/icons-react";
3
3
  import { Popover, PopoverContent, PopoverTrigger } from "./popover";
4
4
  import { cn } from "../utils/cn";
5
+ import {
6
+ pickerItemHoverClass,
7
+ pickerSelectedClass,
8
+ pickerTriggerClass,
9
+ } from "./_picker-classes";
5
10
 
6
11
  // custom 24, hour "hh:mm" time picker replaces `<input type="time">` because the native picker varies wildly
7
12
  // across browsers and platforms (chrome/firefox/safari each render a
@@ -15,7 +20,8 @@ interface TimePickerProps {
15
20
  className?: string;
16
21
  disabled?: boolean;
17
22
  ariaLabel?: string;
18
- // width of the trigger button defaults to a compact 110px
23
+ // merged onto the trigger (width defaults to a compact 110px; height,
24
+ // radius, padding and text size override cleanly)
19
25
  triggerClassName?: string;
20
26
  }
21
27
 
@@ -88,8 +94,9 @@ const TimePicker = React.forwardRef<HTMLButtonElement, TimePickerProps>(
88
94
  disabled={disabled}
89
95
  aria-label={ariaLabel}
90
96
  className={cn(
91
- "inline-flex h-8 items-center justify-between gap-2 rounded-field border border-border bg-field-background px-2.5 text-xs font-medium tabular-nums transition-[border-color,box-shadow,background-color] duration-200 ease-smooth hover:bg-field-background/90 focus-visible:border-focus-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50",
92
- triggerClassName ?? "w-[110px]",
97
+ pickerTriggerClass,
98
+ "w-[110px]",
99
+ triggerClassName,
93
100
  className,
94
101
  )}
95
102
  >
@@ -152,8 +159,8 @@ const TimeColumn = React.forwardRef<HTMLDivElement, TimeColumnProps>(
152
159
  className={cn(
153
160
  "flex h-7 w-full items-center justify-center text-xs tabular-nums transition-colors",
154
161
  isSelected
155
- ? "bg-primary text-primary-foreground"
156
- : "text-foreground/80 hover:bg-surface-tertiary/50",
162
+ ? pickerSelectedClass
163
+ : cn("text-foreground/80", pickerItemHoverClass),
157
164
  )}
158
165
  >
159
166
  {v}