@ponti-studios/ui 1.5.1 → 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.
- package/README.md +7 -11
- package/package.json +6 -31
- package/src/components/feedback/alert.tsx +8 -4
- package/src/components/feedback/index.ts +0 -4
- package/src/components/feedback/progress.stories.tsx +15 -0
- package/src/components/feedback/progress.tsx +14 -3
- package/src/components/feedback/update-guard.stories.tsx +4 -8
- package/src/components/feedback/update-guard.tsx +41 -272
- package/src/components/filters/filter-select.tsx +3 -5
- package/src/components/forms/date-picker.stories.tsx +158 -18
- package/src/components/forms/date-picker.tsx +286 -51
- package/src/components/forms/date-utils.ts +7 -2
- package/src/components/forms/drop-zone.tsx +11 -12
- package/src/components/forms/index.ts +2 -6
- package/src/components/forms/passkey-management.stories.tsx +8 -4
- package/src/components/forms/passkey-management.tsx +78 -42
- package/src/components/forms/radio-group.stories.tsx +3 -5
- package/src/components/forms/radio-group.tsx +2 -2
- package/src/components/forms/select.stories.tsx +16 -13
- package/src/components/forms/textarea.stories.tsx +1 -1
- package/src/components/layout/index.ts +2 -0
- package/src/components/layout/particle-background.stories.tsx +38 -0
- package/src/components/layout/particle-background.tsx +378 -0
- package/src/components/layout/scroll-area.stories.tsx +12 -4
- package/src/components/layout/scroll-area.tsx +41 -4
- package/src/components/navigation/tabs.stories.tsx +0 -21
- package/src/components/overlays/dialog.stories.tsx +38 -1
- package/src/components/overlays/dialog.tsx +53 -12
- package/src/components/overlays/index.ts +0 -11
- package/src/components/primitives/avatar-placeholder.svg +13 -0
- package/src/components/primitives/avatar.stories.tsx +2 -1
- package/src/components/primitives/badge.stories.tsx +13 -3
- package/src/components/primitives/badge.tsx +1 -1
- package/src/components/primitives/button.tsx +1 -1
- package/src/components/primitives/card.tsx +6 -2
- package/src/components/primitives/index.ts +0 -3
- package/src/index.ts +1 -3
- package/src/lib/index.ts +2 -3
- package/src/styles/animations.css +62 -0
- package/src/styles/index.css +27 -0
- package/src/styles/tokens/generated/dark.colors.generated.ts +15 -12
- package/src/styles/tokens/generated/dark.css +15 -12
- package/src/styles/tokens/generated/light.colors.generated.ts +14 -11
- package/src/styles/tokens/generated/light.css +14 -11
- package/src/styles/tokens/index.ts +0 -3
- package/src/styles/tokens/source/dark.tokens.json +24 -12
- package/src/styles/tokens/source/light.tokens.json +23 -11
- package/src/styles/tokens/typography.stories.tsx +157 -0
- package/src/components/feedback/file-upload-status.stories.tsx +0 -57
- package/src/components/feedback/file-upload-status.tsx +0 -160
- package/src/components/feedback/progress-bar.stories.tsx +0 -39
- package/src/components/feedback/progress-bar.tsx +0 -22
- package/src/components/forms/calendar.stories.tsx +0 -30
- package/src/components/forms/calendar.tsx +0 -70
- package/src/components/forms/date-field.stories.tsx +0 -164
- package/src/components/forms/date-field.tsx +0 -174
- package/src/components/overlays/alert-dialog.stories.tsx +0 -45
- package/src/components/overlays/alert-dialog.tsx +0 -116
- package/src/components/primitives/label.stories.tsx +0 -29
- package/src/components/primitives/label.tsx +0 -20
- package/src/components/primitives/status-badge.stories.tsx +0 -52
- package/src/components/primitives/status-badge.tsx +0 -33
- package/src/lib/create-memory-storage.ts +0 -24
- package/src/native/card.tsx +0 -36
- package/src/native/icon-button.tsx +0 -41
- package/src/native/index.ts +0 -38
- package/src/native/list-row.tsx +0 -67
- package/src/native/make-styles.ts +0 -32
- package/src/native/shadows.ts +0 -38
- package/src/native/text-field.tsx +0 -34
- package/src/native/uniwind.d.ts +0 -1
- package/src/native/use-theme-colors.ts +0 -19
- package/src/styles/tokens/foundations.stories.tsx +0 -109
- package/src/styles/tokens/generated/native.generated.ts +0 -266
- package/src/styles/tokens/normalize-native.ts +0 -88
- package/src/styles/tokens/source/native.tokens.json +0 -204
- 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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
25
|
+
name: "startDate",
|
|
26
|
+
defaultValue: "2020-01-15",
|
|
27
|
+
label: "Start date",
|
|
20
28
|
},
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
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
|
|
35
|
+
export const WithDateObjectDefault: Story = {
|
|
28
36
|
args: {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
label: "
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
43
|
-
|
|
70
|
+
name: "startDate",
|
|
71
|
+
defaultValue: "2020-01-15",
|
|
72
|
+
label: "Locked date",
|
|
44
73
|
disabled: true,
|
|
45
|
-
|
|
46
|
-
|
|
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,68 +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 {
|
|
5
|
-
import {
|
|
6
|
-
import { Button } from "../primitives/button";
|
|
5
|
+
import { cn } from "../../lib/utils";
|
|
6
|
+
import { formatDateInput } from "./date-utils";
|
|
7
7
|
|
|
8
|
-
export interface
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
export interface DateRangeValue {
|
|
9
|
+
from?: Date | string | null;
|
|
10
|
+
to?: Date | string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface DatePickerBaseProps {
|
|
14
14
|
label?: string;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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;
|
|
20
47
|
}
|
|
21
48
|
|
|
22
|
-
export
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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,
|
|
28
103
|
label,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
+
|
|
35
132
|
return (
|
|
36
|
-
<div className={
|
|
37
|
-
{
|
|
38
|
-
<label htmlFor={
|
|
133
|
+
<div className={cn("flex flex-col gap-1.5", className)}>
|
|
134
|
+
{label ? (
|
|
135
|
+
<label htmlFor={inputId} className={cn(LABEL_CLASS, required && REQUIRED_CLASS)}>
|
|
39
136
|
{label}
|
|
40
137
|
</label>
|
|
41
138
|
) : null}
|
|
42
|
-
<
|
|
43
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
/>
|
|
46
239
|
</div>
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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")}
|
|
51
255
|
disabled={disabled}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
{
|
|
55
|
-
|
|
56
|
-
|
|
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>
|
|
57
264
|
</div>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
>
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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}
|
|
66
275
|
</div>
|
|
67
276
|
);
|
|
68
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
|
-
|
|
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-
|
|
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-
|
|
161
|
-
status === "failed" && !showDragging && "border-destructive
|
|
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-
|
|
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
|
-
<
|
|
244
|
-
className="
|
|
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
|
-
|
|
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 {
|
|
4
|
-
export type {
|
|
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";
|