@ponti-studios/ui 1.4.0 → 1.5.1
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/package.json +1 -1
- package/src/components/filters/filter-select.stories.tsx +3 -3
- package/src/components/filters/filter-select.tsx +5 -7
- package/src/components/forms/calendar.tsx +1 -1
- package/src/components/forms/date-field.stories.tsx +164 -0
- package/src/components/forms/date-field.tsx +174 -0
- package/src/components/forms/date-picker.tsx +6 -2
- package/src/components/forms/date-utils.ts +32 -0
- package/src/components/forms/index.ts +3 -0
- package/src/components/forms/select.stories.tsx +116 -0
- package/src/components/forms/select.tsx +36 -12
- package/src/components/navigation/app-navigation.stories.tsx +42 -0
- package/src/styles/nokia.css +1 -3
package/package.json
CHANGED
|
@@ -25,7 +25,7 @@ export default meta;
|
|
|
25
25
|
type Story = StoryObj<typeof meta>;
|
|
26
26
|
|
|
27
27
|
export const Default: Story = {
|
|
28
|
-
args: { value:
|
|
28
|
+
args: { value: null },
|
|
29
29
|
play: async ({ canvasElement }) => {
|
|
30
30
|
const canvas = within(canvasElement);
|
|
31
31
|
const trigger = canvas.getByRole("combobox", { name: "Status" });
|
|
@@ -44,7 +44,7 @@ export const WithValue: Story = {
|
|
|
44
44
|
};
|
|
45
45
|
|
|
46
46
|
export const Loading: Story = {
|
|
47
|
-
args: { value:
|
|
47
|
+
args: { value: null, isLoading: true, options: [] },
|
|
48
48
|
play: async ({ canvasElement }) => {
|
|
49
49
|
const canvas = within(canvasElement);
|
|
50
50
|
await expect(canvas.getByRole("combobox", { name: "Status" })).toBeInTheDocument();
|
|
@@ -53,7 +53,7 @@ export const Loading: Story = {
|
|
|
53
53
|
};
|
|
54
54
|
|
|
55
55
|
export const Empty: Story = {
|
|
56
|
-
args: { value:
|
|
56
|
+
args: { value: null, options: [], emptyLabel: "No statuses found" },
|
|
57
57
|
play: async ({ canvasElement }) => {
|
|
58
58
|
const canvas = within(canvasElement);
|
|
59
59
|
await expect(canvas.getByRole("combobox", { name: "Status" })).toBeInTheDocument();
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useId, useMemo } from "react";
|
|
2
2
|
|
|
3
3
|
import { cn } from "../../lib/utils";
|
|
4
|
-
import { Label } from "../primitives/label";
|
|
5
4
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../forms/select";
|
|
5
|
+
import { Label } from "../primitives/label";
|
|
6
6
|
|
|
7
7
|
export interface FilterSelectOption {
|
|
8
8
|
value: string;
|
|
@@ -10,8 +10,8 @@ export interface FilterSelectOption {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export interface FilterSelectProps {
|
|
13
|
-
value: string;
|
|
14
|
-
onChange: (value: string) => void;
|
|
13
|
+
value: string | null;
|
|
14
|
+
onChange: (value: string | null) => void;
|
|
15
15
|
options: FilterSelectOption[];
|
|
16
16
|
label: string;
|
|
17
17
|
loadingText?: string;
|
|
@@ -23,8 +23,6 @@ export interface FilterSelectProps {
|
|
|
23
23
|
id?: string;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const ALL_VALUE = "";
|
|
27
|
-
|
|
28
26
|
export function FilterSelect({
|
|
29
27
|
label,
|
|
30
28
|
value,
|
|
@@ -41,7 +39,7 @@ export function FilterSelect({
|
|
|
41
39
|
const generatedId = useId();
|
|
42
40
|
const selectId = id ?? generatedId;
|
|
43
41
|
const items = useMemo(
|
|
44
|
-
() => [{ value:
|
|
42
|
+
() => [{ value: null, label: placeholder }, ...options],
|
|
45
43
|
[options, placeholder],
|
|
46
44
|
);
|
|
47
45
|
|
|
@@ -53,7 +51,7 @@ export function FilterSelect({
|
|
|
53
51
|
<SelectValue placeholder={placeholder} />
|
|
54
52
|
</SelectTrigger>
|
|
55
53
|
<SelectContent className="bg-background max-h-[250px] min-w-36 overflow-y-auto">
|
|
56
|
-
<SelectItem
|
|
54
|
+
<SelectItem>{placeholder}</SelectItem>
|
|
57
55
|
{isLoading ? (
|
|
58
56
|
<div className="text-muted-foreground px-2 py-1.5 text-sm">{loadingText}</div>
|
|
59
57
|
) : options.length === 0 ? (
|
|
@@ -17,7 +17,7 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
|
|
|
17
17
|
month: "flex flex-col gap-4",
|
|
18
18
|
month_caption: "relative flex h-8 items-center justify-center",
|
|
19
19
|
caption_label: "text-sm font-medium",
|
|
20
|
-
nav: "absolute inset-x-4 top-4 flex items-center justify-between",
|
|
20
|
+
nav: "absolute inset-x-4 top-4 z-10 flex items-center justify-between",
|
|
21
21
|
button_previous:
|
|
22
22
|
"inline-flex size-8 p-2 items-center justify-center rounded-full border bg-background text-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
|
23
23
|
button_next:
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, screen, userEvent, within } from "storybook/test";
|
|
3
|
+
|
|
4
|
+
import { DateField } from "./date-field";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Forms/DateField",
|
|
8
|
+
component: DateField,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
} satisfies Meta<typeof DateField>;
|
|
11
|
+
|
|
12
|
+
export default meta;
|
|
13
|
+
type Story = StoryObj<typeof meta>;
|
|
14
|
+
|
|
15
|
+
function hiddenValue(canvasElement: HTMLElement, name: string): string | null {
|
|
16
|
+
const input = canvasElement.querySelector(`input[name="${name}"]`) as HTMLInputElement | null;
|
|
17
|
+
return input?.value ?? null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function nextMonth(dialog: HTMLElement) {
|
|
21
|
+
await userEvent.click(within(dialog).getByRole("button", { name: "Go to the Next Month" }));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The trigger button's accessible name comes from its <label> (htmlFor), so
|
|
25
|
+
// stories query the button by label and assert the visible date text separately.
|
|
26
|
+
export const WithStringDefaultValue: Story = {
|
|
27
|
+
args: {
|
|
28
|
+
name: "startDate",
|
|
29
|
+
defaultValue: "2020-01-15",
|
|
30
|
+
label: "Start date",
|
|
31
|
+
},
|
|
32
|
+
play: async ({ canvasElement }) => {
|
|
33
|
+
const canvas = within(canvasElement);
|
|
34
|
+
|
|
35
|
+
// The hidden input carries the local "YYYY-MM-DD" value, and the trigger
|
|
36
|
+
// shows the formatted date.
|
|
37
|
+
await expect(hiddenValue(canvasElement, "startDate")).toBe("2020-01-15");
|
|
38
|
+
const trigger = canvas.getByRole("button", { name: "Start date" });
|
|
39
|
+
await expect(trigger).toHaveTextContent("Jan 15, 2020");
|
|
40
|
+
|
|
41
|
+
// The calendar opens on the selected month, not the current one.
|
|
42
|
+
await userEvent.click(trigger);
|
|
43
|
+
const dialog = await screen.findByRole("dialog");
|
|
44
|
+
await expect(within(dialog).getByText("January 2020")).toBeInTheDocument();
|
|
45
|
+
|
|
46
|
+
// Real click on the Next chevron navigates the month.
|
|
47
|
+
await nextMonth(dialog);
|
|
48
|
+
await expect(within(dialog).getByText("February 2020")).toBeInTheDocument();
|
|
49
|
+
|
|
50
|
+
// Picking a day updates the trigger and the hidden input.
|
|
51
|
+
await userEvent.click(within(dialog).getByText("15"));
|
|
52
|
+
await expect(canvas.getByRole("button", { name: "Start date" })).toHaveTextContent(
|
|
53
|
+
"Feb 15, 2020",
|
|
54
|
+
);
|
|
55
|
+
await expect(hiddenValue(canvasElement, "startDate")).toBe("2020-02-15");
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const WithDateDefaultValue: Story = {
|
|
60
|
+
args: {
|
|
61
|
+
name: "endDate",
|
|
62
|
+
defaultValue: new Date(2020, 0, 15),
|
|
63
|
+
label: "End date",
|
|
64
|
+
},
|
|
65
|
+
play: async ({ canvasElement }) => {
|
|
66
|
+
const canvas = within(canvasElement);
|
|
67
|
+
|
|
68
|
+
await expect(hiddenValue(canvasElement, "endDate")).toBe("2020-01-15");
|
|
69
|
+
await expect(canvas.getByRole("button", { name: "End date" })).toHaveTextContent(
|
|
70
|
+
"Jan 15, 2020",
|
|
71
|
+
);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const Empty: Story = {
|
|
76
|
+
args: {
|
|
77
|
+
name: "startDate",
|
|
78
|
+
label: "Start date",
|
|
79
|
+
},
|
|
80
|
+
play: async ({ canvasElement }) => {
|
|
81
|
+
const canvas = within(canvasElement);
|
|
82
|
+
|
|
83
|
+
await expect(hiddenValue(canvasElement, "startDate")).toBe("");
|
|
84
|
+
await expect(canvas.getByRole("button", { name: "Start date" })).toHaveTextContent(
|
|
85
|
+
"Pick a date",
|
|
86
|
+
);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const Disabled: Story = {
|
|
91
|
+
args: {
|
|
92
|
+
name: "startDate",
|
|
93
|
+
defaultValue: "2020-01-15",
|
|
94
|
+
label: "Start date",
|
|
95
|
+
disabled: true,
|
|
96
|
+
},
|
|
97
|
+
play: async ({ canvasElement }) => {
|
|
98
|
+
const canvas = within(canvasElement);
|
|
99
|
+
|
|
100
|
+
await expect(canvas.getByRole("button", { name: "Start date" })).toBeDisabled();
|
|
101
|
+
await expect(hiddenValue(canvasElement, "startDate")).toBe("2020-01-15");
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const WithRangeDefaultValue: Story = {
|
|
106
|
+
args: {
|
|
107
|
+
mode: "range",
|
|
108
|
+
startName: "startDate",
|
|
109
|
+
endName: "endDate",
|
|
110
|
+
defaultValue: { from: "2020-01-15", to: "2020-06-30" },
|
|
111
|
+
label: "Active period",
|
|
112
|
+
},
|
|
113
|
+
play: async ({ canvasElement }) => {
|
|
114
|
+
const canvas = within(canvasElement);
|
|
115
|
+
|
|
116
|
+
// Both hidden inputs carry local date strings; the trigger shows the range.
|
|
117
|
+
await expect(hiddenValue(canvasElement, "startDate")).toBe("2020-01-15");
|
|
118
|
+
await expect(hiddenValue(canvasElement, "endDate")).toBe("2020-06-30");
|
|
119
|
+
const trigger = canvas.getByRole("button", { name: "Active period" });
|
|
120
|
+
await expect(trigger).toHaveTextContent("Jan 15, 2020");
|
|
121
|
+
await expect(trigger).toHaveTextContent("Jun 30, 2020");
|
|
122
|
+
|
|
123
|
+
// The range calendar opens on the start month.
|
|
124
|
+
await userEvent.click(trigger);
|
|
125
|
+
const dialog = await screen.findByRole("dialog");
|
|
126
|
+
await expect(within(dialog).getByText("January 2020")).toBeInTheDocument();
|
|
127
|
+
|
|
128
|
+
// Real-click the Next chevron, then pick a new from date.
|
|
129
|
+
await nextMonth(dialog);
|
|
130
|
+
await expect(within(dialog).getByText("February 2020")).toBeInTheDocument();
|
|
131
|
+
await userEvent.click(within(dialog).getByText("20"));
|
|
132
|
+
await expect(canvas.getByRole("button", { name: "Active period" })).toHaveTextContent(
|
|
133
|
+
"From Feb 20, 2020",
|
|
134
|
+
);
|
|
135
|
+
await expect(hiddenValue(canvasElement, "startDate")).toBe("2020-02-20");
|
|
136
|
+
await expect(hiddenValue(canvasElement, "endDate")).toBe("");
|
|
137
|
+
|
|
138
|
+
// Picking an end date fills the second hidden input.
|
|
139
|
+
await userEvent.click(within(dialog).getByText("25"));
|
|
140
|
+
await expect(canvas.getByRole("button", { name: "Active period" })).toHaveTextContent(
|
|
141
|
+
"Feb 20, 2020 – Feb 25, 2020",
|
|
142
|
+
);
|
|
143
|
+
await expect(hiddenValue(canvasElement, "startDate")).toBe("2020-02-20");
|
|
144
|
+
await expect(hiddenValue(canvasElement, "endDate")).toBe("2020-02-25");
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const EmptyRange: Story = {
|
|
149
|
+
args: {
|
|
150
|
+
mode: "range",
|
|
151
|
+
startName: "startDate",
|
|
152
|
+
endName: "endDate",
|
|
153
|
+
label: "Active period",
|
|
154
|
+
},
|
|
155
|
+
play: async ({ canvasElement }) => {
|
|
156
|
+
const canvas = within(canvasElement);
|
|
157
|
+
|
|
158
|
+
await expect(hiddenValue(canvasElement, "startDate")).toBe("");
|
|
159
|
+
await expect(hiddenValue(canvasElement, "endDate")).toBe("");
|
|
160
|
+
await expect(canvas.getByRole("button", { name: "Active period" })).toHaveTextContent(
|
|
161
|
+
"Pick a date range",
|
|
162
|
+
);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { format } from "date-fns";
|
|
2
|
+
import { CalendarIcon } from "lucide-react";
|
|
3
|
+
import { useId, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { Popover, PopoverContent, PopoverTrigger } from "../overlays/popover";
|
|
6
|
+
import { Button } from "../primitives/button";
|
|
7
|
+
import { Calendar } from "./calendar";
|
|
8
|
+
import { formatDateInput, parseDateInput } from "./date-utils";
|
|
9
|
+
import { DatePicker, type DatePickerProps } from "./date-picker";
|
|
10
|
+
|
|
11
|
+
export interface DateRangeValue {
|
|
12
|
+
from?: Date | string | null;
|
|
13
|
+
to?: Date | string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface DateFieldCommonProps {
|
|
17
|
+
id?: string;
|
|
18
|
+
label?: string;
|
|
19
|
+
showLabel?: boolean;
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
dateFormat?: string;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
containerClassName?: string;
|
|
24
|
+
popoverAlign?: DatePickerProps["popoverAlign"];
|
|
25
|
+
variant?: DatePickerProps["variant"];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SingleDateFieldProps extends DateFieldCommonProps {
|
|
29
|
+
mode?: "single";
|
|
30
|
+
name: string;
|
|
31
|
+
defaultValue?: Date | string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RangeDateFieldProps extends DateFieldCommonProps {
|
|
35
|
+
mode: "range";
|
|
36
|
+
startName: string;
|
|
37
|
+
endName: string;
|
|
38
|
+
defaultValue?: DateRangeValue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type DateFieldProps = SingleDateFieldProps | RangeDateFieldProps;
|
|
42
|
+
|
|
43
|
+
const TRIGGER_CLASSES =
|
|
44
|
+
"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";
|
|
45
|
+
|
|
46
|
+
function SingleDateField({
|
|
47
|
+
name,
|
|
48
|
+
defaultValue,
|
|
49
|
+
id,
|
|
50
|
+
label,
|
|
51
|
+
showLabel,
|
|
52
|
+
placeholder,
|
|
53
|
+
dateFormat,
|
|
54
|
+
disabled,
|
|
55
|
+
containerClassName,
|
|
56
|
+
popoverAlign,
|
|
57
|
+
variant,
|
|
58
|
+
}: SingleDateFieldProps) {
|
|
59
|
+
const generatedId = useId();
|
|
60
|
+
const inputId = id ?? generatedId;
|
|
61
|
+
const [value, setValue] = useState<Date | undefined>(() => parseDateInput(defaultValue));
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div>
|
|
65
|
+
<DatePicker
|
|
66
|
+
id={inputId}
|
|
67
|
+
label={label}
|
|
68
|
+
showLabel={showLabel}
|
|
69
|
+
placeholder={placeholder}
|
|
70
|
+
dateFormat={dateFormat}
|
|
71
|
+
disabled={disabled}
|
|
72
|
+
containerClassName={containerClassName}
|
|
73
|
+
popoverAlign={popoverAlign}
|
|
74
|
+
variant={variant}
|
|
75
|
+
value={value}
|
|
76
|
+
onSelect={setValue}
|
|
77
|
+
/>
|
|
78
|
+
<input type="hidden" name={name} value={value ? formatDateInput(value) : ""} />
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function RangeDateField({
|
|
84
|
+
startName,
|
|
85
|
+
endName,
|
|
86
|
+
defaultValue,
|
|
87
|
+
id,
|
|
88
|
+
label,
|
|
89
|
+
showLabel = true,
|
|
90
|
+
placeholder = "Pick a date range",
|
|
91
|
+
dateFormat = "MMM d, yyyy",
|
|
92
|
+
disabled = false,
|
|
93
|
+
containerClassName = "flex-1 min-w-0",
|
|
94
|
+
popoverAlign = "start",
|
|
95
|
+
variant = "outline",
|
|
96
|
+
}: RangeDateFieldProps) {
|
|
97
|
+
const generatedId = useId();
|
|
98
|
+
const inputId = id ?? generatedId;
|
|
99
|
+
const [range, setRange] = useState<{ from?: Date; to?: Date }>(() => ({
|
|
100
|
+
from: parseDateInput(defaultValue?.from),
|
|
101
|
+
to: parseDateInput(defaultValue?.to),
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
const fromLabel = range.from ? format(range.from, dateFormat) : null;
|
|
105
|
+
const toLabel = range.to ? format(range.to, dateFormat) : null;
|
|
106
|
+
const triggerLabel = fromLabel
|
|
107
|
+
? toLabel
|
|
108
|
+
? `${fromLabel} – ${toLabel}`
|
|
109
|
+
: `From ${fromLabel}`
|
|
110
|
+
: toLabel
|
|
111
|
+
? `Until ${toLabel}`
|
|
112
|
+
: placeholder;
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div className={containerClassName}>
|
|
116
|
+
{showLabel && label ? (
|
|
117
|
+
<label htmlFor={inputId} className="text-foreground mb-2 block text-sm font-medium">
|
|
118
|
+
{label}
|
|
119
|
+
</label>
|
|
120
|
+
) : null}
|
|
121
|
+
<Popover>
|
|
122
|
+
<div className="flex w-full items-center">
|
|
123
|
+
<div className="border-border bg-background text-foreground flex size-10 shrink-0 items-center justify-center rounded-l-xl border">
|
|
124
|
+
<CalendarIcon className="size-4" aria-hidden />
|
|
125
|
+
</div>
|
|
126
|
+
<PopoverTrigger asChild>
|
|
127
|
+
<Button id={inputId} variant={variant} disabled={disabled} className={TRIGGER_CLASSES}>
|
|
128
|
+
{triggerLabel}
|
|
129
|
+
</Button>
|
|
130
|
+
</PopoverTrigger>
|
|
131
|
+
</div>
|
|
132
|
+
<PopoverContent
|
|
133
|
+
className="bg-popover w-auto p-0"
|
|
134
|
+
align={popoverAlign}
|
|
135
|
+
aria-label={label ?? "Choose a date range"}
|
|
136
|
+
>
|
|
137
|
+
<Calendar
|
|
138
|
+
mode="range"
|
|
139
|
+
selected={range.from ? { from: range.from, to: range.to } : undefined}
|
|
140
|
+
defaultMonth={range.from ?? range.to}
|
|
141
|
+
resetOnSelect
|
|
142
|
+
onSelect={(next) => setRange({ from: next?.from, to: next?.to })}
|
|
143
|
+
/>
|
|
144
|
+
</PopoverContent>
|
|
145
|
+
</Popover>
|
|
146
|
+
<input type="hidden" name={startName} value={range.from ? formatDateInput(range.from) : ""} />
|
|
147
|
+
<input type="hidden" name={endName} value={range.to ? formatDateInput(range.to) : ""} />
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* DateField — calendar picker backed by hidden inputs that submit "YYYY-MM-DD"
|
|
154
|
+
* local date strings, so forms never receive UTC-shifted dates. Supports both
|
|
155
|
+
* a single date (`name`) and a range (`startName` + `endName`).
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* <DateField name="startDate" defaultValue="2020-01-15" label="Start date" />
|
|
159
|
+
* <DateField
|
|
160
|
+
* mode="range"
|
|
161
|
+
* startName="startDate"
|
|
162
|
+
* endName="endDate"
|
|
163
|
+
* defaultValue={{ from: "2020-01-15", to: "2020-06-30" }}
|
|
164
|
+
* label="Active period"
|
|
165
|
+
* />
|
|
166
|
+
*/
|
|
167
|
+
function DateField(props: DateFieldProps) {
|
|
168
|
+
if (props.mode === "range") {
|
|
169
|
+
return <RangeDateField {...props} />;
|
|
170
|
+
}
|
|
171
|
+
return <SingleDateField {...props} />;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export { DateField };
|
|
@@ -55,8 +55,12 @@ export function DatePicker({
|
|
|
55
55
|
</Button>
|
|
56
56
|
</PopoverTrigger>
|
|
57
57
|
</div>
|
|
58
|
-
<PopoverContent
|
|
59
|
-
|
|
58
|
+
<PopoverContent
|
|
59
|
+
className="bg-popover w-auto p-0"
|
|
60
|
+
align={popoverAlign}
|
|
61
|
+
aria-label={label ?? "Choose a date"}
|
|
62
|
+
>
|
|
63
|
+
<Calendar mode="single" selected={value} defaultMonth={value} onSelect={onSelect} />
|
|
60
64
|
</PopoverContent>
|
|
61
65
|
</Popover>
|
|
62
66
|
</div>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a date-only string ("YYYY-MM-DD") as a local calendar day instead of
|
|
3
|
+
* UTC midnight, so negative-UTC-offset timezones don't render the previous day.
|
|
4
|
+
* Accepts a `Date` (returned as-is when valid) and returns `undefined` for
|
|
5
|
+
* null/undefined/invalid input.
|
|
6
|
+
*/
|
|
7
|
+
export function parseDateInput(value: Date | string | null | undefined): Date | undefined {
|
|
8
|
+
if (!value) return undefined;
|
|
9
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value;
|
|
10
|
+
const [year, month, day] = value.split("-").map(Number);
|
|
11
|
+
if (!year || !month || !day) return undefined;
|
|
12
|
+
const date = new Date(year, month - 1, day);
|
|
13
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Serialize a date to its "YYYY-MM-DD" form using local components, so the
|
|
18
|
+
* value round-trips without a UTC shift. Returns "" for invalid input.
|
|
19
|
+
*/
|
|
20
|
+
export function formatDateInput(value: Date | string | null | undefined): string {
|
|
21
|
+
const date = parseDateInput(value);
|
|
22
|
+
if (!date) return "";
|
|
23
|
+
const year = date.getFullYear();
|
|
24
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
25
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
26
|
+
return `${year}-${month}-${day}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Today's date as a "YYYY-MM-DD" local string. */
|
|
30
|
+
export function todayDateInput(): string {
|
|
31
|
+
return formatDateInput(new Date());
|
|
32
|
+
}
|
|
@@ -2,6 +2,9 @@ export { FilterSelect } from "../filters/filter-select";
|
|
|
2
2
|
export type { FilterSelectOption, FilterSelectProps } from "../filters/filter-select";
|
|
3
3
|
export { Calendar } from "./calendar";
|
|
4
4
|
export type { CalendarProps } from "./calendar";
|
|
5
|
+
export { DateField } from "./date-field";
|
|
6
|
+
export type { DateFieldProps } from "./date-field";
|
|
7
|
+
export { formatDateInput, parseDateInput, todayDateInput } from "./date-utils";
|
|
5
8
|
export { DateMonthSelect, getCurrentMonthYear } from "./date-month-select";
|
|
6
9
|
export type { DateMonthSelectProps } from "./date-month-select";
|
|
7
10
|
export { DatePicker } from "./date-picker";
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
2
4
|
|
|
3
5
|
import {
|
|
4
6
|
Select,
|
|
@@ -36,6 +38,117 @@ export const Default: Story = {
|
|
|
36
38
|
),
|
|
37
39
|
};
|
|
38
40
|
|
|
41
|
+
export const SelectedValue: Story = {
|
|
42
|
+
render: () => (
|
|
43
|
+
<Select defaultValue="banana">
|
|
44
|
+
<SelectTrigger className="w-[180px]" aria-label="Selected fruit">
|
|
45
|
+
<SelectValue placeholder="Select a fruit" />
|
|
46
|
+
</SelectTrigger>
|
|
47
|
+
<SelectContent>
|
|
48
|
+
<SelectItem value="apple">Apple</SelectItem>
|
|
49
|
+
<SelectItem value="banana">Banana</SelectItem>
|
|
50
|
+
</SelectContent>
|
|
51
|
+
</Select>
|
|
52
|
+
),
|
|
53
|
+
play: async ({ canvasElement }) => {
|
|
54
|
+
await expect(within(canvasElement).getByRole("combobox", { name: "Selected fruit" })).toHaveTextContent(
|
|
55
|
+
"Banana",
|
|
56
|
+
);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const ExplicitItems: Story = {
|
|
61
|
+
render: () => (
|
|
62
|
+
<Select
|
|
63
|
+
defaultValue="us"
|
|
64
|
+
items={[
|
|
65
|
+
{ value: "us", label: "United States" },
|
|
66
|
+
{ value: "ca", label: "Canada" },
|
|
67
|
+
]}
|
|
68
|
+
>
|
|
69
|
+
<SelectTrigger aria-label="Country">
|
|
70
|
+
<SelectValue placeholder="Select a country" />
|
|
71
|
+
</SelectTrigger>
|
|
72
|
+
<SelectContent>
|
|
73
|
+
<SelectItem value="us">US</SelectItem>
|
|
74
|
+
<SelectItem value="ca">CA</SelectItem>
|
|
75
|
+
</SelectContent>
|
|
76
|
+
</Select>
|
|
77
|
+
),
|
|
78
|
+
play: async ({ canvasElement }) => {
|
|
79
|
+
await expect(within(canvasElement).getByRole("combobox", { name: "Country" })).toHaveTextContent(
|
|
80
|
+
"United States",
|
|
81
|
+
);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const NullItem: Story = {
|
|
86
|
+
render: () => (
|
|
87
|
+
<Select
|
|
88
|
+
defaultValue={null}
|
|
89
|
+
items={[{ value: null, label: "All options" }]}
|
|
90
|
+
>
|
|
91
|
+
<SelectTrigger aria-label="Filter">
|
|
92
|
+
<SelectValue placeholder="Choose a filter" />
|
|
93
|
+
</SelectTrigger>
|
|
94
|
+
<SelectContent>
|
|
95
|
+
<SelectItem>All options</SelectItem>
|
|
96
|
+
<SelectItem value="active">Active</SelectItem>
|
|
97
|
+
</SelectContent>
|
|
98
|
+
</Select>
|
|
99
|
+
),
|
|
100
|
+
play: async ({ canvasElement }) => {
|
|
101
|
+
await expect(within(canvasElement).getByRole("combobox", { name: "Filter" })).toHaveTextContent(
|
|
102
|
+
"All options",
|
|
103
|
+
);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function ControlledNullSelection() {
|
|
108
|
+
const [value, setValue] = React.useState<string | null>("active");
|
|
109
|
+
const [lastChange, setLastChange] = React.useState<string | null>("active");
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div>
|
|
113
|
+
<Select
|
|
114
|
+
value={value}
|
|
115
|
+
onValueChange={(nextValue) => {
|
|
116
|
+
setValue(nextValue);
|
|
117
|
+
setLastChange(nextValue);
|
|
118
|
+
}}
|
|
119
|
+
items={[
|
|
120
|
+
{ value: null, label: "All options" },
|
|
121
|
+
{ value: "active", label: "Active" },
|
|
122
|
+
]}
|
|
123
|
+
>
|
|
124
|
+
<SelectTrigger aria-label="Controlled filter">
|
|
125
|
+
<SelectValue placeholder="Choose a filter" />
|
|
126
|
+
</SelectTrigger>
|
|
127
|
+
<SelectContent>
|
|
128
|
+
<SelectItem>All options</SelectItem>
|
|
129
|
+
<SelectItem value="active">Active</SelectItem>
|
|
130
|
+
</SelectContent>
|
|
131
|
+
</Select>
|
|
132
|
+
<output aria-label="Last selected value">{lastChange ?? "null"}</output>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const ControlledNullChange: Story = {
|
|
138
|
+
render: () => <ControlledNullSelection />,
|
|
139
|
+
parameters: { a11y: { test: "off" } },
|
|
140
|
+
play: async ({ canvasElement }) => {
|
|
141
|
+
const canvas = within(canvasElement);
|
|
142
|
+
const trigger = canvas.getByRole("combobox", { name: "Controlled filter" });
|
|
143
|
+
|
|
144
|
+
await userEvent.click(trigger);
|
|
145
|
+
await userEvent.click(await within(document.body).findByRole("option", { name: "All options" }));
|
|
146
|
+
await expect(trigger).toHaveTextContent("All options");
|
|
147
|
+
await expect(canvas.getByRole("status", { name: "Last selected value" })).toHaveTextContent("null");
|
|
148
|
+
await userEvent.keyboard("{Escape}");
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
39
152
|
export const WithGroups: Story = {
|
|
40
153
|
render: () => (
|
|
41
154
|
<Select>
|
|
@@ -70,6 +183,9 @@ export const Disabled: Story = {
|
|
|
70
183
|
</SelectContent>
|
|
71
184
|
</Select>
|
|
72
185
|
),
|
|
186
|
+
play: async ({ canvasElement }) => {
|
|
187
|
+
await expect(within(canvasElement).getByRole("combobox", { name: "Select a fruit" })).toBeDisabled();
|
|
188
|
+
},
|
|
73
189
|
};
|
|
74
190
|
|
|
75
191
|
export const Small: Story = {
|
|
@@ -6,22 +6,19 @@ import { cn } from "../../lib/utils";
|
|
|
6
6
|
|
|
7
7
|
type SelectProps = Omit<
|
|
8
8
|
React.ComponentProps<typeof SelectPrimitive.Root<string>>,
|
|
9
|
-
"
|
|
9
|
+
"value" | "defaultValue"
|
|
10
10
|
> & {
|
|
11
|
-
value?: string;
|
|
12
|
-
defaultValue?: string;
|
|
13
|
-
onValueChange?: (value: string) => void;
|
|
11
|
+
value?: string | null;
|
|
12
|
+
defaultValue?: string | null;
|
|
14
13
|
};
|
|
15
14
|
|
|
16
|
-
function Select({
|
|
15
|
+
function Select({ children, items, ...props }: SelectProps) {
|
|
16
|
+
const resolvedItems = items ?? getItemsFromChildren(children);
|
|
17
|
+
|
|
17
18
|
return (
|
|
18
|
-
<SelectPrimitive.Root
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
onValueChange={(value) => {
|
|
22
|
-
if (value !== null) onValueChange?.(value);
|
|
23
|
-
}}
|
|
24
|
-
/>
|
|
19
|
+
<SelectPrimitive.Root data-slot="select" items={resolvedItems} {...props}>
|
|
20
|
+
{children}
|
|
21
|
+
</SelectPrimitive.Root>
|
|
25
22
|
);
|
|
26
23
|
}
|
|
27
24
|
|
|
@@ -129,6 +126,33 @@ function SelectItem({
|
|
|
129
126
|
);
|
|
130
127
|
}
|
|
131
128
|
|
|
129
|
+
function getItemsFromChildren(children: React.ReactNode): Record<string, React.ReactNode> {
|
|
130
|
+
const items: Record<string, React.ReactNode> = {};
|
|
131
|
+
|
|
132
|
+
React.Children.forEach(children, (child) => {
|
|
133
|
+
if (!React.isValidElement(child)) return;
|
|
134
|
+
|
|
135
|
+
const element = child as React.ReactElement<{
|
|
136
|
+
children?: React.ReactNode;
|
|
137
|
+
value?: unknown;
|
|
138
|
+
}>;
|
|
139
|
+
|
|
140
|
+
if (element.type === SelectItem) {
|
|
141
|
+
const value = element.props.value;
|
|
142
|
+
if (typeof value === "string") {
|
|
143
|
+
items[value] = element.props.children;
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (element.props.children) {
|
|
149
|
+
Object.assign(items, getItemsFromChildren(element.props.children));
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return items;
|
|
154
|
+
}
|
|
155
|
+
|
|
132
156
|
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<"div">) {
|
|
133
157
|
return (
|
|
134
158
|
<SelectPrimitive.ScrollUpArrow
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
2
|
import * as React from "react";
|
|
3
|
+
import { userEvent, within } from "storybook/test";
|
|
3
4
|
|
|
4
5
|
import { Button } from "../primitives/button";
|
|
5
6
|
import { Navigation } from "./app-navigation";
|
|
@@ -173,3 +174,44 @@ export const WithExtraContent: Story = {
|
|
|
173
174
|
</>
|
|
174
175
|
),
|
|
175
176
|
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* `@internal` — undocumented Nokia 3310-style LCD skin for the mobile Sheet
|
|
180
|
+
* menu, opted into via `variant="nokia"`. Only affects the mobile Sheet, so
|
|
181
|
+
* this story forces a mobile viewport and auto-opens the menu trigger.
|
|
182
|
+
*/
|
|
183
|
+
export const Nokia: Story = {
|
|
184
|
+
parameters: {
|
|
185
|
+
viewport: { defaultViewport: "mobile1" },
|
|
186
|
+
chromatic: { viewports: [375] },
|
|
187
|
+
},
|
|
188
|
+
render: () => (
|
|
189
|
+
<>
|
|
190
|
+
<Navigation variant="nokia">
|
|
191
|
+
<Navigation.Brand>
|
|
192
|
+
<a href="/">Acme</a>
|
|
193
|
+
</Navigation.Brand>
|
|
194
|
+
|
|
195
|
+
<Navigation.List>
|
|
196
|
+
{defaultLinks.map((link) => (
|
|
197
|
+
<Navigation.Item key={link.href} href={link.href} active={link.href === "/pricing"}>
|
|
198
|
+
{link.label}
|
|
199
|
+
</Navigation.Item>
|
|
200
|
+
))}
|
|
201
|
+
</Navigation.List>
|
|
202
|
+
|
|
203
|
+
<Navigation.Action>
|
|
204
|
+
<Button size="md" asChild>
|
|
205
|
+
<a href="/get-started">Get started</a>
|
|
206
|
+
</Button>
|
|
207
|
+
</Navigation.Action>
|
|
208
|
+
</Navigation>
|
|
209
|
+
<PageContent message="Open the menu button to see the Nokia 3310-style LCD skin." />
|
|
210
|
+
</>
|
|
211
|
+
),
|
|
212
|
+
play: async ({ canvasElement }) => {
|
|
213
|
+
const canvas = within(canvasElement);
|
|
214
|
+
const trigger = canvas.getByRole("button", { name: /open menu/i });
|
|
215
|
+
await userEvent.click(trigger);
|
|
216
|
+
},
|
|
217
|
+
};
|
package/src/styles/nokia.css
CHANGED
|
@@ -3,14 +3,12 @@
|
|
|
3
3
|
* skin for the mobile Sheet menu. Not part of the public API surface (no
|
|
4
4
|
* README/Storybook entry); opt in via `<Navigation variant="nokia">`.
|
|
5
5
|
*/
|
|
6
|
-
@import url("https://fonts.googleapis.com/css2?family=VT323&display=swap");
|
|
7
|
-
|
|
8
6
|
@theme {
|
|
9
7
|
--color-nokia-screen: #c8d6a6;
|
|
10
8
|
--color-nokia-screen-deep: #b0c188;
|
|
11
9
|
--color-nokia-ink: #26331b;
|
|
12
10
|
--color-nokia-bezel: #1a1a1a;
|
|
13
|
-
--font-nokia: "
|
|
11
|
+
--font-nokia: ui-monospace, "SF Mono", "Courier New", monospace;
|
|
14
12
|
}
|
|
15
13
|
|
|
16
14
|
/* No `position: relative` here — SheetContent is `position: fixed` via its
|