@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 +19 -2
- package/package.json +4 -1
- package/src/artifacts/artifact-card.tsx +4 -0
- package/src/artifacts/artifact-dialogs.tsx +15 -3
- package/src/artifacts/artifact-viewer.tsx +2 -1
- package/src/index.ts +2 -0
- package/src/ui/_picker-classes.ts +18 -0
- package/src/ui/calendar.tsx +161 -0
- package/src/ui/date-picker.tsx +545 -0
- package/src/ui/dialog.tsx +5 -1
- package/src/ui/time-picker.tsx +12 -5
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.
|
|
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({
|
|
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 "{title}"?</ConfirmDialog.Title>
|
|
63
|
-
<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
|
|
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,
|
package/src/ui/time-picker.tsx
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
?
|
|
156
|
-
: "text-foreground/80
|
|
162
|
+
? pickerSelectedClass
|
|
163
|
+
: cn("text-foreground/80", pickerItemHoverClass),
|
|
157
164
|
)}
|
|
158
165
|
>
|
|
159
166
|
{v}
|