@tangle-network/ui 11.28.2 → 11.30.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/CHANGELOG.md +12 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +6 -2
- package/dist/lib/control-presentation.d.ts +10 -0
- package/dist/lib/control-presentation.js +1 -1
- package/dist/primitives/button.d.ts +1 -1
- package/dist/primitives/button.js +3 -2
- package/dist/primitives/calendar.d.ts +38 -0
- package/dist/primitives/calendar.js +186 -0
- package/dist/primitives/date-picker.d.ts +32 -0
- package/dist/primitives/date-picker.js +90 -0
- package/dist/primitives/radio-group.d.ts +13 -0
- package/dist/primitives/radio-group.js +28 -0
- package/dist/primitives/time-select.d.ts +27 -0
- package/dist/primitives/time-select.js +51 -0
- package/dist/primitives/toolbar.js +1 -1
- package/dist/primitives.d.ts +5 -1
- package/dist/primitives.js +6 -2
- package/package.json +3 -2
- package/src/lib/control-presentation.ts +3 -1
- package/src/primitives/button.tsx +9 -3
- package/src/primitives/calendar.tsx +242 -0
- package/src/primitives/control-presentation.test.tsx +2 -2
- package/src/primitives/date-picker.stories.tsx +55 -0
- package/src/primitives/date-picker.test.tsx +146 -0
- package/src/primitives/date-picker.tsx +126 -0
- package/src/primitives/index.ts +9 -0
- package/src/primitives/radio-group.test.tsx +26 -0
- package/src/primitives/radio-group.tsx +43 -0
- package/src/primitives/time-select.tsx +66 -0
- package/src/primitives/toolbar.tsx +3 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { controlHeight } from "../lib/control-presentation";
|
|
6
|
+
import { Button } from "./button";
|
|
7
|
+
import { Calendar, formatCalendarDate, parseCalendarDate } from "./calendar";
|
|
8
|
+
import { DatePicker } from "./date-picker";
|
|
9
|
+
import { Select, SelectTrigger, SelectValue } from "./select";
|
|
10
|
+
import { TimeSelect } from "./time-select";
|
|
11
|
+
import { FilterField, Toolbar } from "./toolbar";
|
|
12
|
+
|
|
13
|
+
afterEach(cleanup);
|
|
14
|
+
|
|
15
|
+
describe("calendar dates", () => {
|
|
16
|
+
it("round-trips YYYY-MM-DD in UTC and rejects impossible dates", () => {
|
|
17
|
+
const time = parseCalendarDate("2026-03-08");
|
|
18
|
+
expect(time).toBe(Date.UTC(2026, 2, 8));
|
|
19
|
+
expect(formatCalendarDate(time!)).toBe("2026-03-08");
|
|
20
|
+
expect(parseCalendarDate("2026-02-31")).toBeNull();
|
|
21
|
+
expect(parseCalendarDate("03/08/2026")).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("Calendar", () => {
|
|
26
|
+
it("renders six weeks, marks today and the selection, and picks a day", async () => {
|
|
27
|
+
const onChange = vi.fn();
|
|
28
|
+
render(<Calendar value="2026-10-08" today="2026-10-08" onChange={onChange} locale="en-US" />);
|
|
29
|
+
const grid = screen.getByRole("grid", { name: "October 2026" });
|
|
30
|
+
expect(within(grid).getAllByRole("gridcell")).toHaveLength(42);
|
|
31
|
+
const selected = screen.getByRole("button", { name: "Thursday, October 8, 2026" });
|
|
32
|
+
expect(selected).toHaveAttribute("aria-current", "date");
|
|
33
|
+
expect(selected.closest("[role=gridcell]")).toHaveAttribute("aria-selected", "true");
|
|
34
|
+
await userEvent.click(screen.getByRole("button", { name: "Friday, October 16, 2026" }));
|
|
35
|
+
expect(onChange).toHaveBeenCalledWith("2026-10-16");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("moves by day, week and month from the keyboard and picks with Enter", async () => {
|
|
39
|
+
const onChange = vi.fn();
|
|
40
|
+
render(<Calendar value="2026-10-31" onChange={onChange} locale="en-US" />);
|
|
41
|
+
const user = userEvent.setup();
|
|
42
|
+
screen.getByRole("button", { name: "Saturday, October 31, 2026" }).focus();
|
|
43
|
+
await user.keyboard("{ArrowRight}");
|
|
44
|
+
expect(screen.getByRole("grid", { name: "November 2026" })).toBeTruthy();
|
|
45
|
+
expect(document.activeElement).toHaveAccessibleName("Sunday, November 1, 2026");
|
|
46
|
+
await user.keyboard("{ArrowDown}{PageUp}");
|
|
47
|
+
expect(document.activeElement).toHaveAccessibleName("Thursday, October 8, 2026");
|
|
48
|
+
await user.keyboard("{Enter}");
|
|
49
|
+
expect(onChange).toHaveBeenCalledWith("2026-10-08");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("refuses dates outside min and max", async () => {
|
|
53
|
+
const onChange = vi.fn();
|
|
54
|
+
render(<Calendar defaultMonth="2026-10-01" min="2026-10-10" onChange={onChange} locale="en-US" />);
|
|
55
|
+
const early = screen.getByRole("button", { name: "Friday, October 9, 2026" });
|
|
56
|
+
expect(early).toHaveAttribute("aria-disabled", "true");
|
|
57
|
+
await userEvent.click(early);
|
|
58
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("starts weeks on Monday when asked", () => {
|
|
62
|
+
render(<Calendar defaultMonth="2026-10-01" weekStartsOn={1} locale="en-US" />);
|
|
63
|
+
expect(screen.getAllByRole("columnheader")[0]).toHaveAccessibleName("Monday");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("DatePicker", () => {
|
|
68
|
+
it("shows the date in the locale instead of a mm/dd/yyyy mask, on the control scale", () => {
|
|
69
|
+
render(<DatePicker aria-label="Schedule for" value="2026-10-08" locale="en-US" />);
|
|
70
|
+
const trigger = screen.getByRole("button", { name: "Schedule for" });
|
|
71
|
+
expect(trigger).toHaveTextContent("Oct 8, 2026");
|
|
72
|
+
expect(trigger.className.split(" ")).toContain(controlHeight.md);
|
|
73
|
+
expect(trigger).toHaveAttribute("aria-haspopup", "dialog");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("opens a calendar, reports the chosen date and closes", async () => {
|
|
77
|
+
const onChange = vi.fn();
|
|
78
|
+
render(<DatePicker aria-label="Schedule for" placeholder="Any date" defaultMonth="2026-10-01" onChange={onChange} locale="en-US" />);
|
|
79
|
+
const trigger = screen.getByRole("button", { name: "Schedule for" });
|
|
80
|
+
expect(trigger).toHaveTextContent("Any date");
|
|
81
|
+
expect(trigger).toHaveAttribute("data-placeholder");
|
|
82
|
+
await userEvent.click(trigger);
|
|
83
|
+
await userEvent.click(await screen.findByRole("button", { name: "Tuesday, October 20, 2026" }));
|
|
84
|
+
expect(onChange).toHaveBeenCalledWith("2026-10-20");
|
|
85
|
+
expect(screen.queryByRole("grid")).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("clears a chosen date and submits it with a name", async () => {
|
|
89
|
+
const onChange = vi.fn();
|
|
90
|
+
const { container } = render(<DatePicker aria-label="Due" name="due" value="2026-10-08" onChange={onChange} locale="en-US" />);
|
|
91
|
+
expect(container.querySelector('input[type="hidden"][name="due"]')).toHaveValue("2026-10-08");
|
|
92
|
+
await userEvent.click(screen.getByRole("button", { name: "Due" }));
|
|
93
|
+
await userEvent.click(await screen.findByRole("button", { name: "Clear" }));
|
|
94
|
+
expect(onChange).toHaveBeenCalledWith("");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("TimeSelect", () => {
|
|
99
|
+
it("lists step slots in the locale and keeps an off-step value", () => {
|
|
100
|
+
render(<TimeSelect aria-label="Time" value="09:07" locale="en-US" step={30} />);
|
|
101
|
+
const trigger = screen.getByRole("combobox", { name: "Time" });
|
|
102
|
+
expect(trigger).toHaveTextContent("9:07 AM");
|
|
103
|
+
expect(trigger.className.split(" ")).toContain(controlHeight.md);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("reports HH:MM when a slot is picked", async () => {
|
|
107
|
+
// jsdom lacks the layout APIs Radix Select calls while opening.
|
|
108
|
+
Element.prototype.scrollIntoView ??= vi.fn();
|
|
109
|
+
Element.prototype.hasPointerCapture ??= vi.fn(() => false);
|
|
110
|
+
Element.prototype.releasePointerCapture ??= vi.fn();
|
|
111
|
+
const onChange = vi.fn();
|
|
112
|
+
render(<TimeSelect aria-label="Time" value="09:00" onChange={onChange} locale="en-US" step={60} />);
|
|
113
|
+
const trigger = screen.getByRole("combobox", { name: "Time" });
|
|
114
|
+
trigger.focus();
|
|
115
|
+
fireEvent.keyDown(trigger, { key: "Enter" });
|
|
116
|
+
fireEvent.click(await screen.findByRole("option", { name: "10:00 AM" }));
|
|
117
|
+
expect(onChange).toHaveBeenCalledWith("10:00");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("raised fields in a Toolbar", () => {
|
|
122
|
+
it("lifts fields inside a Toolbar onto the card surface and leaves others in the well", () => {
|
|
123
|
+
render(
|
|
124
|
+
<>
|
|
125
|
+
<Toolbar data-testid="toolbar" filters={<FilterField label="Format"><Select><SelectTrigger aria-label="Format"><SelectValue /></SelectTrigger></Select></FilterField>} />
|
|
126
|
+
<Select><SelectTrigger aria-label="Elsewhere"><SelectValue /></SelectTrigger></Select>
|
|
127
|
+
</>,
|
|
128
|
+
);
|
|
129
|
+
expect(screen.getByTestId("toolbar").className).toContain("[--field-surface:hsl(var(--card))]");
|
|
130
|
+
for (const name of ["Format", "Elsewhere"]) {
|
|
131
|
+
expect(screen.getByRole("combobox", { name }).className).toContain("bg-[var(--field-surface,var(--bg-input))]");
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("Button bare", () => {
|
|
137
|
+
it("keeps focus and disabled behavior without surface or size classes", () => {
|
|
138
|
+
render(<Button variant="bare" className="flex w-full px-4 py-2" disabled>Row</Button>);
|
|
139
|
+
const row = screen.getByRole("button", { name: "Row" });
|
|
140
|
+
expect(row).toBeDisabled();
|
|
141
|
+
expect(row.className).toContain("focus-visible:ring-4");
|
|
142
|
+
expect(row.className).not.toContain("h-[var(--control-height");
|
|
143
|
+
expect(row.className).not.toContain("inline-flex");
|
|
144
|
+
expect(row.className).toContain("px-4");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { CalendarDays } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { fieldPresentation, fieldSizes, fieldTouchText, type ControlSize } from "../lib/control-presentation";
|
|
6
|
+
import { cn } from "../lib/utils";
|
|
7
|
+
import { Button } from "./button";
|
|
8
|
+
import { Calendar, parseCalendarDate, type CalendarDate, type CalendarProps } from "./calendar";
|
|
9
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
10
|
+
|
|
11
|
+
export interface DatePickerProps
|
|
12
|
+
extends Pick<CalendarProps, "min" | "max" | "isDateDisabled" | "today" | "weekStartsOn" | "locale" | "defaultMonth"> {
|
|
13
|
+
/** The chosen date, `YYYY-MM-DD`, or empty for none. */
|
|
14
|
+
value?: CalendarDate | null;
|
|
15
|
+
onChange?: (value: CalendarDate | "") => void;
|
|
16
|
+
/** Shown when no date is chosen. */
|
|
17
|
+
placeholder?: string;
|
|
18
|
+
size?: ControlSize;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
/** Offer a Clear action while a date is chosen. Default true. */
|
|
21
|
+
clearable?: boolean;
|
|
22
|
+
/** How the chosen date reads on the trigger. */
|
|
23
|
+
formatOptions?: Intl.DateTimeFormatOptions;
|
|
24
|
+
id?: string;
|
|
25
|
+
name?: string;
|
|
26
|
+
className?: string;
|
|
27
|
+
"aria-label"?: string;
|
|
28
|
+
"aria-labelledby"?: string;
|
|
29
|
+
"aria-describedby"?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_FORMAT: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A date field: a trigger on the control scale that opens a Calendar. It reads
|
|
36
|
+
* like a Select (same height, well, border, focus ring), shows the date in the
|
|
37
|
+
* viewer's locale instead of a `mm/dd/yyyy` mask, and reports `YYYY-MM-DD`.
|
|
38
|
+
* With `name` it also submits that value in a form.
|
|
39
|
+
*/
|
|
40
|
+
const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
|
|
41
|
+
(
|
|
42
|
+
{
|
|
43
|
+
value,
|
|
44
|
+
onChange,
|
|
45
|
+
placeholder = "Pick a date",
|
|
46
|
+
size = "md",
|
|
47
|
+
disabled,
|
|
48
|
+
clearable = true,
|
|
49
|
+
formatOptions = DEFAULT_FORMAT,
|
|
50
|
+
id,
|
|
51
|
+
name,
|
|
52
|
+
className,
|
|
53
|
+
locale,
|
|
54
|
+
"aria-label": ariaLabel,
|
|
55
|
+
"aria-labelledby": ariaLabelledBy,
|
|
56
|
+
"aria-describedby": ariaDescribedBy,
|
|
57
|
+
...calendarProps
|
|
58
|
+
},
|
|
59
|
+
ref,
|
|
60
|
+
) => {
|
|
61
|
+
const [open, setOpen] = React.useState(false);
|
|
62
|
+
const selected = parseCalendarDate(value);
|
|
63
|
+
const label = selected === null
|
|
64
|
+
? null
|
|
65
|
+
: new Intl.DateTimeFormat(locale, { ...formatOptions, timeZone: "UTC" }).format(selected);
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
69
|
+
<PopoverTrigger asChild>
|
|
70
|
+
<button
|
|
71
|
+
ref={ref}
|
|
72
|
+
id={id}
|
|
73
|
+
type="button"
|
|
74
|
+
disabled={disabled}
|
|
75
|
+
aria-label={ariaLabel}
|
|
76
|
+
aria-labelledby={ariaLabelledBy}
|
|
77
|
+
aria-describedby={ariaDescribedBy}
|
|
78
|
+
aria-haspopup="dialog"
|
|
79
|
+
data-placeholder={selected === null ? "" : undefined}
|
|
80
|
+
className={cn(
|
|
81
|
+
"inline-flex w-full items-center justify-between gap-2 whitespace-nowrap border py-1 text-left shadow-sm disabled:cursor-not-allowed disabled:opacity-50",
|
|
82
|
+
fieldPresentation,
|
|
83
|
+
fieldTouchText,
|
|
84
|
+
fieldSizes[size],
|
|
85
|
+
"data-[placeholder]:text-[var(--text-dim)] data-[state=open]:border-primary",
|
|
86
|
+
className,
|
|
87
|
+
)}
|
|
88
|
+
>
|
|
89
|
+
<span className="min-w-0 truncate">{label ?? placeholder}</span>
|
|
90
|
+
<CalendarDays aria-hidden className="size-4 shrink-0 opacity-60" />
|
|
91
|
+
</button>
|
|
92
|
+
</PopoverTrigger>
|
|
93
|
+
{name && <input type="hidden" name={name} value={selected === null ? "" : (value as string)} />}
|
|
94
|
+
<PopoverContent align="start" className="w-auto p-3">
|
|
95
|
+
<Calendar
|
|
96
|
+
{...calendarProps}
|
|
97
|
+
locale={locale}
|
|
98
|
+
value={value}
|
|
99
|
+
onChange={(next) => {
|
|
100
|
+
onChange?.(next);
|
|
101
|
+
setOpen(false);
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
{clearable && selected !== null && (
|
|
105
|
+
<div className="mt-2 flex justify-end border-t border-border pt-2">
|
|
106
|
+
<Button
|
|
107
|
+
type="button"
|
|
108
|
+
variant="ghost"
|
|
109
|
+
size="sm"
|
|
110
|
+
onClick={() => {
|
|
111
|
+
onChange?.("");
|
|
112
|
+
setOpen(false);
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
Clear
|
|
116
|
+
</Button>
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
</PopoverContent>
|
|
120
|
+
</Popover>
|
|
121
|
+
);
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
DatePicker.displayName = "DatePicker";
|
|
125
|
+
|
|
126
|
+
export { DatePicker };
|
package/src/primitives/index.ts
CHANGED
|
@@ -68,6 +68,13 @@ export type { InputProps, TextareaProps } from "./input";
|
|
|
68
68
|
export { HelpText } from "./help-text";
|
|
69
69
|
export type { HelpTextProps } from "./help-text";
|
|
70
70
|
|
|
71
|
+
export { Calendar, formatCalendarDate, localToday, parseCalendarDate } from "./calendar";
|
|
72
|
+
export type { CalendarDate, CalendarProps } from "./calendar";
|
|
73
|
+
export { DatePicker } from "./date-picker";
|
|
74
|
+
export type { DatePickerProps } from "./date-picker";
|
|
75
|
+
export { TimeSelect } from "./time-select";
|
|
76
|
+
export type { TimeSelectProps } from "./time-select";
|
|
77
|
+
|
|
71
78
|
export { Badge, badgeVariants } from "./badge";
|
|
72
79
|
export type { BadgeProps } from "./badge";
|
|
73
80
|
|
|
@@ -130,6 +137,8 @@ export { Switch } from "./switch";
|
|
|
130
137
|
|
|
131
138
|
export { Checkbox } from "./checkbox";
|
|
132
139
|
|
|
140
|
+
export { RadioGroup, RadioGroupItem } from "./radio-group";
|
|
141
|
+
|
|
133
142
|
export { Separator } from "./separator";
|
|
134
143
|
|
|
135
144
|
export {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { Label } from "./label";
|
|
6
|
+
import { RadioGroup, RadioGroupItem } from "./radio-group";
|
|
7
|
+
|
|
8
|
+
afterEach(cleanup);
|
|
9
|
+
|
|
10
|
+
describe("RadioGroup", () => {
|
|
11
|
+
it("chooses one item by click or arrow key and reports its value", async () => {
|
|
12
|
+
const onValueChange = vi.fn();
|
|
13
|
+
render(
|
|
14
|
+
<RadioGroup aria-label="Plan" defaultValue="basic" onValueChange={onValueChange}>
|
|
15
|
+
<div><RadioGroupItem id="basic" value="basic" /><Label htmlFor="basic">Basic</Label></div>
|
|
16
|
+
<div><RadioGroupItem id="pro" value="pro" /><Label htmlFor="pro">Pro</Label></div>
|
|
17
|
+
</RadioGroup>,
|
|
18
|
+
);
|
|
19
|
+
expect(screen.getByRole("radiogroup", { name: "Plan" })).toBeTruthy();
|
|
20
|
+
expect(screen.getByRole("radio", { name: "Basic" })).toHaveAttribute("aria-checked", "true");
|
|
21
|
+
await userEvent.click(screen.getByText("Pro"));
|
|
22
|
+
expect(onValueChange).toHaveBeenLastCalledWith("pro");
|
|
23
|
+
expect(screen.getByRole("radio", { name: "Pro" })).toHaveAttribute("aria-checked", "true");
|
|
24
|
+
expect(screen.getByRole("radio", { name: "Basic" })).toHaveAttribute("aria-checked", "false");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { focusRing } from "../lib/focus";
|
|
6
|
+
import { cn } from "../lib/utils";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One choice from a set. Arrow keys move between items; pair each item with a
|
|
10
|
+
* `Label` through `id`/`htmlFor`. The item matches `Checkbox` (16px, the
|
|
11
|
+
* strong border, primary when chosen) so single and multiple choice lists
|
|
12
|
+
* read as one family.
|
|
13
|
+
*/
|
|
14
|
+
const RadioGroup = React.forwardRef<
|
|
15
|
+
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
|
16
|
+
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
|
17
|
+
>(({ className, ...props }, ref) => (
|
|
18
|
+
<RadioGroupPrimitive.Root ref={ref} className={cn("grid gap-2", className)} {...props} />
|
|
19
|
+
));
|
|
20
|
+
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
|
21
|
+
|
|
22
|
+
const RadioGroupItem = React.forwardRef<
|
|
23
|
+
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
|
24
|
+
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
|
25
|
+
>(({ className, ...props }, ref) => (
|
|
26
|
+
<RadioGroupPrimitive.Item
|
|
27
|
+
ref={ref}
|
|
28
|
+
className={cn(
|
|
29
|
+
"peer inline-grid size-4 shrink-0 cursor-pointer place-content-center rounded-full border border-[var(--border-strong)] bg-transparent transition-[border-color,box-shadow] duration-150 ease-out",
|
|
30
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
31
|
+
"aria-invalid:border-[var(--surface-danger-border)]",
|
|
32
|
+
"data-[state=checked]:border-primary",
|
|
33
|
+
focusRing,
|
|
34
|
+
className,
|
|
35
|
+
)}
|
|
36
|
+
{...props}
|
|
37
|
+
>
|
|
38
|
+
<RadioGroupPrimitive.Indicator className="size-2 rounded-full bg-primary" />
|
|
39
|
+
</RadioGroupPrimitive.Item>
|
|
40
|
+
));
|
|
41
|
+
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
|
42
|
+
|
|
43
|
+
export { RadioGroup, RadioGroupItem };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import type { ControlSize } from "../lib/control-presentation";
|
|
5
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
|
|
6
|
+
|
|
7
|
+
const TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
8
|
+
|
|
9
|
+
export interface TimeSelectProps {
|
|
10
|
+
/** `HH:MM`, 24-hour, the value a native time input reports. */
|
|
11
|
+
value?: string;
|
|
12
|
+
onChange?: (value: string) => void;
|
|
13
|
+
/** Minutes between choices. Default 15. */
|
|
14
|
+
step?: number;
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
size?: ControlSize;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
locale?: string;
|
|
19
|
+
id?: string;
|
|
20
|
+
name?: string;
|
|
21
|
+
className?: string;
|
|
22
|
+
"aria-label"?: string;
|
|
23
|
+
"aria-labelledby"?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A time of day as a Select on the control scale: the day in `step`-minute
|
|
28
|
+
* slots, labelled in the viewer's locale (9:30 AM, 21:30). A value between
|
|
29
|
+
* slots stays listed so the current time is never lost.
|
|
30
|
+
*/
|
|
31
|
+
const TimeSelect = React.forwardRef<HTMLButtonElement, TimeSelectProps>(
|
|
32
|
+
({ value, onChange, step = 15, placeholder = "Pick a time", size = "md", disabled, locale, id, name, className, ...aria }, ref) => {
|
|
33
|
+
const format = React.useMemo(
|
|
34
|
+
() => new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", timeZone: "UTC" }),
|
|
35
|
+
[locale],
|
|
36
|
+
);
|
|
37
|
+
const options = React.useMemo(() => {
|
|
38
|
+
const values: string[] = [];
|
|
39
|
+
for (let minutes = 0; minutes < 24 * 60; minutes += Math.max(1, step)) {
|
|
40
|
+
values.push(`${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`);
|
|
41
|
+
}
|
|
42
|
+
if (value && TIME_PATTERN.test(value) && !values.includes(value)) values.push(value);
|
|
43
|
+
return values.sort();
|
|
44
|
+
}, [step, value]);
|
|
45
|
+
const label = (time: string) => {
|
|
46
|
+
const [hours, minutes] = time.split(":").map(Number);
|
|
47
|
+
return format.format(Date.UTC(1970, 0, 1, hours, minutes));
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<Select value={value && TIME_PATTERN.test(value) ? value : undefined} onValueChange={onChange} disabled={disabled} name={name}>
|
|
52
|
+
<SelectTrigger ref={ref} id={id} size={size} className={className} {...aria}>
|
|
53
|
+
<SelectValue placeholder={placeholder} />
|
|
54
|
+
</SelectTrigger>
|
|
55
|
+
<SelectContent className="max-h-72">
|
|
56
|
+
{options.map((time) => (
|
|
57
|
+
<SelectItem key={time} value={time}>{label(time)}</SelectItem>
|
|
58
|
+
))}
|
|
59
|
+
</SelectContent>
|
|
60
|
+
</Select>
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
TimeSelect.displayName = "TimeSelect";
|
|
65
|
+
|
|
66
|
+
export { TimeSelect };
|
|
@@ -47,7 +47,9 @@ const Toolbar = React.forwardRef<HTMLDivElement, ToolbarProps>(
|
|
|
47
47
|
<div
|
|
48
48
|
ref={ref}
|
|
49
49
|
className={cn(
|
|
50
|
-
|
|
50
|
+
// Filters sit on the page canvas, where the recessed field well barely
|
|
51
|
+
// differs from the page. Raise every field inside onto the card surface.
|
|
52
|
+
"mb-4 flex flex-col gap-3 lg:flex-row lg:items-center [--field-surface:hsl(var(--card))]",
|
|
51
53
|
className,
|
|
52
54
|
)}
|
|
53
55
|
{...props}
|