@ponti-studios/ui 1.6.0 → 1.7.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.
Files changed (77) hide show
  1. package/README.md +7 -11
  2. package/package.json +6 -31
  3. package/src/components/feedback/alert.tsx +8 -4
  4. package/src/components/feedback/index.ts +0 -4
  5. package/src/components/feedback/progress.stories.tsx +15 -0
  6. package/src/components/feedback/progress.tsx +14 -3
  7. package/src/components/feedback/update-guard.stories.tsx +4 -8
  8. package/src/components/feedback/update-guard.tsx +41 -272
  9. package/src/components/filters/filter-select.tsx +3 -5
  10. package/src/components/forms/date-picker.stories.tsx +158 -18
  11. package/src/components/forms/date-picker.tsx +286 -64
  12. package/src/components/forms/date-utils.ts +7 -2
  13. package/src/components/forms/drop-zone.tsx +11 -12
  14. package/src/components/forms/index.ts +2 -6
  15. package/src/components/forms/passkey-management.stories.tsx +8 -4
  16. package/src/components/forms/passkey-management.tsx +78 -42
  17. package/src/components/forms/radio-group.stories.tsx +3 -5
  18. package/src/components/forms/radio-group.tsx +2 -2
  19. package/src/components/forms/select.stories.tsx +16 -13
  20. package/src/components/forms/textarea.stories.tsx +1 -1
  21. package/src/components/layout/index.ts +2 -0
  22. package/src/components/layout/particle-background.stories.tsx +38 -0
  23. package/src/components/layout/particle-background.tsx +378 -0
  24. package/src/components/layout/scroll-area.stories.tsx +12 -4
  25. package/src/components/layout/scroll-area.tsx +41 -4
  26. package/src/components/navigation/tabs.stories.tsx +0 -21
  27. package/src/components/overlays/dialog.stories.tsx +38 -1
  28. package/src/components/overlays/dialog.tsx +53 -12
  29. package/src/components/overlays/index.ts +0 -11
  30. package/src/components/primitives/avatar-placeholder.svg +13 -0
  31. package/src/components/primitives/avatar.stories.tsx +2 -1
  32. package/src/components/primitives/badge.stories.tsx +13 -3
  33. package/src/components/primitives/badge.tsx +1 -1
  34. package/src/components/primitives/button.tsx +1 -1
  35. package/src/components/primitives/card.tsx +6 -2
  36. package/src/components/primitives/index.ts +0 -3
  37. package/src/index.ts +1 -3
  38. package/src/lib/index.ts +2 -3
  39. package/src/styles/animations.css +62 -0
  40. package/src/styles/index.css +27 -0
  41. package/src/styles/tokens/generated/dark.colors.generated.ts +15 -12
  42. package/src/styles/tokens/generated/dark.css +15 -12
  43. package/src/styles/tokens/generated/light.colors.generated.ts +14 -11
  44. package/src/styles/tokens/generated/light.css +14 -11
  45. package/src/styles/tokens/index.ts +0 -3
  46. package/src/styles/tokens/source/dark.tokens.json +24 -12
  47. package/src/styles/tokens/source/light.tokens.json +23 -11
  48. package/src/styles/tokens/typography.stories.tsx +157 -0
  49. package/src/components/feedback/file-upload-status.stories.tsx +0 -57
  50. package/src/components/feedback/file-upload-status.tsx +0 -160
  51. package/src/components/feedback/progress-bar.stories.tsx +0 -39
  52. package/src/components/feedback/progress-bar.tsx +0 -22
  53. package/src/components/forms/calendar.stories.tsx +0 -30
  54. package/src/components/forms/calendar.tsx +0 -75
  55. package/src/components/forms/date-field.stories.tsx +0 -202
  56. package/src/components/forms/date-field.tsx +0 -179
  57. package/src/components/overlays/alert-dialog.stories.tsx +0 -45
  58. package/src/components/overlays/alert-dialog.tsx +0 -116
  59. package/src/components/primitives/label.stories.tsx +0 -29
  60. package/src/components/primitives/label.tsx +0 -20
  61. package/src/components/primitives/status-badge.stories.tsx +0 -52
  62. package/src/components/primitives/status-badge.tsx +0 -33
  63. package/src/lib/create-memory-storage.ts +0 -24
  64. package/src/native/card.tsx +0 -36
  65. package/src/native/icon-button.tsx +0 -41
  66. package/src/native/index.ts +0 -38
  67. package/src/native/list-row.tsx +0 -67
  68. package/src/native/make-styles.ts +0 -32
  69. package/src/native/shadows.ts +0 -38
  70. package/src/native/text-field.tsx +0 -34
  71. package/src/native/uniwind.d.ts +0 -1
  72. package/src/native/use-theme-colors.ts +0 -19
  73. package/src/styles/tokens/foundations.stories.tsx +0 -109
  74. package/src/styles/tokens/generated/native.generated.ts +0 -266
  75. package/src/styles/tokens/normalize-native.ts +0 -88
  76. package/src/styles/tokens/source/native.tokens.json +0 -204
  77. package/src/styles/tokens/style-dictionary.native.mjs +0 -26
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, userEvent } from "storybook/test";
2
3
  import { useState } from "react";
3
4
 
4
5
  import { DatePicker } from "./date-picker";
@@ -12,37 +13,176 @@ const meta = {
12
13
  export default meta;
13
14
  type Story = StoryObj<typeof meta>;
14
15
 
16
+ function dateValue(canvasElement: HTMLElement, name: string): string {
17
+ const input = canvasElement.querySelector<HTMLInputElement>(`input[name="${name}"]`);
18
+ return input?.value ?? "";
19
+ }
20
+
21
+ // The native date input owns the picker UI, so stories assert on the input's
22
+ // "YYYY-MM-DD" value — the same string a form would submit.
15
23
  export const Default: Story = {
16
24
  args: {
17
- value: undefined,
18
- onSelect: () => {},
19
- placeholder: "Pick a date",
25
+ name: "startDate",
26
+ defaultValue: "2020-01-15",
27
+ label: "Start date",
20
28
  },
21
- render: (args) => {
22
- const [date, setDate] = useState<Date | undefined>(args.value);
23
- return <DatePicker {...args} value={date} onSelect={setDate} />;
29
+ play: async ({ canvasElement }) => {
30
+ const input = canvasElement.querySelector<HTMLInputElement>('input[name="startDate"]');
31
+ await expect(input?.value).toBe("2020-01-15");
24
32
  },
25
33
  };
26
34
 
27
- export const WithLabel: Story = {
35
+ export const WithDateObjectDefault: Story = {
28
36
  args: {
29
- value: new Date(),
30
- onSelect: () => {},
31
- label: "Due Date",
32
- showLabel: true,
37
+ name: "endDate",
38
+ defaultValue: new Date(2020, 0, 15),
39
+ label: "End date",
40
+ },
41
+ play: async ({ canvasElement }) => {
42
+ const input = canvasElement.querySelector<HTMLInputElement>('input[name="endDate"]');
43
+ await expect(input?.value).toBe("2020-01-15");
44
+ },
45
+ };
46
+
47
+ export const Controlled: Story = {
48
+ render: () => {
49
+ const [value, setValue] = useState<string | null>("2026-08-20");
50
+ return (
51
+ <div className="flex flex-col gap-2">
52
+ <DatePicker
53
+ mode="single"
54
+ label="Due date"
55
+ value={value}
56
+ onValueChange={setValue}
57
+ helpText="Controlled — pick a new date"
58
+ />
59
+ <p className="text-sm">value: {value ?? "(none)"}</p>
60
+ </div>
61
+ );
33
62
  },
34
- render: (args) => {
35
- const [date, setDate] = useState<Date | undefined>(args.value);
36
- return <DatePicker {...args} value={date} onSelect={setDate} />;
63
+ play: async ({ canvasElement }) => {
64
+ await expect(canvasElement.textContent).toContain("2026-08-20");
37
65
  },
38
66
  };
39
67
 
40
68
  export const Disabled: Story = {
41
69
  args: {
42
- value: new Date(),
43
- onSelect: () => {},
70
+ name: "startDate",
71
+ defaultValue: "2020-01-15",
72
+ label: "Locked date",
44
73
  disabled: true,
45
- label: "Locked Date",
46
- showLabel: true,
74
+ },
75
+ play: async ({ canvasElement }) => {
76
+ const input = canvasElement.querySelector<HTMLInputElement>('input[name="startDate"]');
77
+ await expect(input?.disabled).toBe(true);
78
+ await expect(input?.value).toBe("2020-01-15");
79
+ },
80
+ };
81
+
82
+ export const WithHelpText: Story = {
83
+ args: {
84
+ name: "startDate",
85
+ label: "Start date",
86
+ helpText: "Format is YYYY-MM-DD.",
87
+ },
88
+ };
89
+
90
+ export const WithError: Story = {
91
+ args: {
92
+ name: "startDate",
93
+ label: "Start date",
94
+ error: "A start date is required.",
95
+ },
96
+ play: async ({ canvasElement }) => {
97
+ const input = canvasElement.querySelector<HTMLInputElement>('input[name="startDate"]');
98
+ await expect(input?.getAttribute("aria-invalid")).toBe("true");
99
+ await expect(canvasElement.textContent).toContain("A start date is required.");
100
+ },
101
+ };
102
+
103
+ export const Range: Story = {
104
+ args: {
105
+ mode: "range",
106
+ startName: "startDate",
107
+ endName: "endDate",
108
+ defaultValue: { from: "2020-01-15", to: "2020-06-30" },
109
+ label: "Active period",
110
+ },
111
+ play: async ({ canvasElement }) => {
112
+ await expect(dateValue(canvasElement, "startDate")).toBe("2020-01-15");
113
+ await expect(dateValue(canvasElement, "endDate")).toBe("2020-06-30");
114
+ },
115
+ };
116
+
117
+ export const RangeEmpty: Story = {
118
+ args: {
119
+ mode: "range",
120
+ startName: "startDate",
121
+ endName: "endDate",
122
+ label: "Active period",
123
+ },
124
+ play: async ({ canvasElement }) => {
125
+ await expect(dateValue(canvasElement, "startDate")).toBe("");
126
+ await expect(dateValue(canvasElement, "endDate")).toBe("");
127
+ },
128
+ };
129
+
130
+ export const RangeControlled: Story = {
131
+ render: () => {
132
+ const [range, setRange] = useState<{ from: string | null; to: string | null }>({
133
+ from: "2026-08-01",
134
+ to: "2026-08-15",
135
+ });
136
+ return (
137
+ <div className="flex flex-col gap-2">
138
+ <DatePicker
139
+ mode="range"
140
+ label="Stay dates"
141
+ startName="stayStart"
142
+ endName="stayEnd"
143
+ value={range}
144
+ onValueChange={setRange}
145
+ fromLabel="Check-in"
146
+ toLabel="Check-out"
147
+ />
148
+ <p className="text-sm">
149
+ from: {range.from ?? "(none)"} · to: {range.to ?? "(none)"}
150
+ </p>
151
+ </div>
152
+ );
153
+ },
154
+ play: async ({ canvasElement }) => {
155
+ await expect(canvasElement.textContent).toContain("from: 2026-08-01");
156
+ await expect(canvasElement.textContent).toContain("to: 2026-08-15");
157
+
158
+ const end = canvasElement.querySelector<HTMLInputElement>('input[name="stayEnd"]');
159
+ await userEvent.clear(end!);
160
+ await userEvent.type(end!, "0002-08-01");
161
+ await expect(end?.value).toBe("0002-08-01");
162
+ await expect(canvasElement.textContent).toContain("to: 0002-08-01");
163
+ },
164
+ };
165
+
166
+ // Two independent fields cross-wired via min/max: the end field can't go
167
+ // before the start, and the start field can't go after the end.
168
+ export const RangeMinMax: Story = {
169
+ args: {
170
+ mode: "range",
171
+ startName: "startDate",
172
+ endName: "endDate",
173
+ min: "2026-01-01",
174
+ max: "2026-12-31",
175
+ label: "Active period",
176
+ },
177
+ play: async ({ canvasElement }) => {
178
+ const start = canvasElement.querySelector<HTMLInputElement>('input[name="startDate"]');
179
+ const end = canvasElement.querySelector<HTMLInputElement>('input[name="endDate"]');
180
+ await expect(start?.min).toBe("2026-01-01");
181
+ await expect(end?.max).toBe("2026-12-31");
182
+
183
+ await userEvent.type(start!, "2026-03-15");
184
+ await expect(start?.value).toBe("2026-03-15");
185
+ // End field's floor is now the chosen start date.
186
+ await expect(end?.min).toBe("2026-03-15");
47
187
  },
48
188
  };
@@ -1,81 +1,303 @@
1
- import { format } from "date-fns";
2
1
  import { CalendarIcon } from "lucide-react";
2
+ import { useId, useRef, useState } from "react";
3
+ import type { ChangeEvent, RefObject } from "react";
3
4
 
4
- import { Calendar } from "../forms/calendar";
5
- import { Popover, PopoverContent, PopoverTrigger } from "../overlays/popover";
6
- import { Button } from "../primitives/button";
5
+ import { cn } from "../../lib/utils";
6
+ import { formatDateInput } from "./date-utils";
7
7
 
8
- export interface DatePickerProps {
9
- value: Date | undefined;
10
- onSelect: (date: Date | undefined) => void;
11
- disabled?: boolean;
12
- id?: string;
13
- placeholder?: string;
8
+ export interface DateRangeValue {
9
+ from?: Date | string | null;
10
+ to?: Date | string | null;
11
+ }
12
+
13
+ interface DatePickerBaseProps {
14
14
  label?: string;
15
- dateFormat?: string;
16
- showLabel?: boolean;
17
- containerClassName?: string;
18
- popoverAlign?: "start" | "center" | "end";
19
- variant?: "outline" | "default" | "destructive" | "secondary" | "ghost" | "link";
20
- /**
21
- * Calendar caption style. `"dropdown"` swaps the month/year label for
22
- * select dropdowns, which is much faster for picking dates years in the
23
- * past (e.g. a job start date) than clicking prev/next repeatedly.
24
- */
25
- captionLayout?: "label" | "dropdown" | "dropdown-months" | "dropdown-years";
15
+ helpText?: string;
16
+ error?: string;
17
+ id?: string;
18
+ disabled?: boolean;
19
+ required?: boolean;
20
+ className?: string;
21
+ /** Earliest selectable date, "YYYY-MM-DD". */
22
+ min?: string;
23
+ /** Latest selectable date, "YYYY-MM-DD". */
24
+ max?: string;
25
+ }
26
+
27
+ export interface SingleDatePickerProps extends DatePickerBaseProps {
28
+ mode?: "single";
29
+ /** Form field name — the native input submits "YYYY-MM-DD". */
30
+ name?: string;
31
+ value?: Date | string | null;
32
+ defaultValue?: Date | string | null;
33
+ onValueChange?: (value: string | null) => void;
34
+ }
35
+
36
+ export interface RangeDatePickerProps extends DatePickerBaseProps {
37
+ mode: "range";
38
+ /** Form field name for the range start — submits "YYYY-MM-DD". */
39
+ startName?: string;
40
+ /** Form field name for the range end — submits "YYYY-MM-DD". */
41
+ endName?: string;
42
+ value?: DateRangeValue;
43
+ defaultValue?: DateRangeValue;
44
+ onValueChange?: (value: { from: string | null; to: string | null }) => void;
45
+ fromLabel?: string;
46
+ toLabel?: string;
26
47
  }
27
48
 
28
- export function DatePicker({
29
- value,
30
- onSelect,
31
- disabled = false,
32
- id = "date-picker",
33
- placeholder = "Pick a date",
49
+ export type DatePickerProps = SingleDatePickerProps | RangeDatePickerProps;
50
+
51
+ const LABEL_CLASS = "text-foreground text-sm font-medium";
52
+ const REQUIRED_CLASS = "after:text-destructive-text after:ml-0.5 after:content-['*']";
53
+ const SUBLABEL_CLASS = "text-muted-foreground text-xs font-medium";
54
+ const FIELD_CLASS =
55
+ "bg-background text-foreground border-input h-10 w-full min-w-0 appearance-none border px-3 py-1 text-sm transition-colors " +
56
+ "[&::-webkit-calendar-picker-indicator]:appearance-none [&::-webkit-calendar-picker-indicator]:opacity-0 " +
57
+ "placeholder:text-muted-foreground " +
58
+ "focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none " +
59
+ "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 " +
60
+ "aria-invalid:ring-destructive/20 aria-invalid:border-destructive";
61
+
62
+ /**
63
+ * DatePickerIcon — the brand calendar glyph. The browser's own picker
64
+ * indicator is hidden, so this is the visible affordance: clicking it calls
65
+ * the input's `showPicker()` (falling back to focus, which opens the native
66
+ * picker on iOS). It is not a tab stop because the input itself is the
67
+ * focusable control and opens the picker on focus.
68
+ */
69
+ function DatePickerIcon({
70
+ inputRef,
71
+ disabled,
72
+ ariaLabel,
73
+ }: {
74
+ inputRef: RefObject<HTMLInputElement | null>;
75
+ disabled?: boolean;
76
+ ariaLabel: string;
77
+ }) {
78
+ return (
79
+ <button
80
+ type="button"
81
+ tabIndex={-1}
82
+ aria-label={ariaLabel}
83
+ disabled={disabled}
84
+ onClick={() => {
85
+ const input = inputRef.current;
86
+ if (!input) return;
87
+ try {
88
+ input.showPicker?.();
89
+ } catch {
90
+ input.focus();
91
+ }
92
+ }}
93
+ className="border-border bg-background text-foreground flex size-10 shrink-0 items-center justify-center rounded-l-xl border"
94
+ >
95
+ <CalendarIcon className="size-4" aria-hidden />
96
+ </button>
97
+ );
98
+ }
99
+
100
+ function SingleDatePicker({
101
+ id,
102
+ name,
34
103
  label,
35
- dateFormat = "MMM d, yyyy",
36
- showLabel = true,
37
- containerClassName = "flex-1 min-w-0",
38
- popoverAlign = "start",
39
- variant = "outline",
40
- captionLayout = "label",
41
- }: DatePickerProps) {
104
+ helpText,
105
+ error,
106
+ className,
107
+ disabled,
108
+ required,
109
+ min,
110
+ max,
111
+ value: controlledValue,
112
+ defaultValue,
113
+ onValueChange,
114
+ }: SingleDatePickerProps) {
115
+ const generatedId = useId();
116
+ const inputId = id ?? generatedId;
117
+ const descriptionId = `${inputId}-description`;
118
+ const errorId = `${inputId}-error`;
119
+ const inputRef = useRef<HTMLInputElement>(null);
120
+ const isControlled = controlledValue !== undefined;
121
+ const [internalValue, setInternalValue] = useState<string>(() =>
122
+ formatDateInput(isControlled ? controlledValue : defaultValue),
123
+ );
124
+ const inputValue = (isControlled ? formatDateInput(controlledValue) : internalValue) || "";
125
+
126
+ function handleChange(event: ChangeEvent<HTMLInputElement>) {
127
+ const next = event.target.value || null;
128
+ if (!isControlled) setInternalValue(next ?? "");
129
+ onValueChange?.(next);
130
+ }
131
+
42
132
  return (
43
- <div className={containerClassName}>
44
- {showLabel && label ? (
45
- <label htmlFor={id} className="text-foreground mb-2 block text-sm font-medium">
133
+ <div className={cn("flex flex-col gap-1.5", className)}>
134
+ {label ? (
135
+ <label htmlFor={inputId} className={cn(LABEL_CLASS, required && REQUIRED_CLASS)}>
46
136
  {label}
47
137
  </label>
48
138
  ) : null}
49
- <Popover>
50
- <div className="flex w-full items-center">
51
- <div className="border-border bg-background text-foreground flex size-10 shrink-0 items-center justify-center rounded-l-xl border">
52
- <CalendarIcon className="size-4" aria-hidden />
139
+ <div className="flex w-full items-center">
140
+ <DatePickerIcon inputRef={inputRef} disabled={disabled} ariaLabel="Open date picker" />
141
+ <input
142
+ ref={inputRef}
143
+ id={inputId}
144
+ name={name}
145
+ type="date"
146
+ value={isControlled ? inputValue : undefined}
147
+ defaultValue={isControlled ? undefined : inputValue}
148
+ onChange={handleChange}
149
+ disabled={disabled}
150
+ required={required}
151
+ min={min}
152
+ max={max}
153
+ aria-invalid={error ? true : undefined}
154
+ aria-describedby={error ? errorId : helpText ? descriptionId : undefined}
155
+ className={cn(FIELD_CLASS, "rounded-l-none rounded-r-xl border-l-0")}
156
+ />
157
+ </div>
158
+ {error ? (
159
+ <p id={errorId} role="alert" className="text-destructive-text text-sm">
160
+ {error}
161
+ </p>
162
+ ) : helpText ? (
163
+ <p id={descriptionId} className="text-muted-foreground text-sm">
164
+ {helpText}
165
+ </p>
166
+ ) : null}
167
+ </div>
168
+ );
169
+ }
170
+
171
+ function RangeDatePicker({
172
+ id,
173
+ label,
174
+ helpText,
175
+ error,
176
+ className,
177
+ disabled,
178
+ required,
179
+ min,
180
+ max,
181
+ startName,
182
+ endName,
183
+ value: controlledValue,
184
+ defaultValue,
185
+ onValueChange,
186
+ fromLabel = "From",
187
+ toLabel = "To",
188
+ }: RangeDatePickerProps) {
189
+ const generatedId = useId();
190
+ const startId = id ?? generatedId;
191
+ const endId = `${startId}-to`;
192
+ const descriptionId = `${startId}-description`;
193
+ const errorId = `${startId}-error`;
194
+ const startRef = useRef<HTMLInputElement>(null);
195
+ const endRef = useRef<HTMLInputElement>(null);
196
+ const isControlled = controlledValue !== undefined;
197
+ const [internal, setInternal] = useState<{ from: string; to: string }>(() => {
198
+ const initial = (isControlled ? controlledValue : defaultValue) as DateRangeValue | undefined;
199
+ return { from: formatDateInput(initial?.from), to: formatDateInput(initial?.to) };
200
+ });
201
+ const from = (isControlled ? formatDateInput(controlledValue?.from) : internal.from) || "";
202
+ const to = (isControlled ? formatDateInput(controlledValue?.to) : internal.to) || "";
203
+
204
+ function handleChange(which: "from" | "to") {
205
+ return (event: ChangeEvent<HTMLInputElement>) => {
206
+ const nextValue = event.target.value || null;
207
+ const next = which === "from" ? { from: nextValue, to } : { from, to: nextValue };
208
+ if (!isControlled) setInternal({ from: next.from ?? "", to: next.to ?? "" });
209
+ onValueChange?.({ from: next.from, to: next.to });
210
+ };
211
+ }
212
+
213
+ return (
214
+ <div className={cn("flex flex-col gap-1.5", className)}>
215
+ {label ? <span className={cn(LABEL_CLASS, required && REQUIRED_CLASS)}>{label}</span> : null}
216
+ <div className="grid gap-3 sm:grid-cols-2">
217
+ <div className="flex min-w-0 flex-col gap-1">
218
+ <label htmlFor={startId} className={SUBLABEL_CLASS}>
219
+ {fromLabel}
220
+ </label>
221
+ <div className="flex w-full items-center">
222
+ <DatePickerIcon inputRef={startRef} disabled={disabled} ariaLabel={fromLabel} />
223
+ <input
224
+ ref={startRef}
225
+ id={startId}
226
+ name={startName}
227
+ type="date"
228
+ value={isControlled ? from : undefined}
229
+ defaultValue={isControlled ? undefined : from}
230
+ onChange={handleChange("from")}
231
+ disabled={disabled}
232
+ required={required}
233
+ min={min}
234
+ max={to || max || undefined}
235
+ aria-invalid={error ? true : undefined}
236
+ aria-describedby={error ? errorId : helpText ? descriptionId : undefined}
237
+ className={cn(FIELD_CLASS, "rounded-l-none rounded-r-xl border-l-0")}
238
+ />
53
239
  </div>
54
- <PopoverTrigger asChild>
55
- <Button
56
- id={id}
57
- variant={variant}
240
+ </div>
241
+ <div className="flex min-w-0 flex-col gap-1">
242
+ <label htmlFor={endId} className={SUBLABEL_CLASS}>
243
+ {toLabel}
244
+ </label>
245
+ <div className="flex w-full items-center">
246
+ <DatePickerIcon inputRef={endRef} disabled={disabled} ariaLabel={toLabel} />
247
+ <input
248
+ ref={endRef}
249
+ id={endId}
250
+ name={endName}
251
+ type="date"
252
+ value={isControlled ? to : undefined}
253
+ defaultValue={isControlled ? undefined : to}
254
+ onChange={handleChange("to")}
58
255
  disabled={disabled}
59
- className="border-border bg-background text-foreground placeholder:text-muted-foreground min-h-10 max-h-10 w-full justify-start rounded-l-none rounded-r-xl border-l-0 text-left font-normal"
60
- >
61
- {value ? format(value, dateFormat) : <span>{placeholder}</span>}
62
- </Button>
63
- </PopoverTrigger>
256
+ required={required}
257
+ min={from || min || undefined}
258
+ max={max}
259
+ aria-invalid={error ? true : undefined}
260
+ aria-describedby={error ? errorId : helpText ? descriptionId : undefined}
261
+ className={cn(FIELD_CLASS, "rounded-l-none rounded-r-xl border-l-0")}
262
+ />
263
+ </div>
64
264
  </div>
65
- <PopoverContent
66
- className="bg-popover w-auto p-0"
67
- align={popoverAlign}
68
- aria-label={label ?? "Choose a date"}
69
- >
70
- <Calendar
71
- mode="single"
72
- selected={value}
73
- defaultMonth={value}
74
- onSelect={onSelect}
75
- captionLayout={captionLayout}
76
- />
77
- </PopoverContent>
78
- </Popover>
265
+ </div>
266
+ {error ? (
267
+ <p id={errorId} role="alert" className="text-destructive-text text-sm">
268
+ {error}
269
+ </p>
270
+ ) : helpText ? (
271
+ <p id={descriptionId} className="text-muted-foreground text-sm">
272
+ {helpText}
273
+ </p>
274
+ ) : null}
79
275
  </div>
80
276
  );
81
277
  }
278
+
279
+ /**
280
+ * DatePicker — native date inputs styled to the design system. The browser
281
+ * owns the picker UI (calendar on desktop, wheel on iOS, etc.) so there is no
282
+ * popover, calendar grid, or positioning layer in the bundle. Both a single
283
+ * date (`name`) and a range (`startName` + `endName`) are supported; the
284
+ * native inputs submit "YYYY-MM-DD" local date strings directly.
285
+ *
286
+ * @example
287
+ * <DatePicker name="startDate" defaultValue="2020-01-15" label="Start date" />
288
+ * <DatePicker
289
+ * mode="range"
290
+ * startName="startDate"
291
+ * endName="endDate"
292
+ * defaultValue={{ from: "2020-01-15", to: "2020-06-30" }}
293
+ * label="Active period"
294
+ * />
295
+ */
296
+ function DatePicker(props: DatePickerProps) {
297
+ if (props.mode === "range") {
298
+ return <RangeDatePicker {...props} />;
299
+ }
300
+ return <SingleDatePicker {...props} />;
301
+ }
302
+
303
+ export { DatePicker };
@@ -9,7 +9,12 @@ export function parseDateInput(value: Date | string | null | undefined): Date |
9
9
  if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value;
10
10
  const [year, month, day] = value.split("-").map(Number);
11
11
  if (!year || !month || !day) return undefined;
12
- const date = new Date(year, month - 1, day);
12
+ // `new Date(year, month, day)` treats years from 0 through 99 as
13
+ // 1900-based years. Native date inputs can temporarily expose those years
14
+ // while a user is editing the year segment, so construct the date first and
15
+ // set the full year explicitly to avoid turning `0002` into `1902`.
16
+ const date = new Date(0);
17
+ date.setFullYear(year, month - 1, day);
13
18
  return Number.isNaN(date.getTime()) ? undefined : date;
14
19
  }
15
20
 
@@ -20,7 +25,7 @@ export function parseDateInput(value: Date | string | null | undefined): Date |
20
25
  export function formatDateInput(value: Date | string | null | undefined): string {
21
26
  const date = parseDateInput(value);
22
27
  if (!date) return "";
23
- const year = date.getFullYear();
28
+ const year = String(date.getFullYear()).padStart(4, "0");
24
29
  const month = String(date.getMonth() + 1).padStart(2, "0");
25
30
  const day = String(date.getDate()).padStart(2, "0");
26
31
  return `${year}-${month}-${day}`;
@@ -1,3 +1,4 @@
1
+ import { FileIcon, UploadIcon, XIcon } from "lucide-react";
1
2
  import {
2
3
  useCallback,
3
4
  useId,
@@ -8,9 +9,9 @@ import {
8
9
  type KeyboardEvent,
9
10
  type ReactNode,
10
11
  } from "react";
11
- import { FileIcon, UploadIcon, XIcon } from "lucide-react";
12
12
 
13
13
  import { cn } from "../../lib/utils";
14
+ import { Progress } from "../feedback/progress";
14
15
  import { Button } from "../primitives/button";
15
16
 
16
17
  export type DropZoneStatus = "empty" | "armed" | "busy" | "failed";
@@ -73,7 +74,7 @@ export function DropZone({
73
74
  className,
74
75
  emptyLabel = (
75
76
  <>
76
- Drop a file here, or <span className="text-primary font-medium">browse</span>
77
+ Drop a file here, or <span className="text-accent-text font-medium">browse</span>
77
78
  </>
78
79
  ),
79
80
  emptyHint,
@@ -157,8 +158,8 @@ export function DropZone({
157
158
 
158
159
  const showDragging = dragging && isInteractive;
159
160
  const frameTone = cn(
160
- showDragging && "border-primary bg-primary/5",
161
- status === "failed" && !showDragging && "border-destructive/50 bg-destructive/5",
161
+ showDragging && "border-ring bg-primary/5",
162
+ status === "failed" && !showDragging && "border-destructive bg-destructive/5",
162
163
  status === "busy" && "border-border bg-muted/30",
163
164
  status !== "failed" && status !== "busy" && !showDragging && "border-border bg-card",
164
165
  );
@@ -204,7 +205,7 @@ export function DropZone({
204
205
  {showDragging ? (
205
206
  <>
206
207
  <UploadIcon
207
- className="text-primary size-8 motion-safe:transition-[transform,opacity] motion-safe:duration-200 motion-safe:ease-in-out motion-reduce:transition-none motion-safe:-translate-y-0.5 motion-reduce:translate-y-0"
208
+ className="text-accent-text size-8 motion-safe:transition-[transform,opacity] motion-safe:duration-200 motion-safe:ease-in-out motion-reduce:transition-none motion-safe:-translate-y-0.5 motion-reduce:translate-y-0"
208
209
  aria-hidden
209
210
  />
210
211
  <p className="body-3 text-foreground">{draggingLabel}</p>
@@ -240,20 +241,18 @@ export function DropZone({
240
241
  ) : null}
241
242
  {file ? <p className="body-4 truncate text-muted-foreground">{file.name}</p> : null}
242
243
  </div>
243
- <div
244
- className="bg-muted h-1.5 w-full overflow-hidden rounded-full"
245
- role="progressbar"
244
+ <Progress
245
+ className="h-1.5 opacity-80 motion-safe:animate-pulse"
246
246
  aria-label={typeof busyLabel === "string" ? busyLabel : "Processing"}
247
- >
248
- <div className="bg-primary h-full w-2/5 rounded-full opacity-80 motion-safe:animate-pulse" />
249
- </div>
247
+ value={50}
248
+ />
250
249
  </div>
251
250
  ) : null}
252
251
 
253
252
  {!showDragging && status === "failed" ? (
254
253
  <div className="w-full space-y-2">
255
254
  {file ? <p className="subheading-4 truncate text-foreground">{file.name}</p> : null}
256
- {error ? <p className="body-3 text-destructive">{error}</p> : null}
255
+ {error ? <p className="body-3 text-destructive-text">{error}</p> : null}
257
256
  {failedActions}
258
257
  </div>
259
258
  ) : null}
@@ -1,14 +1,10 @@
1
1
  export { FilterSelect } from "../filters/filter-select";
2
2
  export type { FilterSelectOption, FilterSelectProps } from "../filters/filter-select";
3
- export { Calendar } from "./calendar";
4
- export type { CalendarProps } from "./calendar";
5
- export { DateField } from "./date-field";
6
- export type { DateFieldProps } from "./date-field";
3
+ export { DatePicker } from "./date-picker";
4
+ export type { DatePickerProps, DateRangeValue } from "./date-picker";
7
5
  export { formatDateInput, parseDateInput, todayDateInput } from "./date-utils";
8
6
  export { DateMonthSelect, getCurrentMonthYear } from "./date-month-select";
9
7
  export type { DateMonthSelectProps } from "./date-month-select";
10
- export { DatePicker } from "./date-picker";
11
- export type { DatePickerProps } from "./date-picker";
12
8
  export { DropZone } from "./drop-zone";
13
9
  export type { DropZoneProps } from "./drop-zone";
14
10
  export { Input } from "./input";