@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.
@@ -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 };
@@ -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
- "mb-4 flex flex-col gap-3 lg:flex-row lg:items-center",
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}