@emara/ui 1.1.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/components/ui/.gitkeep +0 -0
- package/components/ui/accordion.stories.tsx +231 -0
- package/components/ui/accordion.tsx +250 -0
- package/components/ui/app-shell.stories.tsx +270 -0
- package/components/ui/app-shell.tsx +491 -0
- package/components/ui/avatar.stories.tsx +174 -0
- package/components/ui/avatar.tsx +257 -0
- package/components/ui/badge.stories.tsx +127 -0
- package/components/ui/badge.tsx +146 -0
- package/components/ui/breadcrumb.stories.tsx +92 -0
- package/components/ui/breadcrumb.tsx +302 -0
- package/components/ui/button.stories.tsx +186 -0
- package/components/ui/button.tsx +128 -0
- package/components/ui/card.stories.tsx +279 -0
- package/components/ui/card.tsx +250 -0
- package/components/ui/checkbox.stories.tsx +93 -0
- package/components/ui/checkbox.tsx +131 -0
- package/components/ui/combobox.stories.tsx +489 -0
- package/components/ui/combobox.tsx +874 -0
- package/components/ui/context-menu.stories.tsx +202 -0
- package/components/ui/context-menu.tsx +309 -0
- package/components/ui/data-table.stories.tsx +227 -0
- package/components/ui/data-table.tsx +539 -0
- package/components/ui/date-picker.stories.tsx +225 -0
- package/components/ui/date-picker.tsx +597 -0
- package/components/ui/dialog.stories.tsx +193 -0
- package/components/ui/dialog.tsx +262 -0
- package/components/ui/divider.stories.tsx +84 -0
- package/components/ui/divider.tsx +135 -0
- package/components/ui/drawer.stories.tsx +218 -0
- package/components/ui/drawer.tsx +329 -0
- package/components/ui/dropdown-menu.stories.tsx +270 -0
- package/components/ui/dropdown-menu.tsx +353 -0
- package/components/ui/empty-state.stories.tsx +121 -0
- package/components/ui/empty-state.tsx +289 -0
- package/components/ui/field-group.stories.tsx +201 -0
- package/components/ui/field-group.tsx +276 -0
- package/components/ui/form.stories.tsx +219 -0
- package/components/ui/form.tsx +542 -0
- package/components/ui/input.stories.tsx +154 -0
- package/components/ui/input.tsx +208 -0
- package/components/ui/label.stories.tsx +84 -0
- package/components/ui/label.tsx +98 -0
- package/components/ui/page-header.stories.tsx +136 -0
- package/components/ui/page-header.tsx +315 -0
- package/components/ui/pagination.stories.tsx +136 -0
- package/components/ui/pagination.tsx +427 -0
- package/components/ui/popover.stories.tsx +212 -0
- package/components/ui/popover.tsx +167 -0
- package/components/ui/radio-group.stories.tsx +96 -0
- package/components/ui/radio-group.tsx +250 -0
- package/components/ui/select.stories.tsx +203 -0
- package/components/ui/select.tsx +318 -0
- package/components/ui/sidebar.stories.tsx +186 -0
- package/components/ui/sidebar.tsx +623 -0
- package/components/ui/skeleton.stories.tsx +131 -0
- package/components/ui/skeleton.tsx +311 -0
- package/components/ui/switch.stories.tsx +74 -0
- package/components/ui/switch.tsx +186 -0
- package/components/ui/table.stories.tsx +107 -0
- package/components/ui/table.tsx +285 -0
- package/components/ui/tabs.stories.tsx +222 -0
- package/components/ui/tabs.tsx +287 -0
- package/components/ui/textarea.stories.tsx +96 -0
- package/components/ui/textarea.tsx +182 -0
- package/components/ui/toast.stories.tsx +169 -0
- package/components/ui/toast.tsx +250 -0
- package/components/ui/tooltip.stories.tsx +146 -0
- package/components/ui/tooltip.tsx +156 -0
- package/components/ui/top-bar.stories.tsx +182 -0
- package/components/ui/top-bar.tsx +155 -0
- package/dist/components/ui/accordion.d.ts +45 -0
- package/dist/components/ui/accordion.d.ts.map +1 -0
- package/dist/components/ui/accordion.js +99 -0
- package/dist/components/ui/accordion.js.map +1 -0
- package/dist/components/ui/app-shell.d.ts +70 -0
- package/dist/components/ui/app-shell.d.ts.map +1 -0
- package/dist/components/ui/app-shell.js +199 -0
- package/dist/components/ui/app-shell.js.map +1 -0
- package/dist/components/ui/avatar.d.ts +41 -0
- package/dist/components/ui/avatar.d.ts.map +1 -0
- package/dist/components/ui/avatar.js +104 -0
- package/dist/components/ui/avatar.js.map +1 -0
- package/dist/components/ui/badge.d.ts +27 -0
- package/dist/components/ui/badge.d.ts.map +1 -0
- package/dist/components/ui/badge.js +65 -0
- package/dist/components/ui/badge.js.map +1 -0
- package/dist/components/ui/breadcrumb.d.ts +35 -0
- package/dist/components/ui/breadcrumb.d.ts.map +1 -0
- package/dist/components/ui/breadcrumb.js +88 -0
- package/dist/components/ui/breadcrumb.js.map +1 -0
- package/dist/components/ui/button.d.ts +26 -0
- package/dist/components/ui/button.d.ts.map +1 -0
- package/dist/components/ui/button.js +73 -0
- package/dist/components/ui/button.js.map +1 -0
- package/dist/components/ui/card.d.ts +52 -0
- package/dist/components/ui/card.d.ts.map +1 -0
- package/dist/components/ui/card.js +96 -0
- package/dist/components/ui/card.js.map +1 -0
- package/dist/components/ui/checkbox.d.ts +18 -0
- package/dist/components/ui/checkbox.d.ts.map +1 -0
- package/dist/components/ui/checkbox.js +59 -0
- package/dist/components/ui/checkbox.js.map +1 -0
- package/dist/components/ui/combobox.d.ts +194 -0
- package/dist/components/ui/combobox.d.ts.map +1 -0
- package/dist/components/ui/combobox.js +361 -0
- package/dist/components/ui/combobox.js.map +1 -0
- package/dist/components/ui/context-menu.d.ts +46 -0
- package/dist/components/ui/context-menu.d.ts.map +1 -0
- package/dist/components/ui/context-menu.js +95 -0
- package/dist/components/ui/context-menu.js.map +1 -0
- package/dist/components/ui/data-table.d.ts +53 -0
- package/dist/components/ui/data-table.d.ts.map +1 -0
- package/dist/components/ui/data-table.js +163 -0
- package/dist/components/ui/data-table.js.map +1 -0
- package/dist/components/ui/date-picker.d.ts +103 -0
- package/dist/components/ui/date-picker.d.ts.map +1 -0
- package/dist/components/ui/date-picker.js +306 -0
- package/dist/components/ui/date-picker.js.map +1 -0
- package/dist/components/ui/dialog.d.ts +40 -0
- package/dist/components/ui/dialog.d.ts.map +1 -0
- package/dist/components/ui/dialog.js +110 -0
- package/dist/components/ui/dialog.js.map +1 -0
- package/dist/components/ui/divider.d.ts +30 -0
- package/dist/components/ui/divider.d.ts.map +1 -0
- package/dist/components/ui/divider.js +62 -0
- package/dist/components/ui/divider.js.map +1 -0
- package/dist/components/ui/drawer.d.ts +56 -0
- package/dist/components/ui/drawer.d.ts.map +1 -0
- package/dist/components/ui/drawer.js +147 -0
- package/dist/components/ui/drawer.js.map +1 -0
- package/dist/components/ui/dropdown-menu.d.ts +63 -0
- package/dist/components/ui/dropdown-menu.d.ts.map +1 -0
- package/dist/components/ui/dropdown-menu.js +116 -0
- package/dist/components/ui/dropdown-menu.js.map +1 -0
- package/dist/components/ui/empty-state.d.ts +43 -0
- package/dist/components/ui/empty-state.d.ts.map +1 -0
- package/dist/components/ui/empty-state.js +128 -0
- package/dist/components/ui/empty-state.js.map +1 -0
- package/dist/components/ui/field-group.d.ts +38 -0
- package/dist/components/ui/field-group.d.ts.map +1 -0
- package/dist/components/ui/field-group.js +107 -0
- package/dist/components/ui/field-group.js.map +1 -0
- package/dist/components/ui/form.d.ts +67 -0
- package/dist/components/ui/form.d.ts.map +1 -0
- package/dist/components/ui/form.js +286 -0
- package/dist/components/ui/form.js.map +1 -0
- package/dist/components/ui/input.d.ts +36 -0
- package/dist/components/ui/input.d.ts.map +1 -0
- package/dist/components/ui/input.js +99 -0
- package/dist/components/ui/input.js.map +1 -0
- package/dist/components/ui/label.d.ts +37 -0
- package/dist/components/ui/label.d.ts.map +1 -0
- package/dist/components/ui/label.js +34 -0
- package/dist/components/ui/label.js.map +1 -0
- package/dist/components/ui/page-header.d.ts +65 -0
- package/dist/components/ui/page-header.d.ts.map +1 -0
- package/dist/components/ui/page-header.js +140 -0
- package/dist/components/ui/page-header.js.map +1 -0
- package/dist/components/ui/pagination.d.ts +67 -0
- package/dist/components/ui/pagination.d.ts.map +1 -0
- package/dist/components/ui/pagination.js +109 -0
- package/dist/components/ui/pagination.js.map +1 -0
- package/dist/components/ui/popover.d.ts +28 -0
- package/dist/components/ui/popover.d.ts.map +1 -0
- package/dist/components/ui/popover.js +85 -0
- package/dist/components/ui/popover.js.map +1 -0
- package/dist/components/ui/radio-group.d.ts +35 -0
- package/dist/components/ui/radio-group.d.ts.map +1 -0
- package/dist/components/ui/radio-group.js +103 -0
- package/dist/components/ui/radio-group.js.map +1 -0
- package/dist/components/ui/select.d.ts +42 -0
- package/dist/components/ui/select.d.ts.map +1 -0
- package/dist/components/ui/select.js +86 -0
- package/dist/components/ui/select.js.map +1 -0
- package/dist/components/ui/sidebar.d.ts +59 -0
- package/dist/components/ui/sidebar.d.ts.map +1 -0
- package/dist/components/ui/sidebar.js +189 -0
- package/dist/components/ui/sidebar.js.map +1 -0
- package/dist/components/ui/skeleton.d.ts +77 -0
- package/dist/components/ui/skeleton.d.ts.map +1 -0
- package/dist/components/ui/skeleton.js +115 -0
- package/dist/components/ui/skeleton.js.map +1 -0
- package/dist/components/ui/switch.d.ts +26 -0
- package/dist/components/ui/switch.d.ts.map +1 -0
- package/dist/components/ui/switch.js +84 -0
- package/dist/components/ui/switch.js.map +1 -0
- package/dist/components/ui/table.d.ts +52 -0
- package/dist/components/ui/table.d.ts.map +1 -0
- package/dist/components/ui/table.js +109 -0
- package/dist/components/ui/table.js.map +1 -0
- package/dist/components/ui/tabs.d.ts +42 -0
- package/dist/components/ui/tabs.d.ts.map +1 -0
- package/dist/components/ui/tabs.js +163 -0
- package/dist/components/ui/tabs.js.map +1 -0
- package/dist/components/ui/textarea.d.ts +26 -0
- package/dist/components/ui/textarea.d.ts.map +1 -0
- package/dist/components/ui/textarea.js +96 -0
- package/dist/components/ui/textarea.js.map +1 -0
- package/dist/components/ui/toast.d.ts +77 -0
- package/dist/components/ui/toast.d.ts.map +1 -0
- package/dist/components/ui/toast.js +141 -0
- package/dist/components/ui/toast.js.map +1 -0
- package/dist/components/ui/tooltip.d.ts +31 -0
- package/dist/components/ui/tooltip.d.ts.map +1 -0
- package/dist/components/ui/tooltip.js +71 -0
- package/dist/components/ui/tooltip.js.map +1 -0
- package/dist/components/ui/top-bar.d.ts +30 -0
- package/dist/components/ui/top-bar.d.ts.map +1 -0
- package/dist/components/ui/top-bar.js +64 -0
- package/dist/components/ui/top-bar.js.map +1 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +6 -0
- package/dist/lib/utils.js.map +1 -0
- package/lib/utils.ts +6 -0
- package/package.json +112 -0
- package/styles/globals.css +685 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, useId, useMemo, useState } from "react";
|
|
4
|
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
5
|
+
import {
|
|
6
|
+
RiArrowLeftSLine,
|
|
7
|
+
RiArrowRightSLine,
|
|
8
|
+
RiCalendar2Line,
|
|
9
|
+
RiCloseLine,
|
|
10
|
+
} from "@remixicon/react";
|
|
11
|
+
import { format as fnsFormat, parse as fnsParse } from "date-fns";
|
|
12
|
+
import { ar, enUS, fr } from "date-fns/locale";
|
|
13
|
+
import { DayPicker, type DayPickerProps } from "react-day-picker";
|
|
14
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
15
|
+
|
|
16
|
+
import { cn } from "@/lib/utils";
|
|
17
|
+
|
|
18
|
+
// Per docs/emara-ui-phase-2-components.md §6.
|
|
19
|
+
// First pass: single mode + locale + format + min/max + numberingSystem.
|
|
20
|
+
// Range / multiple / inline / presets come in subsequent slices per the v0.3
|
|
21
|
+
// implementation plan.
|
|
22
|
+
|
|
23
|
+
// ----------------------------------------------------------------------------
|
|
24
|
+
// Numbering system helpers
|
|
25
|
+
// ----------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
type NumberingSystem = "latn" | "arab";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build a Intl.NumberFormat configured for a given locale + numbering system.
|
|
31
|
+
* Used to translate day numbers, week numbers, and year labels when the
|
|
32
|
+
* consumer asks for non-Latin numerals (e.g. ar/arab → ٠١٢٣٤٥٦٧٨٩).
|
|
33
|
+
*/
|
|
34
|
+
function buildNumberFormatter(
|
|
35
|
+
locale: string | undefined,
|
|
36
|
+
system: NumberingSystem,
|
|
37
|
+
): Intl.NumberFormat {
|
|
38
|
+
// BCP-47 extension subtag `-u-nu-<system>` is the standard way to attach
|
|
39
|
+
// a numbering system. Works even when the base locale isn't recognized.
|
|
40
|
+
const base = locale ?? "en-US";
|
|
41
|
+
try {
|
|
42
|
+
return new Intl.NumberFormat(`${base}-u-nu-${system}`);
|
|
43
|
+
} catch {
|
|
44
|
+
return new Intl.NumberFormat("en-US");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Replace ASCII digits 0-9 in `s` with the equivalents from `nf`. */
|
|
49
|
+
function translateDigits(s: string, nf: Intl.NumberFormat): string {
|
|
50
|
+
return s.replace(/[0-9]/g, (d) => nf.format(Number(d)));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ----------------------------------------------------------------------------
|
|
54
|
+
// Locale registry — small built-in set; consumers can extend via the
|
|
55
|
+
// `localeObject` prop when they need a locale not listed here.
|
|
56
|
+
// ----------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
const BUILT_IN_LOCALES: Record<string, Locale> = {
|
|
59
|
+
"en-US": enUS,
|
|
60
|
+
fr: fr,
|
|
61
|
+
"fr-FR": fr,
|
|
62
|
+
"fr-MA": fr,
|
|
63
|
+
ar: ar,
|
|
64
|
+
"ar-MA": ar,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type Locale = typeof enUS;
|
|
68
|
+
|
|
69
|
+
function resolveLocale(locale: string | undefined, override: Locale | undefined): Locale {
|
|
70
|
+
if (override) return override;
|
|
71
|
+
if (locale && BUILT_IN_LOCALES[locale]) return BUILT_IN_LOCALES[locale]!;
|
|
72
|
+
// Fall back to a 2-letter prefix lookup (e.g. "fr-CA" → fr).
|
|
73
|
+
if (locale) {
|
|
74
|
+
const prefix = locale.slice(0, 2);
|
|
75
|
+
if (BUILT_IN_LOCALES[prefix]) return BUILT_IN_LOCALES[prefix]!;
|
|
76
|
+
}
|
|
77
|
+
return enUS;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ----------------------------------------------------------------------------
|
|
81
|
+
// Trigger styling — mirrors Input's height + padding scale.
|
|
82
|
+
// ----------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
const triggerVariants = cva(
|
|
85
|
+
[
|
|
86
|
+
"flex items-center justify-between w-full gap-2",
|
|
87
|
+
"rounded-md border border-input bg-background text-foreground",
|
|
88
|
+
"transition-colors",
|
|
89
|
+
"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
90
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
91
|
+
].join(" "),
|
|
92
|
+
{
|
|
93
|
+
variants: {
|
|
94
|
+
size: {
|
|
95
|
+
xs: "h-7 ps-2.5 pe-2 text-xs",
|
|
96
|
+
sm: "h-8 ps-3 pe-2 text-xs",
|
|
97
|
+
md: "h-9 ps-3 pe-2 text-sm",
|
|
98
|
+
lg: "h-10 ps-3.5 pe-2.5 text-base",
|
|
99
|
+
xl: "h-12 ps-4 pe-3 text-base",
|
|
100
|
+
},
|
|
101
|
+
invalid: {
|
|
102
|
+
true: "border-destructive focus-visible:ring-destructive",
|
|
103
|
+
false: "",
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
defaultVariants: { size: "md", invalid: false },
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
type TriggerVariants = VariantProps<typeof triggerVariants>;
|
|
111
|
+
|
|
112
|
+
// ----------------------------------------------------------------------------
|
|
113
|
+
// DatePicker — single / range mode root.
|
|
114
|
+
// ----------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
type DateRange = { from: Date | undefined; to?: Date | undefined };
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Quick-pick option rendered as a side-column button next to the calendar.
|
|
120
|
+
* The `value` shape must match the picker's current `mode` — passing a
|
|
121
|
+
* `Date` to a range-mode picker (or vice versa) is a consumer error and
|
|
122
|
+
* will be ignored at click time.
|
|
123
|
+
*/
|
|
124
|
+
export type DatePickerPreset = {
|
|
125
|
+
label: React.ReactNode;
|
|
126
|
+
value: Date | DateRange | Date[];
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
type DatePickerCommonProps = Omit<
|
|
130
|
+
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
131
|
+
"value" | "defaultValue" | "onChange"
|
|
132
|
+
> &
|
|
133
|
+
TriggerVariants & {
|
|
134
|
+
open?: boolean;
|
|
135
|
+
defaultOpen?: boolean;
|
|
136
|
+
onOpenChange?: (open: boolean) => void;
|
|
137
|
+
placeholder?: string;
|
|
138
|
+
/** Range-only: trigger text shown after a start date is picked but the
|
|
139
|
+
* end date isn't yet. Default "Pick end date". */
|
|
140
|
+
rangeEndPlaceholder?: string;
|
|
141
|
+
/** Display format string per date-fns. Falls back to the locale default. */
|
|
142
|
+
format?: string;
|
|
143
|
+
/** BCP-47 locale string (e.g. "fr-MA", "ar-MA"). */
|
|
144
|
+
locale?: string;
|
|
145
|
+
/** Direct locale object; takes precedence over `locale`. */
|
|
146
|
+
localeObject?: Locale;
|
|
147
|
+
min?: Date;
|
|
148
|
+
max?: Date;
|
|
149
|
+
clearable?: boolean;
|
|
150
|
+
/** Forwarded to react-day-picker's `disabled` for custom disable logic. */
|
|
151
|
+
disabledDates?: DayPickerProps["disabled"];
|
|
152
|
+
/** Override the locale's week start (0 = Sunday, 1 = Monday, ...). */
|
|
153
|
+
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
|
154
|
+
showOutsideDays?: boolean;
|
|
155
|
+
showWeekNumbers?: boolean;
|
|
156
|
+
/**
|
|
157
|
+
* Numeral system for day numbers, week numbers, and year labels in the
|
|
158
|
+
* grid + trigger. `"latn"` (default) = Western Arabic digits (0123…);
|
|
159
|
+
* `"arab"` = Eastern Arabic digits (٠١٢…). Resolves via
|
|
160
|
+
* `Intl.NumberFormat`; the spec calls it out for Arabic locales.
|
|
161
|
+
*/
|
|
162
|
+
numberingSystem?: NumberingSystem;
|
|
163
|
+
/**
|
|
164
|
+
* Render the calendar inline (no Popover trigger). The component still
|
|
165
|
+
* controls `value` / `onValueChange`; `placeholder`, `format`, `clearable`,
|
|
166
|
+
* and trigger-only styling props (`size`, `invalid`) are ignored.
|
|
167
|
+
* Useful for date-picker filters that sit permanently inside a sidebar.
|
|
168
|
+
*/
|
|
169
|
+
inline?: boolean;
|
|
170
|
+
/**
|
|
171
|
+
* Quick-pick rows rendered in a side column next to the calendar.
|
|
172
|
+
* Common patterns: Today / Yesterday / Last 7 days / This month.
|
|
173
|
+
* Clicking a preset applies its `value` to the picker and (in popover
|
|
174
|
+
* single/range mode) closes the popover.
|
|
175
|
+
*/
|
|
176
|
+
presets?: DatePickerPreset[];
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
type DatePickerSingleProps = DatePickerCommonProps & {
|
|
180
|
+
mode?: "single";
|
|
181
|
+
value?: Date | null;
|
|
182
|
+
defaultValue?: Date | null;
|
|
183
|
+
onValueChange?: (value: Date | null) => void;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
type DatePickerRangeProps = DatePickerCommonProps & {
|
|
187
|
+
mode: "range";
|
|
188
|
+
value?: DateRange | null;
|
|
189
|
+
defaultValue?: DateRange | null;
|
|
190
|
+
onValueChange?: (value: DateRange | null) => void;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
type DatePickerMultipleProps = DatePickerCommonProps & {
|
|
194
|
+
mode: "multiple";
|
|
195
|
+
value?: Date[] | null;
|
|
196
|
+
defaultValue?: Date[] | null;
|
|
197
|
+
onValueChange?: (value: Date[]) => void;
|
|
198
|
+
/** Trigger-only: how many dates to render before collapsing the rest into
|
|
199
|
+
* "+N more". Default 2. */
|
|
200
|
+
maxVisibleDates?: number;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps | DatePickerMultipleProps;
|
|
204
|
+
|
|
205
|
+
const DatePicker = forwardRef<HTMLButtonElement, DatePickerProps>(function DatePicker(props, ref) {
|
|
206
|
+
// Extract the mode-specific props (value / defaultValue / onValueChange /
|
|
207
|
+
// mode) explicitly so they don't leak into the underlying <button> via
|
|
208
|
+
// `...rest`. We cast through `any` because TS can't narrow the union without
|
|
209
|
+
// a runtime discriminant, and the spreads below are mode-safe.
|
|
210
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
211
|
+
const propsAny = props as any;
|
|
212
|
+
const {
|
|
213
|
+
className,
|
|
214
|
+
size,
|
|
215
|
+
invalid,
|
|
216
|
+
open: openProp,
|
|
217
|
+
defaultOpen,
|
|
218
|
+
onOpenChange,
|
|
219
|
+
placeholder = "Pick a date",
|
|
220
|
+
rangeEndPlaceholder = "Pick end date",
|
|
221
|
+
format = "PP",
|
|
222
|
+
locale,
|
|
223
|
+
localeObject,
|
|
224
|
+
min,
|
|
225
|
+
max,
|
|
226
|
+
clearable = false,
|
|
227
|
+
disabledDates,
|
|
228
|
+
weekStartsOn,
|
|
229
|
+
showOutsideDays = true,
|
|
230
|
+
showWeekNumbers = false,
|
|
231
|
+
numberingSystem = "latn",
|
|
232
|
+
inline = false,
|
|
233
|
+
presets,
|
|
234
|
+
disabled,
|
|
235
|
+
mode: _mode,
|
|
236
|
+
value: _value,
|
|
237
|
+
defaultValue: _defaultValue,
|
|
238
|
+
onValueChange: _onValueChange,
|
|
239
|
+
...rest
|
|
240
|
+
} = propsAny;
|
|
241
|
+
void _mode;
|
|
242
|
+
void _value;
|
|
243
|
+
void _defaultValue;
|
|
244
|
+
void _onValueChange;
|
|
245
|
+
const mode = (props.mode ?? "single") as "single" | "range" | "multiple";
|
|
246
|
+
const maxVisibleDates =
|
|
247
|
+
mode === "multiple" ? ((props as DatePickerMultipleProps).maxVisibleDates ?? 2) : 0;
|
|
248
|
+
|
|
249
|
+
const isOpenControlled = openProp !== undefined;
|
|
250
|
+
const [openInternal, setOpenInternal] = useState(defaultOpen ?? false);
|
|
251
|
+
const open = isOpenControlled ? openProp : openInternal;
|
|
252
|
+
const setOpen = (next: boolean) => {
|
|
253
|
+
if (!isOpenControlled) setOpenInternal(next);
|
|
254
|
+
onOpenChange?.(next);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// ---- Single-mode value state ----
|
|
258
|
+
const singleValueProp = mode === "single" ? (props as DatePickerSingleProps).value : undefined;
|
|
259
|
+
const singleDefault =
|
|
260
|
+
mode === "single" ? ((props as DatePickerSingleProps).defaultValue ?? null) : null;
|
|
261
|
+
const isSingleControlled = mode === "single" && singleValueProp !== undefined;
|
|
262
|
+
const [singleInternal, setSingleInternal] = useState<Date | null>(singleDefault);
|
|
263
|
+
const singleValue = isSingleControlled ? (singleValueProp ?? null) : singleInternal;
|
|
264
|
+
const setSingleValue = (next: Date | null) => {
|
|
265
|
+
if (!isSingleControlled) setSingleInternal(next);
|
|
266
|
+
if (mode === "single") (props as DatePickerSingleProps).onValueChange?.(next);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// ---- Range-mode value state ----
|
|
270
|
+
const rangeValueProp = mode === "range" ? (props as DatePickerRangeProps).value : undefined;
|
|
271
|
+
const rangeDefault =
|
|
272
|
+
mode === "range" ? ((props as DatePickerRangeProps).defaultValue ?? null) : null;
|
|
273
|
+
const isRangeControlled = mode === "range" && rangeValueProp !== undefined;
|
|
274
|
+
const [rangeInternal, setRangeInternal] = useState<DateRange | null>(rangeDefault);
|
|
275
|
+
const rangeValue = isRangeControlled ? (rangeValueProp ?? null) : rangeInternal;
|
|
276
|
+
const setRangeValue = (next: DateRange | null) => {
|
|
277
|
+
if (!isRangeControlled) setRangeInternal(next);
|
|
278
|
+
if (mode === "range") (props as DatePickerRangeProps).onValueChange?.(next);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// ---- Multiple-mode value state ----
|
|
282
|
+
const multipleValueProp =
|
|
283
|
+
mode === "multiple" ? (props as DatePickerMultipleProps).value : undefined;
|
|
284
|
+
const multipleDefault =
|
|
285
|
+
mode === "multiple" ? ((props as DatePickerMultipleProps).defaultValue ?? []) : [];
|
|
286
|
+
const isMultipleControlled = mode === "multiple" && multipleValueProp !== undefined;
|
|
287
|
+
const [multipleInternal, setMultipleInternal] = useState<Date[]>(multipleDefault ?? []);
|
|
288
|
+
const multipleValue: Date[] = isMultipleControlled ? (multipleValueProp ?? []) : multipleInternal;
|
|
289
|
+
const setMultipleValue = (next: Date[]) => {
|
|
290
|
+
if (!isMultipleControlled) setMultipleInternal(next);
|
|
291
|
+
if (mode === "multiple") (props as DatePickerMultipleProps).onValueChange?.(next);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const resolved = resolveLocale(locale, localeObject);
|
|
295
|
+
const numberFormatter = useMemo(
|
|
296
|
+
() => buildNumberFormatter(locale, numberingSystem),
|
|
297
|
+
[locale, numberingSystem],
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// Display string per mode. For range, format both ends; show
|
|
301
|
+
// `rangeEndPlaceholder` while the second pick is pending.
|
|
302
|
+
const fmtOne = (d: Date) => {
|
|
303
|
+
const raw = fnsFormat(d, format, { locale: resolved });
|
|
304
|
+
return numberingSystem !== "latn" ? translateDigits(raw, numberFormatter) : raw;
|
|
305
|
+
};
|
|
306
|
+
let display: string | null = null;
|
|
307
|
+
if (mode === "single") {
|
|
308
|
+
display = singleValue ? fmtOne(singleValue) : null;
|
|
309
|
+
} else if (mode === "range") {
|
|
310
|
+
if (rangeValue?.from && rangeValue.to) {
|
|
311
|
+
display = `${fmtOne(rangeValue.from)} — ${fmtOne(rangeValue.to)}`;
|
|
312
|
+
} else if (rangeValue?.from) {
|
|
313
|
+
display = `${fmtOne(rangeValue.from)} — ${rangeEndPlaceholder}`;
|
|
314
|
+
}
|
|
315
|
+
} else if (mode === "multiple") {
|
|
316
|
+
if (multipleValue.length > 0) {
|
|
317
|
+
const visible = multipleValue.slice(0, maxVisibleDates).map(fmtOne).join(", ");
|
|
318
|
+
const overflow = multipleValue.length - maxVisibleDates;
|
|
319
|
+
display = overflow > 0 ? `${visible} +${overflow} more` : visible;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const contentId = useId();
|
|
324
|
+
const consumerLabelled =
|
|
325
|
+
rest["aria-label"] !== undefined || rest["aria-labelledby"] !== undefined;
|
|
326
|
+
const ariaLabelFallback = consumerLabelled ? undefined : (display ?? placeholder);
|
|
327
|
+
|
|
328
|
+
const disabledList: DayPickerProps["disabled"] = (() => {
|
|
329
|
+
const parts: NonNullable<DayPickerProps["disabled"]>[] = [];
|
|
330
|
+
if (min) parts.push({ before: min });
|
|
331
|
+
if (max) parts.push({ after: max });
|
|
332
|
+
if (disabledDates) parts.push(disabledDates);
|
|
333
|
+
return parts.length === 0 ? undefined : (parts as DayPickerProps["disabled"]);
|
|
334
|
+
})();
|
|
335
|
+
|
|
336
|
+
const handleSingleSelect = (date: Date | undefined) => {
|
|
337
|
+
setSingleValue(date ?? null);
|
|
338
|
+
if (date && !inline) setOpen(false);
|
|
339
|
+
};
|
|
340
|
+
const handleRangeSelect = (range: DateRange | undefined) => {
|
|
341
|
+
setRangeValue(range ?? null);
|
|
342
|
+
// Auto-close only when a complete range is set (both endpoints).
|
|
343
|
+
if (range?.from && range.to && !inline) setOpen(false);
|
|
344
|
+
};
|
|
345
|
+
const handleMultipleSelect = (dates: Date[] | undefined) => {
|
|
346
|
+
setMultipleValue(dates ?? []);
|
|
347
|
+
// No auto-close — picking individual dates is open-ended in multiple mode.
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const applyPreset = (preset: DatePickerPreset) => {
|
|
351
|
+
const v = preset.value;
|
|
352
|
+
if (mode === "single" && v instanceof Date) {
|
|
353
|
+
setSingleValue(v);
|
|
354
|
+
if (!inline) setOpen(false);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (mode === "range" && !Array.isArray(v) && !(v instanceof Date)) {
|
|
358
|
+
setRangeValue(v);
|
|
359
|
+
if (!inline && v.from && v.to) setOpen(false);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (mode === "multiple" && Array.isArray(v)) {
|
|
363
|
+
setMultipleValue(v);
|
|
364
|
+
// Multiple mode never auto-closes — matches handleMultipleSelect.
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
// Shape mismatch — silently ignore. Consumers see no effect and can
|
|
368
|
+
// notice in dev via the visual no-op; we don't throw to keep the
|
|
369
|
+
// component permissive (per the JSDoc note on DatePickerPreset).
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// Has-value flag for the clear button + a11y fallback.
|
|
373
|
+
const hasValue =
|
|
374
|
+
mode === "single"
|
|
375
|
+
? Boolean(singleValue)
|
|
376
|
+
: mode === "range"
|
|
377
|
+
? Boolean(rangeValue?.from)
|
|
378
|
+
: multipleValue.length > 0;
|
|
379
|
+
const buttonProps = rest;
|
|
380
|
+
|
|
381
|
+
// The DayPicker JSX is reused in both popover and inline branches. Extracted
|
|
382
|
+
// so we don't duplicate the (sizable) classNames + formatters config.
|
|
383
|
+
// Shared DayPicker config across modes / branches.
|
|
384
|
+
const sharedDayPickerProps = {
|
|
385
|
+
locale: resolved,
|
|
386
|
+
disabled: disabledList,
|
|
387
|
+
weekStartsOn,
|
|
388
|
+
showOutsideDays,
|
|
389
|
+
showWeekNumber: showWeekNumbers,
|
|
390
|
+
...(numberingSystem !== "latn" && {
|
|
391
|
+
formatters: {
|
|
392
|
+
formatDay: (date: Date) => numberFormatter.format(date.getDate()),
|
|
393
|
+
formatWeekNumber: (week: number) => numberFormatter.format(week),
|
|
394
|
+
formatYearDropdown: (year: Date) => numberFormatter.format(year.getFullYear()),
|
|
395
|
+
formatMonthDropdown: (month: Date) =>
|
|
396
|
+
new Intl.DateTimeFormat(locale ?? "en-US", { month: "long" }).format(month),
|
|
397
|
+
},
|
|
398
|
+
}),
|
|
399
|
+
classNames: {
|
|
400
|
+
months: "flex flex-col sm:flex-row gap-4",
|
|
401
|
+
month: "space-y-3",
|
|
402
|
+
month_caption: "flex justify-center pt-1 relative items-center h-7",
|
|
403
|
+
caption_label: "text-sm font-medium",
|
|
404
|
+
nav: "absolute inset-x-0 top-1 flex items-center justify-between px-1",
|
|
405
|
+
button_previous:
|
|
406
|
+
"inline-flex h-7 w-7 items-center justify-center rounded-md border border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-50",
|
|
407
|
+
button_next:
|
|
408
|
+
"inline-flex h-7 w-7 items-center justify-center rounded-md border border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-50",
|
|
409
|
+
month_grid: "w-full border-collapse",
|
|
410
|
+
weekdays: "flex",
|
|
411
|
+
weekday: "text-muted-foreground rounded-md w-9 font-normal text-xs uppercase",
|
|
412
|
+
week: "flex w-full mt-1",
|
|
413
|
+
day: "h-9 w-9 text-center text-sm p-0 relative",
|
|
414
|
+
day_button:
|
|
415
|
+
"inline-flex h-9 w-9 items-center justify-center rounded-md text-sm font-normal hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
416
|
+
selected:
|
|
417
|
+
"[&_button]:bg-primary [&_button]:text-primary-foreground [&_button]:hover:bg-primary/90 [&_button]:hover:text-primary-foreground",
|
|
418
|
+
// Range modifiers — endpoints stay primary (same as selected), the
|
|
419
|
+
// middle days get a softer accent fill so the range is visible.
|
|
420
|
+
range_start:
|
|
421
|
+
"[&_button]:bg-primary [&_button]:text-primary-foreground rounded-s-md rounded-e-none",
|
|
422
|
+
range_end:
|
|
423
|
+
"[&_button]:bg-primary [&_button]:text-primary-foreground rounded-e-md rounded-s-none",
|
|
424
|
+
range_middle:
|
|
425
|
+
"[&_button]:bg-accent/40 [&_button]:text-foreground [&_button]:rounded-none rounded-none",
|
|
426
|
+
today: "[&_button]:ring-1 [&_button]:ring-ring",
|
|
427
|
+
outside: "[&_button]:text-muted-foreground [&_button]:opacity-50",
|
|
428
|
+
disabled:
|
|
429
|
+
"[&_button]:text-muted-foreground [&_button]:opacity-40 [&_button]:pointer-events-none",
|
|
430
|
+
hidden: "invisible",
|
|
431
|
+
},
|
|
432
|
+
components: {
|
|
433
|
+
Chevron: ({ orientation }: { orientation?: "left" | "right" | "up" | "down" }) =>
|
|
434
|
+
orientation === "left" ? (
|
|
435
|
+
<RiArrowLeftSLine className="rtl-mirror size-4" aria-hidden="true" />
|
|
436
|
+
) : (
|
|
437
|
+
<RiArrowRightSLine className="rtl-mirror size-4" aria-hidden="true" />
|
|
438
|
+
),
|
|
439
|
+
},
|
|
440
|
+
} as const;
|
|
441
|
+
|
|
442
|
+
const rawDayPicker =
|
|
443
|
+
mode === "range" ? (
|
|
444
|
+
<DayPicker
|
|
445
|
+
mode="range"
|
|
446
|
+
selected={(rangeValue ?? undefined) as DateRange | undefined}
|
|
447
|
+
onSelect={handleRangeSelect}
|
|
448
|
+
{...sharedDayPickerProps}
|
|
449
|
+
/>
|
|
450
|
+
) : mode === "multiple" ? (
|
|
451
|
+
<DayPicker
|
|
452
|
+
mode="multiple"
|
|
453
|
+
selected={multipleValue}
|
|
454
|
+
onSelect={handleMultipleSelect}
|
|
455
|
+
{...sharedDayPickerProps}
|
|
456
|
+
/>
|
|
457
|
+
) : (
|
|
458
|
+
<DayPicker
|
|
459
|
+
mode="single"
|
|
460
|
+
selected={singleValue ?? undefined}
|
|
461
|
+
onSelect={handleSingleSelect}
|
|
462
|
+
{...sharedDayPickerProps}
|
|
463
|
+
/>
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
const presetsList =
|
|
467
|
+
presets && presets.length > 0 ? (
|
|
468
|
+
<ul
|
|
469
|
+
aria-label="Quick options"
|
|
470
|
+
className="border-border me-1 flex shrink-0 flex-col gap-1 border-e pe-3"
|
|
471
|
+
>
|
|
472
|
+
{presets.map((preset: DatePickerPreset, idx: number) => (
|
|
473
|
+
<li key={idx}>
|
|
474
|
+
<button
|
|
475
|
+
type="button"
|
|
476
|
+
onClick={() => applyPreset(preset)}
|
|
477
|
+
className={cn(
|
|
478
|
+
"inline-flex w-full items-center rounded-md px-3 py-1.5 text-sm",
|
|
479
|
+
"text-foreground hover:bg-accent hover:text-accent-foreground",
|
|
480
|
+
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
|
|
481
|
+
)}
|
|
482
|
+
>
|
|
483
|
+
{preset.label}
|
|
484
|
+
</button>
|
|
485
|
+
</li>
|
|
486
|
+
))}
|
|
487
|
+
</ul>
|
|
488
|
+
) : null;
|
|
489
|
+
|
|
490
|
+
const dayPicker = presetsList ? (
|
|
491
|
+
<div className="flex items-start">
|
|
492
|
+
{presetsList}
|
|
493
|
+
<div>{rawDayPicker}</div>
|
|
494
|
+
</div>
|
|
495
|
+
) : (
|
|
496
|
+
rawDayPicker
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
if (inline) {
|
|
500
|
+
// Inline: no Popover, no trigger button. Render the calendar in a
|
|
501
|
+
// panel-styled wrapper so it looks consistent with the popover mode.
|
|
502
|
+
// `ref` is the consumer's button-shaped ref in popover mode; for the
|
|
503
|
+
// inline div we forward to a div via cast since the component's
|
|
504
|
+
// primary contract is the button trigger. Consumers needing a ref in
|
|
505
|
+
// inline mode should use `useRef<HTMLDivElement>()`.
|
|
506
|
+
return (
|
|
507
|
+
<div
|
|
508
|
+
ref={ref as unknown as React.Ref<HTMLDivElement>}
|
|
509
|
+
id={contentId}
|
|
510
|
+
className={cn(
|
|
511
|
+
"border-border bg-popover text-popover-foreground inline-block rounded-md border p-3 shadow-sm",
|
|
512
|
+
className,
|
|
513
|
+
)}
|
|
514
|
+
>
|
|
515
|
+
{dayPicker}
|
|
516
|
+
</div>
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return (
|
|
521
|
+
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
|
522
|
+
<PopoverPrimitive.Trigger asChild>
|
|
523
|
+
<button
|
|
524
|
+
ref={ref}
|
|
525
|
+
type="button"
|
|
526
|
+
// role="combobox" so aria-invalid is accepted (WAI-ARIA forbids
|
|
527
|
+
// it on `role="button"`). Matches Combobox's trigger semantics:
|
|
528
|
+
// the button represents a value and opens a panel.
|
|
529
|
+
role="combobox"
|
|
530
|
+
aria-haspopup="dialog"
|
|
531
|
+
aria-expanded={open}
|
|
532
|
+
// aria-controls is only valid when the controlled element exists
|
|
533
|
+
// in the DOM. Radix unmounts Content when closed, so omit
|
|
534
|
+
// aria-controls until open. Matches Radix Select's pattern.
|
|
535
|
+
aria-controls={open ? contentId : undefined}
|
|
536
|
+
aria-invalid={invalid || undefined}
|
|
537
|
+
aria-label={ariaLabelFallback}
|
|
538
|
+
disabled={disabled}
|
|
539
|
+
className={cn(triggerVariants({ size, invalid }), className)}
|
|
540
|
+
{...buttonProps}
|
|
541
|
+
>
|
|
542
|
+
<RiCalendar2Line className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
|
|
543
|
+
<span className={cn("flex-1 truncate text-start", !display && "text-muted-foreground")}>
|
|
544
|
+
{display ?? placeholder}
|
|
545
|
+
</span>
|
|
546
|
+
{clearable && hasValue && !disabled ? (
|
|
547
|
+
<button
|
|
548
|
+
type="button"
|
|
549
|
+
aria-label="Clear"
|
|
550
|
+
onClick={(e) => {
|
|
551
|
+
e.preventDefault();
|
|
552
|
+
e.stopPropagation();
|
|
553
|
+
if (mode === "range") setRangeValue(null);
|
|
554
|
+
else if (mode === "multiple") setMultipleValue([]);
|
|
555
|
+
else setSingleValue(null);
|
|
556
|
+
}}
|
|
557
|
+
className={cn(
|
|
558
|
+
"inline-flex shrink-0 items-center justify-center rounded p-0.5",
|
|
559
|
+
"text-muted-foreground hover:text-foreground",
|
|
560
|
+
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
|
|
561
|
+
)}
|
|
562
|
+
>
|
|
563
|
+
<RiCloseLine className="size-4" />
|
|
564
|
+
</button>
|
|
565
|
+
) : null}
|
|
566
|
+
</button>
|
|
567
|
+
</PopoverPrimitive.Trigger>
|
|
568
|
+
|
|
569
|
+
<PopoverPrimitive.Portal>
|
|
570
|
+
<PopoverPrimitive.Content
|
|
571
|
+
id={contentId}
|
|
572
|
+
align="start"
|
|
573
|
+
sideOffset={4}
|
|
574
|
+
className={cn(
|
|
575
|
+
"z-popover border-border bg-popover text-popover-foreground rounded-md border p-3 shadow-md outline-none",
|
|
576
|
+
"data-[state=open]:animate-[scale-in_var(--duration-fast)_var(--ease-out)]",
|
|
577
|
+
"data-[state=closed]:animate-[scale-out_var(--duration-fast)_var(--ease-in)]",
|
|
578
|
+
)}
|
|
579
|
+
>
|
|
580
|
+
{dayPicker}
|
|
581
|
+
</PopoverPrimitive.Content>
|
|
582
|
+
</PopoverPrimitive.Portal>
|
|
583
|
+
</PopoverPrimitive.Root>
|
|
584
|
+
);
|
|
585
|
+
});
|
|
586
|
+
DatePicker.displayName = "DatePicker";
|
|
587
|
+
|
|
588
|
+
export { DatePicker };
|
|
589
|
+
export type { DatePickerProps };
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Utility export — re-exports date-fns' `format` + `parse` so consumers don't
|
|
593
|
+
* have to import them separately for adapter glue. Pulls in date-fns
|
|
594
|
+
* tree-shakably.
|
|
595
|
+
*/
|
|
596
|
+
export const dateFormat = fnsFormat;
|
|
597
|
+
export const dateParse = fnsParse;
|