@tangle-network/ui 11.28.1 → 11.29.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.
@@ -11,6 +11,9 @@ import { Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger }
11
11
  import { Slider } from "./primitives/slider.js";
12
12
  import { Input, InputProps, Textarea, TextareaProps } from "./primitives/input.js";
13
13
  import { HelpText, HelpTextProps } from "./primitives/help-text.js";
14
+ import { Calendar, CalendarDate, CalendarProps, formatCalendarDate, localToday, parseCalendarDate } from "./primitives/calendar.js";
15
+ import { DatePicker, DatePickerProps } from "./primitives/date-picker.js";
16
+ import { TimeSelect, TimeSelectProps } from "./primitives/time-select.js";
14
17
  import { Badge, BadgeProps, badgeVariants } from "./primitives/badge.js";
15
18
  import { Avatar, AvatarFallback, AvatarImage } from "./primitives/avatar.js";
16
19
  import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from "./primitives/dropdown-menu.js";
@@ -48,4 +51,4 @@ import { Chip, ChipProps } from "./primitives/chip.js";
48
51
  import { Metric, MetricProps, MetricStrip, MetricStripProps } from "./primitives/metric-strip.js";
49
52
  import { FilterField, FilterFieldProps, Toolbar, ToolbarProps } from "./primitives/toolbar.js";
50
53
  import { Logo, LogoProps, TangleKnot } from "@tangle-network/brand";
51
- export { Alert, AlertDescription, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger, type AlertProps, AlertTitle, type AlertTone, ArtifactPane, type ArtifactPaneProps, Avatar, AvatarFallback, AvatarImage, Badge, type BadgeProps, Button, type ButtonProps, CATEGORY_TONES, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, type CardTitleProps, type CategoryTone, Checkbox, Chip, type ChipProps, CodeBlock, type CodeBlockProps, Collapsible, CollapsibleContent, CollapsibleTrigger, ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuPortal, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, CopyButton, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, DropZone, type DropZoneProps, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, EmptyState, type EmptyStateProps, FilterField, type FilterFieldProps, Heading, type HeadingProps, type HeadingVariant, HelpText, type HelpTextProps, IconTile, type IconTileProps, InlineCode, type InlineCodeProps, Input, type InputProps, Label, Logo, type LogoProps, Metric, type MetricProps, MetricStrip, type MetricStripProps, PageHeader, type PageHeaderProps, PageShell, type PageShellProps, Pagination, type PaginationProps, Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger, Progress, SegmentedControl, type SegmentedControlOption, type SegmentedControlProps, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, Separator, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, SidebarDropZone, type SidebarDropZoneProps, Skeleton, SkeletonCard, SkeletonTable, Slider, StatCard, type StatCardProps, StatusPill, type StatusPillProps, type StatusTone, Switch, TONE_CLASSES, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, type TableProps, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, Tag, type TagProps, TangleKnot, TerminalCursor, TerminalDisplay, TerminalInput, TerminalLine, TextShimmer, type TextShimmerProps, Textarea, type TextareaProps, ThemeToggle, type Toast, ToastContainer, ToastProvider, type Tone, type ToneClasses, Toolbar, type ToolbarProps, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, type UploadFile, UploadProgress, type UploadProgressProps, badgeVariants, buttonVariants, initialsOf, toneFor, useTheme, useToast };
54
+ export { Alert, AlertDescription, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger, type AlertProps, AlertTitle, type AlertTone, ArtifactPane, type ArtifactPaneProps, Avatar, AvatarFallback, AvatarImage, Badge, type BadgeProps, Button, type ButtonProps, CATEGORY_TONES, Calendar, type CalendarDate, type CalendarProps, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, type CardTitleProps, type CategoryTone, Checkbox, Chip, type ChipProps, CodeBlock, type CodeBlockProps, Collapsible, CollapsibleContent, CollapsibleTrigger, ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuPortal, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, CopyButton, DatePicker, type DatePickerProps, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, DropZone, type DropZoneProps, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, EmptyState, type EmptyStateProps, FilterField, type FilterFieldProps, Heading, type HeadingProps, type HeadingVariant, HelpText, type HelpTextProps, IconTile, type IconTileProps, InlineCode, type InlineCodeProps, Input, type InputProps, Label, Logo, type LogoProps, Metric, type MetricProps, MetricStrip, type MetricStripProps, PageHeader, type PageHeaderProps, PageShell, type PageShellProps, Pagination, type PaginationProps, Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger, Progress, SegmentedControl, type SegmentedControlOption, type SegmentedControlProps, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, Separator, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, SidebarDropZone, type SidebarDropZoneProps, Skeleton, SkeletonCard, SkeletonTable, Slider, StatCard, type StatCardProps, StatusPill, type StatusPillProps, type StatusTone, Switch, TONE_CLASSES, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, type TableProps, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, Tag, type TagProps, TangleKnot, TerminalCursor, TerminalDisplay, TerminalInput, TerminalLine, TextShimmer, type TextShimmerProps, Textarea, type TextareaProps, ThemeToggle, TimeSelect, type TimeSelectProps, type Toast, ToastContainer, ToastProvider, type Tone, type ToneClasses, Toolbar, type ToolbarProps, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, type UploadFile, UploadProgress, type UploadProgressProps, badgeVariants, buttonVariants, formatCalendarDate, initialsOf, localToday, parseCalendarDate, toneFor, useTheme, useToast };
@@ -10,10 +10,13 @@ import { Slider } from "./primitives/slider.js";
10
10
  import { HelpText } from "./primitives/help-text.js";
11
11
  import { Label } from "./primitives/label.js";
12
12
  import { Input, Textarea } from "./primitives/input.js";
13
+ import { Calendar, formatCalendarDate, localToday, parseCalendarDate } from "./primitives/calendar.js";
14
+ import { DatePicker } from "./primitives/date-picker.js";
15
+ import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue } from "./primitives/select.js";
16
+ import { TimeSelect } from "./primitives/time-select.js";
13
17
  import { Badge, badgeVariants } from "./primitives/badge.js";
14
18
  import { Avatar, AvatarFallback, AvatarImage } from "./primitives/avatar.js";
15
19
  import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from "./primitives/dropdown-menu.js";
16
- import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue } from "./primitives/select.js";
17
20
  import { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "./primitives/table.js";
18
21
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "./primitives/tabs.js";
19
22
  import { SegmentedControl } from "./primitives/segmented-control.js";
@@ -48,4 +51,4 @@ import { IconTile, initialsOf } from "./primitives/icon-tile.js";
48
51
  import { Metric, MetricStrip } from "./primitives/metric-strip.js";
49
52
  import { FilterField, Toolbar } from "./primitives/toolbar.js";
50
53
  import { Logo, TangleKnot } from "@tangle-network/brand";
51
- export { Alert, AlertDescription, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger, AlertTitle, ArtifactPane, Avatar, AvatarFallback, AvatarImage, Badge, Button, CATEGORY_TONES, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Checkbox, Chip, CodeBlock, Collapsible, CollapsibleContent, CollapsibleTrigger, ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuPortal, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, CopyButton, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, DropZone, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, EmptyState, FilterField, Heading, HelpText, IconTile, InlineCode, Input, Label, Logo, Metric, MetricStrip, PageHeader, PageShell, Pagination, Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger, Progress, SegmentedControl, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, Separator, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, SidebarDropZone, Skeleton, SkeletonCard, SkeletonTable, Slider, StatCard, StatusPill, Switch, TONE_CLASSES, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, Tag, TangleKnot, TerminalCursor, TerminalDisplay, TerminalInput, TerminalLine, TextShimmer, Textarea, ThemeToggle, ToastContainer, ToastProvider, Toolbar, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, UploadProgress, badgeVariants, buttonVariants, initialsOf, toneFor, useTheme, useToast };
54
+ export { Alert, AlertDescription, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger, AlertTitle, ArtifactPane, Avatar, AvatarFallback, AvatarImage, Badge, Button, CATEGORY_TONES, Calendar, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Checkbox, Chip, CodeBlock, Collapsible, CollapsibleContent, CollapsibleTrigger, ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuPortal, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, CopyButton, DatePicker, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, DropZone, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, EmptyState, FilterField, Heading, HelpText, IconTile, InlineCode, Input, Label, Logo, Metric, MetricStrip, PageHeader, PageShell, Pagination, Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger, Progress, SegmentedControl, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, Separator, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, SidebarDropZone, Skeleton, SkeletonCard, SkeletonTable, Slider, StatCard, StatusPill, Switch, TONE_CLASSES, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, Tag, TangleKnot, TerminalCursor, TerminalDisplay, TerminalInput, TerminalLine, TextShimmer, Textarea, ThemeToggle, TimeSelect, ToastContainer, ToastProvider, Toolbar, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, UploadProgress, badgeVariants, buttonVariants, formatCalendarDate, initialsOf, localToday, parseCalendarDate, toneFor, useTheme, useToast };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "11.28.1",
3
+ "version": "11.29.0",
4
4
  "description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -149,7 +149,7 @@
149
149
  "peerDependencies": {
150
150
  "@hocuspocus/provider": ">=2.15.0 <5.0.0",
151
151
  "@nanostores/react": ">=0.8.4 <3.0.0",
152
- "@tangle-network/brand": "^1.18.0",
152
+ "@tangle-network/brand": "^1.19.2",
153
153
  "@tiptap/core": ">=3.20.4 <4.0.0",
154
154
  "@tiptap/extension-collaboration": ">=3.20.4 <4.0.0",
155
155
  "@tiptap/extension-collaboration-caret": ">=3.7.0 <4.0.0",
@@ -64,10 +64,12 @@ export const controlSizes = {
64
64
  // resolves the recessed well inside every canonical/named light/dark scope.
65
65
  // An autofill inset shadow coexists with the Tailwind focus ring; never hide
66
66
  // autofill with a seconds-long background transition or clear the focus halo.
67
+ // --field-surface lets a context raise its fields: Toolbar sets it to the card
68
+ // surface so filters on the page canvas read as controls, not as holes in it.
67
69
  export const fieldPresentation = cn(
68
70
  focusField,
69
71
  controlMotion,
70
- "min-w-0 bg-[var(--bg-input)] text-foreground",
72
+ "min-w-0 bg-[var(--field-surface,var(--bg-input))] text-foreground",
71
73
  "aria-invalid:border-[var(--surface-danger-border)] aria-invalid:hover:border-[var(--surface-danger-border)] aria-invalid:focus:border-[var(--focus-border-danger)] aria-invalid:focus:ring-[var(--focus-halo-danger)]",
72
74
  "autofill:shadow-[inset_0_0_0_1000px_var(--bg-input)] autofill:[-webkit-text-fill-color:var(--text-primary)] autofill:caret-[var(--text-primary)]",
73
75
  );
@@ -46,9 +46,15 @@ const buttonVariants = cva(
46
46
  },
47
47
  );
48
48
 
49
+ // `bare` keeps the shared focus ring, disabled and loading behavior and drops
50
+ // every surface, size and type class. It is for pressable rows, tiles and pills
51
+ // that draw their own layout; anything that reads as a button uses a styled
52
+ // variant and a size.
53
+ const bareButton = `${focusRing} disabled:pointer-events-none disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50`;
54
+
49
55
  export interface ButtonProps
50
56
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
51
- variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "sandbox" | null;
57
+ variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "sandbox" | "bare" | null;
52
58
  /** sm 32px, default/md 36px (`--control-height`), lg 44px; icon sizes are squares on the same heights. */
53
59
  size?: "default" | "md" | "sm" | "lg" | "xl" | "icon" | "icon-sm" | "icon-lg" | "compact" | "touch" | null;
54
60
  asChild?: boolean;
@@ -109,7 +115,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
109
115
  // Keep slotted content intact: its action name must survive loading too.
110
116
  return (
111
117
  <Slot
112
- className={cn(buttonVariants({ variant, size, className }))}
118
+ className={variant === "bare" ? cn(bareButton, className) : cn(buttonVariants({ variant, size, className }))}
113
119
  ref={ref}
114
120
  {...props}
115
121
  {...(isDisabled ? disabledHandlers(props) : {})}
@@ -134,7 +140,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
134
140
 
135
141
  return (
136
142
  <button
137
- className={cn(buttonVariants({ variant, size, className }))}
143
+ className={variant === "bare" ? cn(bareButton, className) : cn(buttonVariants({ variant, size, className }))}
138
144
  ref={ref}
139
145
  {...props}
140
146
  disabled={disabled || loading}
@@ -0,0 +1,242 @@
1
+ "use client";
2
+
3
+ import { ChevronLeft, ChevronRight } from "lucide-react";
4
+ import * as React from "react";
5
+ import { focusRing } from "../lib/focus";
6
+ import { cn } from "../lib/utils";
7
+ import { Button } from "./button";
8
+
9
+ /**
10
+ * Calendar dates are plain `YYYY-MM-DD` strings, the same value a native date
11
+ * input reports. All arithmetic runs in UTC so a date never shifts with the
12
+ * viewer's time zone or a daylight-saving boundary.
13
+ */
14
+ export type CalendarDate = string;
15
+
16
+ const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
17
+ const DAY_MS = 86_400_000;
18
+
19
+ export function parseCalendarDate(value: string | null | undefined): number | null {
20
+ const match = value ? DATE_PATTERN.exec(value) : null;
21
+ if (!match) return null;
22
+ const time = Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
23
+ const date = new Date(time);
24
+ // Reject impossible dates such as 2026-02-31 rather than rolling them over.
25
+ return date.getUTCMonth() === Number(match[2]) - 1 ? time : null;
26
+ }
27
+
28
+ export function formatCalendarDate(time: number): CalendarDate {
29
+ const date = new Date(time);
30
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
31
+ const day = String(date.getUTCDate()).padStart(2, "0");
32
+ return `${date.getUTCFullYear()}-${month}-${day}`;
33
+ }
34
+
35
+ /** Today in the viewer's local calendar, as a `YYYY-MM-DD` string. */
36
+ export function localToday(now = new Date()): CalendarDate {
37
+ return formatCalendarDate(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
38
+ }
39
+
40
+ function firstOfMonth(time: number): number {
41
+ const date = new Date(time);
42
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1);
43
+ }
44
+
45
+ function addMonths(time: number, delta: number): number {
46
+ const date = new Date(time);
47
+ const target = Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + delta, 1);
48
+ const lastDay = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + delta + 1, 0)).getUTCDate();
49
+ const day = Math.min(date.getUTCDate(), lastDay);
50
+ return target + (day - 1) * DAY_MS;
51
+ }
52
+
53
+ export interface CalendarProps {
54
+ /** The selected date, `YYYY-MM-DD`. */
55
+ value?: CalendarDate | null;
56
+ onChange?: (value: CalendarDate) => void;
57
+ /** The month shown first (any date inside it). Defaults to the value, else today. */
58
+ defaultMonth?: CalendarDate;
59
+ /** Earliest and latest selectable dates, inclusive. */
60
+ min?: CalendarDate;
61
+ max?: CalendarDate;
62
+ isDateDisabled?: (value: CalendarDate) => boolean;
63
+ /** The date marked as today. Pass it to use a workspace time zone instead of the viewer's. */
64
+ today?: CalendarDate;
65
+ /** 0 starts weeks on Sunday, 1 on Monday. */
66
+ weekStartsOn?: 0 | 1;
67
+ locale?: string;
68
+ className?: string;
69
+ "aria-label"?: string;
70
+ }
71
+
72
+ /**
73
+ * A month grid that picks one date. Arrow keys move by day and week, Page Up
74
+ * and Page Down by month, Home and End to the week's ends; Enter or Space picks
75
+ * the focused date. Six weeks always render so the grid never changes height.
76
+ */
77
+ const Calendar = React.forwardRef<HTMLDivElement, CalendarProps>(
78
+ (
79
+ {
80
+ value,
81
+ onChange,
82
+ defaultMonth,
83
+ min,
84
+ max,
85
+ isDateDisabled,
86
+ today: todayProp,
87
+ weekStartsOn = 0,
88
+ locale,
89
+ className,
90
+ "aria-label": ariaLabel,
91
+ },
92
+ ref,
93
+ ) => {
94
+ const today = parseCalendarDate(todayProp) ?? (parseCalendarDate(localToday()) as number);
95
+ const selected = parseCalendarDate(value);
96
+ const minTime = parseCalendarDate(min);
97
+ const maxTime = parseCalendarDate(max);
98
+ const [focused, setFocused] = React.useState<number>(
99
+ () => selected ?? parseCalendarDate(defaultMonth) ?? today,
100
+ );
101
+ const viewMonth = firstOfMonth(focused);
102
+ const gridRef = React.useRef<HTMLDivElement>(null);
103
+ const moveFocusRef = React.useRef(false);
104
+
105
+ React.useEffect(() => {
106
+ if (selected !== null) setFocused(selected);
107
+ }, [selected]);
108
+
109
+ React.useEffect(() => {
110
+ if (!moveFocusRef.current) return;
111
+ moveFocusRef.current = false;
112
+ gridRef.current?.querySelector<HTMLButtonElement>('[data-focused="true"]')?.focus();
113
+ }, [focused]);
114
+
115
+ const disabled = (time: number) =>
116
+ (minTime !== null && time < minTime) ||
117
+ (maxTime !== null && time > maxTime) ||
118
+ Boolean(isDateDisabled?.(formatCalendarDate(time)));
119
+
120
+ const leading = (new Date(viewMonth).getUTCDay() - weekStartsOn + 7) % 7;
121
+ const start = viewMonth - leading * DAY_MS;
122
+ const days = Array.from({ length: 42 }, (_, index) => start + index * DAY_MS);
123
+ const weeks = Array.from({ length: 6 }, (_, row) => days.slice(row * 7, row * 7 + 7));
124
+
125
+ const monthLabel = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric", timeZone: "UTC" }).format(viewMonth);
126
+ const dayLabel = new Intl.DateTimeFormat(locale, { weekday: "long", month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
127
+ const weekdayShort = new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" });
128
+ const weekdayLong = new Intl.DateTimeFormat(locale, { weekday: "long", timeZone: "UTC" });
129
+ const weekdays = days.slice(0, 7);
130
+
131
+ const move = (next: number) => {
132
+ moveFocusRef.current = true;
133
+ setFocused(next);
134
+ };
135
+
136
+ const onKeyDown = (event: React.KeyboardEvent) => {
137
+ const weekday = (new Date(focused).getUTCDay() - weekStartsOn + 7) % 7;
138
+ const steps: Record<string, () => number> = {
139
+ ArrowLeft: () => focused - DAY_MS,
140
+ ArrowRight: () => focused + DAY_MS,
141
+ ArrowUp: () => focused - 7 * DAY_MS,
142
+ ArrowDown: () => focused + 7 * DAY_MS,
143
+ PageUp: () => addMonths(focused, event.shiftKey ? -12 : -1),
144
+ PageDown: () => addMonths(focused, event.shiftKey ? 12 : 1),
145
+ Home: () => focused - weekday * DAY_MS,
146
+ End: () => focused + (6 - weekday) * DAY_MS,
147
+ };
148
+ const step = steps[event.key];
149
+ if (!step) return;
150
+ event.preventDefault();
151
+ move(step());
152
+ };
153
+
154
+ const pick = (time: number) => {
155
+ if (disabled(time)) return;
156
+ setFocused(time);
157
+ onChange?.(formatCalendarDate(time));
158
+ };
159
+
160
+ return (
161
+ <div ref={ref} className={cn("w-fit select-none", className)}>
162
+ <div className="mb-2 flex items-center justify-between gap-2">
163
+ <Button
164
+ type="button"
165
+ variant="ghost"
166
+ size="icon-sm"
167
+ aria-label="Previous month"
168
+ onClick={() => setFocused(addMonths(focused, -1))}
169
+ >
170
+ <ChevronLeft />
171
+ </Button>
172
+ <div aria-live="polite" className="font-medium text-[length:var(--control-text-md,0.875rem)] text-foreground">
173
+ {monthLabel}
174
+ </div>
175
+ <Button
176
+ type="button"
177
+ variant="ghost"
178
+ size="icon-sm"
179
+ aria-label="Next month"
180
+ onClick={() => setFocused(addMonths(focused, 1))}
181
+ >
182
+ <ChevronRight />
183
+ </Button>
184
+ </div>
185
+ <div ref={gridRef} role="grid" aria-label={ariaLabel ?? monthLabel} onKeyDown={onKeyDown}>
186
+ <div role="row" className="grid grid-cols-7">
187
+ {weekdays.map((time) => (
188
+ <div
189
+ key={time}
190
+ role="columnheader"
191
+ aria-label={weekdayLong.format(time)}
192
+ className="flex h-8 items-center justify-center text-[length:var(--font-size-help,0.75rem)] text-muted-foreground"
193
+ >
194
+ {weekdayShort.format(time).slice(0, 2)}
195
+ </div>
196
+ ))}
197
+ </div>
198
+ {weeks.map((week) => (
199
+ <div key={week[0]} role="row" className="grid grid-cols-7">
200
+ {week.map((time) => {
201
+ const outside = firstOfMonth(time) !== viewMonth;
202
+ const isSelected = selected === time;
203
+ const isToday = today === time;
204
+ const isDisabled = disabled(time);
205
+ return (
206
+ <div key={time} role="gridcell" aria-selected={isSelected}>
207
+ <button
208
+ type="button"
209
+ tabIndex={time === focused ? 0 : -1}
210
+ data-focused={time === focused}
211
+ data-outside={outside || undefined}
212
+ data-today={isToday || undefined}
213
+ aria-label={dayLabel.format(time)}
214
+ aria-current={isToday ? "date" : undefined}
215
+ aria-disabled={isDisabled || undefined}
216
+ onClick={() => pick(time)}
217
+ onFocus={() => { if (time !== focused) setFocused(time); }}
218
+ className={cn(
219
+ "flex size-[var(--control-height,2.25rem)] items-center justify-center rounded-lg text-[length:var(--control-text-md,0.875rem)] tabular-nums transition-colors",
220
+ focusRing,
221
+ "hover:bg-muted",
222
+ outside && "text-muted-foreground/60",
223
+ isToday && !isSelected && "font-semibold text-[var(--accent-text)]",
224
+ isSelected && "bg-primary font-medium text-primary-foreground hover:bg-primary/90",
225
+ isDisabled && "cursor-not-allowed opacity-40 hover:bg-transparent",
226
+ )}
227
+ >
228
+ {new Date(time).getUTCDate()}
229
+ </button>
230
+ </div>
231
+ );
232
+ })}
233
+ </div>
234
+ ))}
235
+ </div>
236
+ </div>
237
+ );
238
+ },
239
+ );
240
+ Calendar.displayName = "Calendar";
241
+
242
+ export { Calendar };
@@ -12,7 +12,7 @@ afterEach(cleanup);
12
12
 
13
13
  describe("shared control presentation", () => {
14
14
  it("uses the field well, not the border/track alias, with scoped foreground and time-only motion", () => {
15
- expect(fieldPresentation).toContain("bg-[var(--bg-input)]");
15
+ expect(fieldPresentation).toContain("bg-[var(--field-surface,var(--bg-input))]");
16
16
  expect(fieldPresentation.split(" ")).not.toContain("bg-input");
17
17
  expect(fieldPresentation.split(" ")).not.toContain("bg-card");
18
18
  expect(fieldPresentation).toContain("border-border");
@@ -63,6 +63,13 @@ describe("shared control presentation", () => {
63
63
  }
64
64
  });
65
65
 
66
+ it("lets a file input's selector button take the field's text size", () => {
67
+ render(<Input type="file" size="lg" aria-label="Upload" />);
68
+ const classes = screen.getByLabelText("Upload").className.split(" ");
69
+ expect(classes).toContain("text-[length:var(--control-text-lg,1rem)]");
70
+ expect(classes.filter((name) => name.startsWith("file:text-"))).toEqual([]);
71
+ });
72
+
66
73
  it("gives icon buttons squares on the same heights", () => {
67
74
  render(<><Button size="icon-sm" aria-label="Small" /><Button size="icon" aria-label="Medium" /><Button size="icon-lg" aria-label="Large" /></>);
68
75
  expect(screen.getByLabelText("Small").className).toContain("size-[var(--control-height-sm,2rem)]");
@@ -93,7 +100,7 @@ describe("shared control presentation", () => {
93
100
  expect(input.current).toHaveAttribute("aria-invalid", "true");
94
101
  expect(input.current!.className).toContain("autofill:shadow-");
95
102
  expect(input.current!.className.split(" ")).toContain("bg-card");
96
- expect(input.current!.className).not.toContain("bg-[var(--bg-input)]");
103
+ expect(input.current!.className).not.toContain("bg-[var(--field-surface,var(--bg-input))]");
97
104
  expect(textarea.current).toBeDisabled();
98
105
  expect(trigger.current).toBe(screen.getByLabelText("Choice"));
99
106
  expect(trigger.current).toBeDisabled();
@@ -0,0 +1,55 @@
1
+ import * as React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { Button } from "./button";
4
+ import { Calendar } from "./calendar";
5
+ import { DatePicker } from "./date-picker";
6
+ import { Input } from "./input";
7
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
8
+ import { TimeSelect } from "./time-select";
9
+ import { FilterField, Toolbar } from "./toolbar";
10
+
11
+ function FilterBar() {
12
+ const [date, setDate] = React.useState("");
13
+ const [format, setFormat] = React.useState("all");
14
+ const [time, setTime] = React.useState("09:30");
15
+ return (
16
+ <main className="space-y-8 bg-background p-6 text-foreground" data-date-picker-story>
17
+ <section>
18
+ <h2 className="mb-3 font-semibold text-section">On the page canvas, in a Toolbar</h2>
19
+ <Toolbar
20
+ search={<Input aria-label="Filter assets" placeholder="Filter assets…" />}
21
+ filters={<>
22
+ <FilterField label="Format" htmlFor="format">
23
+ <Select value={format} onValueChange={setFormat}>
24
+ <SelectTrigger id="format" className="w-40"><SelectValue /></SelectTrigger>
25
+ <SelectContent>
26
+ <SelectItem value="all">All formats</SelectItem>
27
+ <SelectItem value="email">Email</SelectItem>
28
+ <SelectItem value="story">Story</SelectItem>
29
+ </SelectContent>
30
+ </Select>
31
+ </FilterField>
32
+ <FilterField label="Schedule for" htmlFor="schedule">
33
+ <DatePicker id="schedule" className="w-40" placeholder="Any date" value={date} onChange={setDate} />
34
+ </FilterField>
35
+ <FilterField label="At" htmlFor="at">
36
+ <TimeSelect id="at" className="w-32" value={time} onChange={setTime} />
37
+ </FilterField>
38
+ </>}
39
+ actions={<Button variant="outline">Approve all (4)</Button>}
40
+ />
41
+ </section>
42
+ <section className="max-w-sm space-y-2 rounded-xl border border-border bg-card p-4">
43
+ <h2 className="font-semibold text-section">In a card, in the field well</h2>
44
+ <DatePicker aria-label="Due" value="2026-10-08" />
45
+ </section>
46
+ <section className="w-fit rounded-xl border border-border bg-card p-3">
47
+ <Calendar value="2026-10-08" today="2026-10-08" weekStartsOn={1} />
48
+ </section>
49
+ </main>
50
+ );
51
+ }
52
+
53
+ const meta = { title: "Primitives/Date Picker", component: FilterBar, parameters: { layout: "fullscreen" } } satisfies Meta<typeof FilterBar>;
54
+ export default meta;
55
+ export const InAToolbar: StoryObj<typeof meta> = {};
@@ -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
+ });