@exxatdesignux/ui 0.0.5

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.
Files changed (59) hide show
  1. package/package.json +72 -0
  2. package/src/components/ui/avatar.tsx +384 -0
  3. package/src/components/ui/badge.tsx +49 -0
  4. package/src/components/ui/banner.tsx +364 -0
  5. package/src/components/ui/breadcrumb.tsx +120 -0
  6. package/src/components/ui/button.tsx +66 -0
  7. package/src/components/ui/calendar.tsx +220 -0
  8. package/src/components/ui/card.tsx +136 -0
  9. package/src/components/ui/chart.tsx +378 -0
  10. package/src/components/ui/checkbox.tsx +160 -0
  11. package/src/components/ui/coach-mark.tsx +361 -0
  12. package/src/components/ui/collapsible.tsx +33 -0
  13. package/src/components/ui/command.tsx +232 -0
  14. package/src/components/ui/date-picker-field.tsx +186 -0
  15. package/src/components/ui/dialog.tsx +171 -0
  16. package/src/components/ui/drag-handle-grip.tsx +10 -0
  17. package/src/components/ui/drawer.tsx +134 -0
  18. package/src/components/ui/dropdown-menu.tsx +422 -0
  19. package/src/components/ui/field.tsx +238 -0
  20. package/src/components/ui/form.tsx +137 -0
  21. package/src/components/ui/input-group.tsx +156 -0
  22. package/src/components/ui/input-mask.tsx +135 -0
  23. package/src/components/ui/input.tsx +22 -0
  24. package/src/components/ui/kbd.tsx +55 -0
  25. package/src/components/ui/label.tsx +25 -0
  26. package/src/components/ui/payment-card-fields.tsx +65 -0
  27. package/src/components/ui/popover.tsx +46 -0
  28. package/src/components/ui/radio-group.tsx +217 -0
  29. package/src/components/ui/select.tsx +191 -0
  30. package/src/components/ui/selection-tile-grid.tsx +246 -0
  31. package/src/components/ui/separator.tsx +28 -0
  32. package/src/components/ui/sheet.tsx +147 -0
  33. package/src/components/ui/sidebar.tsx +716 -0
  34. package/src/components/ui/skeleton.tsx +13 -0
  35. package/src/components/ui/sonner.tsx +39 -0
  36. package/src/components/ui/status-badge.tsx +109 -0
  37. package/src/components/ui/table.tsx +117 -0
  38. package/src/components/ui/tabs.tsx +90 -0
  39. package/src/components/ui/textarea.tsx +18 -0
  40. package/src/components/ui/tip.tsx +21 -0
  41. package/src/components/ui/toggle-group.tsx +89 -0
  42. package/src/components/ui/toggle-switch.tsx +31 -0
  43. package/src/components/ui/toggle.tsx +48 -0
  44. package/src/components/ui/tooltip.tsx +59 -0
  45. package/src/components/ui/view-segmented-control.tsx +160 -0
  46. package/src/globals.css +1795 -0
  47. package/src/hooks/.gitkeep +0 -0
  48. package/src/hooks/use-app-theme.ts +172 -0
  49. package/src/hooks/use-coach-mark.ts +342 -0
  50. package/src/hooks/use-mobile.ts +31 -0
  51. package/src/hooks/use-mod-key-label.ts +29 -0
  52. package/src/index.ts +55 -0
  53. package/src/lib/compose-refs.ts +15 -0
  54. package/src/lib/date-filter.ts +67 -0
  55. package/src/lib/utils.ts +6 -0
  56. package/src/theme/apply-windows-contrast-theme.ts +29 -0
  57. package/src/theme/windows-contrast-theme.json +147 -0
  58. package/src/theme.css +1130 -0
  59. package/src/types/react-payment-inputs.d.ts +20 -0
@@ -0,0 +1,217 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Radio group — Radix `RadioGroup` + `RadioGroupItem` with Exxat styling.
5
+ *
6
+ * Aligned with Shadcn Studio radio-group guidance (see shadcnstudio.com/docs/components/radio-group):
7
+ * • Visual variants: default, outline, secondary, success, destructive, warning, muted
8
+ * • Sizes: sm, default, lg (outer ring + inner dot scale together)
9
+ * • Motion: none | pop | glow | pop-glow — `motion-safe` / `motion-reduce` (WCAG 2.3.3)
10
+ * • Optional group defaults: `<RadioGroup itemVariant="outline" itemSize="sm">` so items inherit
11
+ * • Per-item overrides: `<RadioGroupItem variant="success" />`
12
+ * • Forms: `aria-invalid`, `disabled`, hit slop (`after:`); pair with `RadioGroupLabel` + `htmlFor` / `id`
13
+ */
14
+
15
+ import * as React from "react"
16
+ import { cva, type VariantProps } from "class-variance-authority"
17
+ import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
18
+
19
+ import { cn } from "../../lib/utils"
20
+ import { Label } from "./label"
21
+
22
+ // ── Item chrome (variants / sizes / motion) ─────────────────────────────────
23
+
24
+ const radioGroupItemVariants = cva(
25
+ [
26
+ "group/radio-group-item peer relative flex shrink-0 aspect-square rounded-full border border-input",
27
+ "outline-none transition-[color,box-shadow,transform,background-color,border-color] duration-150",
28
+ "motion-reduce:transition-none",
29
+ "group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2",
30
+ "focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
31
+ "disabled:cursor-not-allowed disabled:opacity-50",
32
+ "aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:data-[state=checked]:border-primary",
33
+ "dark:bg-input/15 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
34
+ ].join(" "),
35
+ {
36
+ variants: {
37
+ variant: {
38
+ default: [
39
+ "data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary",
40
+ ].join(" "),
41
+ outline: [
42
+ "bg-background",
43
+ "data-[state=checked]:border-primary data-[state=checked]:bg-background data-[state=checked]:text-primary data-[state=checked]:ring-2 data-[state=checked]:ring-primary/25",
44
+ ].join(" "),
45
+ secondary: [
46
+ "data-[state=checked]:border-secondary data-[state=checked]:bg-secondary data-[state=checked]:text-secondary-foreground",
47
+ ].join(" "),
48
+ success: [
49
+ "data-[state=checked]:border-chart-2 data-[state=checked]:bg-chart-2 data-[state=checked]:text-primary-foreground",
50
+ ].join(" "),
51
+ destructive: [
52
+ "data-[state=checked]:border-destructive data-[state=checked]:bg-destructive data-[state=checked]:text-destructive-foreground",
53
+ ].join(" "),
54
+ warning: [
55
+ "data-[state=checked]:border-amber-500 data-[state=checked]:bg-amber-500 data-[state=checked]:text-amber-950",
56
+ ].join(" "),
57
+ muted: [
58
+ "data-[state=checked]:border-muted-foreground/50 data-[state=checked]:bg-muted data-[state=checked]:text-foreground",
59
+ ].join(" "),
60
+ },
61
+ size: {
62
+ sm: "size-3.5 min-h-3.5 min-w-3.5 max-h-3.5 max-w-3.5",
63
+ default: "size-4 min-h-4 min-w-4 max-h-4 max-w-4",
64
+ lg: "size-5 min-h-5 min-w-5 max-h-5 max-w-5",
65
+ },
66
+ motion: {
67
+ none: "",
68
+ pop: [
69
+ "motion-safe:active:scale-95",
70
+ "data-[state=checked]:motion-safe:scale-[1.04]",
71
+ ].join(" "),
72
+ glow: "data-[state=checked]:shadow-[0_0_0_3px] data-[state=checked]:shadow-primary/35",
73
+ "pop-glow": [
74
+ "motion-safe:active:scale-95",
75
+ "data-[state=checked]:motion-safe:scale-[1.04]",
76
+ "data-[state=checked]:shadow-[0_0_0_3px] data-[state=checked]:shadow-primary/35",
77
+ ].join(" "),
78
+ },
79
+ },
80
+ defaultVariants: {
81
+ variant: "default",
82
+ size: "default",
83
+ motion: "none",
84
+ },
85
+ }
86
+ )
87
+
88
+ const radioIndicatorDotVariants = cva(
89
+ "pointer-events-none absolute top-1/2 start-1/2 -translate-x-1/2 rtl:translate-x-1/2 -translate-y-1/2 rounded-full",
90
+ {
91
+ variants: {
92
+ variant: {
93
+ default: "bg-primary-foreground",
94
+ outline: "bg-primary",
95
+ secondary: "bg-secondary-foreground",
96
+ success: "bg-primary-foreground",
97
+ destructive: "bg-destructive-foreground",
98
+ warning: "bg-amber-950",
99
+ muted: "bg-foreground",
100
+ },
101
+ size: {
102
+ sm: "size-1.5",
103
+ default: "size-2",
104
+ lg: "size-2.5",
105
+ },
106
+ },
107
+ defaultVariants: {
108
+ variant: "default",
109
+ size: "default",
110
+ },
111
+ }
112
+ )
113
+
114
+ const radioIndicatorWrapperVariants = cva("relative flex size-full items-center justify-center", {
115
+ variants: {
116
+ motion: {
117
+ none: "",
118
+ pop: "motion-safe:animate-in motion-safe:fade-in-0 motion-safe:zoom-in-95 motion-safe:duration-150",
119
+ glow: "",
120
+ "pop-glow": "motion-safe:animate-in motion-safe:fade-in-0 motion-safe:zoom-in-95 motion-safe:duration-150",
121
+ },
122
+ },
123
+ defaultVariants: { motion: "none" },
124
+ })
125
+
126
+ type ItemChrome = {
127
+ itemVariant?: VariantProps<typeof radioGroupItemVariants>["variant"]
128
+ itemSize?: VariantProps<typeof radioGroupItemVariants>["size"]
129
+ itemMotion?: VariantProps<typeof radioGroupItemVariants>["motion"]
130
+ }
131
+
132
+ const RadioGroupItemChromeContext = React.createContext<ItemChrome>({})
133
+
134
+ // ── RadioGroup root ─────────────────────────────────────────────────────────
135
+
136
+ export type RadioGroupProps = React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> &
137
+ ItemChrome
138
+
139
+ function RadioGroup({
140
+ className,
141
+ itemVariant,
142
+ itemSize,
143
+ itemMotion,
144
+ ...props
145
+ }: RadioGroupProps) {
146
+ const ctx = React.useMemo(
147
+ () => ({ itemVariant, itemSize, itemMotion }),
148
+ [itemVariant, itemSize, itemMotion],
149
+ )
150
+ return (
151
+ <RadioGroupItemChromeContext.Provider value={ctx}>
152
+ <RadioGroupPrimitive.Root
153
+ data-slot="radio-group"
154
+ className={cn("grid w-full gap-2", className)}
155
+ {...props}
156
+ />
157
+ </RadioGroupItemChromeContext.Provider>
158
+ )
159
+ }
160
+
161
+ // ── RadioGroupItem ──────────────────────────────────────────────────────────
162
+
163
+ export type RadioGroupItemProps = React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> &
164
+ VariantProps<typeof radioGroupItemVariants>
165
+
166
+ const RadioGroupItem = React.forwardRef<
167
+ React.ElementRef<typeof RadioGroupPrimitive.Item>,
168
+ RadioGroupItemProps
169
+ >(function RadioGroupItem(
170
+ { className, variant: variantProp, size: sizeProp, motion: motionProp, ...props },
171
+ ref,
172
+ ) {
173
+ const ctx = React.useContext(RadioGroupItemChromeContext)
174
+ const variant = variantProp ?? ctx.itemVariant
175
+ const size = sizeProp ?? ctx.itemSize
176
+ const motion = motionProp ?? ctx.itemMotion ?? "none"
177
+ const vResolved = variant ?? "default"
178
+ const sResolved = size ?? "default"
179
+
180
+ return (
181
+ <RadioGroupPrimitive.Item
182
+ ref={ref}
183
+ data-slot="radio-group-item"
184
+ data-variant={vResolved}
185
+ data-motion={motion}
186
+ className={cn(radioGroupItemVariants({ variant, size, motion }), className)}
187
+ {...props}
188
+ >
189
+ <RadioGroupPrimitive.Indicator
190
+ data-slot="radio-group-indicator"
191
+ className={radioIndicatorWrapperVariants({ motion })}
192
+ >
193
+ <span className={radioIndicatorDotVariants({ variant: vResolved, size: sResolved })} />
194
+ </RadioGroupPrimitive.Indicator>
195
+ </RadioGroupPrimitive.Item>
196
+ )
197
+ })
198
+
199
+ // ── Label helper (touch-friendly row with peer-disabled) ────────────────────
200
+
201
+ export type RadioGroupLabelProps = React.ComponentPropsWithRef<typeof Label>
202
+
203
+ function RadioGroupLabel({ className, ...props }: RadioGroupLabelProps) {
204
+ return (
205
+ <Label
206
+ data-slot="radio-group-label"
207
+ className={cn(
208
+ "inline-flex min-h-11 cursor-pointer select-none items-center gap-2 py-1 -my-1 text-sm font-medium leading-snug",
209
+ "peer-disabled:cursor-not-allowed peer-disabled:opacity-60",
210
+ className
211
+ )}
212
+ {...props}
213
+ />
214
+ )
215
+ }
216
+
217
+ export { RadioGroup, RadioGroupItem, RadioGroupLabel, radioGroupItemVariants }
@@ -0,0 +1,191 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Select as SelectPrimitive } from "radix-ui"
5
+
6
+ import { cn } from "../../lib/utils"
7
+
8
+ function Select({
9
+ ...props
10
+ }: React.ComponentProps<typeof SelectPrimitive.Root>) {
11
+ return <SelectPrimitive.Root data-slot="select" {...props} />
12
+ }
13
+
14
+ function SelectGroup({
15
+ className,
16
+ ...props
17
+ }: React.ComponentProps<typeof SelectPrimitive.Group>) {
18
+ return (
19
+ <SelectPrimitive.Group
20
+ data-slot="select-group"
21
+ className={cn("scroll-my-1 p-1", className)}
22
+ {...props}
23
+ />
24
+ )
25
+ }
26
+
27
+ function SelectValue({
28
+ ...props
29
+ }: React.ComponentProps<typeof SelectPrimitive.Value>) {
30
+ return <SelectPrimitive.Value data-slot="select-value" {...props} />
31
+ }
32
+
33
+ function SelectTrigger({
34
+ className,
35
+ size = "default",
36
+ children,
37
+ ...props
38
+ }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
39
+ size?: "sm" | "default"
40
+ }) {
41
+ return (
42
+ <SelectPrimitive.Trigger
43
+ data-slot="select-trigger"
44
+ data-size={size}
45
+ className={cn(
46
+ "flex w-fit items-center justify-between gap-1.5 rounded-md border border-input bg-transparent py-2 pe-2 ps-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/15 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
47
+ className
48
+ )}
49
+ {...props}
50
+ >
51
+ {children}
52
+ <SelectPrimitive.Icon asChild>
53
+ <i className="fa-light fa-chevron-down pointer-events-none size-4 text-muted-foreground" aria-hidden="true" />
54
+ </SelectPrimitive.Icon>
55
+ </SelectPrimitive.Trigger>
56
+ )
57
+ }
58
+
59
+ function SelectContent({
60
+ className,
61
+ children,
62
+ position = "item-aligned",
63
+ align = "center",
64
+ ...props
65
+ }: React.ComponentProps<typeof SelectPrimitive.Content>) {
66
+ return (
67
+ <SelectPrimitive.Portal>
68
+ <SelectPrimitive.Content
69
+ data-slot="select-content"
70
+ data-align-trigger={position === "item-aligned"}
71
+ className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 rtl:data-[side=left]:translate-x-1 data-[side=right]:translate-x-1 rtl:data-[side=right]:-translate-x-1 data-[side=top]:-translate-y-1", className )}
72
+ position={position}
73
+ align={align}
74
+ {...props}
75
+ >
76
+ <SelectScrollUpButton />
77
+ <SelectPrimitive.Viewport
78
+ data-position={position}
79
+ className={cn(
80
+ "data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
81
+ position === "popper" && ""
82
+ )}
83
+ >
84
+ {children}
85
+ </SelectPrimitive.Viewport>
86
+ <SelectScrollDownButton />
87
+ </SelectPrimitive.Content>
88
+ </SelectPrimitive.Portal>
89
+ )
90
+ }
91
+
92
+ function SelectLabel({
93
+ className,
94
+ ...props
95
+ }: React.ComponentProps<typeof SelectPrimitive.Label>) {
96
+ return (
97
+ <SelectPrimitive.Label
98
+ data-slot="select-label"
99
+ className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
100
+ {...props}
101
+ />
102
+ )
103
+ }
104
+
105
+ function SelectItem({
106
+ className,
107
+ children,
108
+ ...props
109
+ }: React.ComponentProps<typeof SelectPrimitive.Item>) {
110
+ return (
111
+ <SelectPrimitive.Item
112
+ data-slot="select-item"
113
+ className={cn(
114
+ "relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
115
+ className
116
+ )}
117
+ {...props}
118
+ >
119
+ <span className="pointer-events-none absolute end-2 flex size-4 items-center justify-center">
120
+ <SelectPrimitive.ItemIndicator>
121
+ <i className="fa-light fa-check pointer-events-none" aria-hidden="true" />
122
+ </SelectPrimitive.ItemIndicator>
123
+ </span>
124
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
125
+ </SelectPrimitive.Item>
126
+ )
127
+ }
128
+
129
+ function SelectSeparator({
130
+ className,
131
+ ...props
132
+ }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
133
+ return (
134
+ <SelectPrimitive.Separator
135
+ data-slot="select-separator"
136
+ className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
137
+ {...props}
138
+ />
139
+ )
140
+ }
141
+
142
+ function SelectScrollUpButton({
143
+ className,
144
+ ...props
145
+ }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
146
+ return (
147
+ <SelectPrimitive.ScrollUpButton
148
+ data-slot="select-scroll-up-button"
149
+ className={cn(
150
+ "z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
151
+ className
152
+ )}
153
+ {...props}
154
+ >
155
+ <i className="fa-light fa-chevron-up" aria-hidden="true"
156
+ />
157
+ </SelectPrimitive.ScrollUpButton>
158
+ )
159
+ }
160
+
161
+ function SelectScrollDownButton({
162
+ className,
163
+ ...props
164
+ }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
165
+ return (
166
+ <SelectPrimitive.ScrollDownButton
167
+ data-slot="select-scroll-down-button"
168
+ className={cn(
169
+ "z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
170
+ className
171
+ )}
172
+ {...props}
173
+ >
174
+ <i className="fa-light fa-chevron-down" aria-hidden="true"
175
+ />
176
+ </SelectPrimitive.ScrollDownButton>
177
+ )
178
+ }
179
+
180
+ export {
181
+ Select,
182
+ SelectContent,
183
+ SelectGroup,
184
+ SelectItem,
185
+ SelectLabel,
186
+ SelectScrollDownButton,
187
+ SelectScrollUpButton,
188
+ SelectSeparator,
189
+ SelectTrigger,
190
+ SelectValue,
191
+ }
@@ -0,0 +1,246 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import { cn } from "../../lib/utils"
6
+ import { Label } from "./label"
7
+ import { RadioGroup, RadioGroupItem, type RadioGroupProps } from "./radio-group"
8
+
9
+ export interface SelectionTileOption<T extends string = string> {
10
+ value: T
11
+ label: string
12
+ /** Font Awesome icon class without prefix (e.g. `fa-table`); rendered with `fa-light`. Ignored when `leading` is set. */
13
+ icon?: string
14
+ /** Custom graphic (SVG, swatch, etc.) instead of `icon`. */
15
+ leading?: React.ReactNode
16
+ }
17
+
18
+ /** Shared surface classes for icon+label tiles (Properties view type, Export format, etc.). */
19
+ export function selectionTileClassNames(selected: boolean) {
20
+ return cn(
21
+ "flex flex-col items-center justify-center gap-1.5 rounded-lg border py-3 text-xs transition-colors",
22
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
23
+ selected
24
+ ? "border-brand bg-brand/5 text-foreground font-medium shadow-sm"
25
+ : "border-border bg-background text-muted-foreground hover:border-border/80 hover:text-interactive-hover-foreground",
26
+ )
27
+ }
28
+
29
+ /** Preview box only — label rendered below (Settings appearance pattern). */
30
+ export function selectionTilePreviewClassNames(selected: boolean) {
31
+ return cn(
32
+ "relative box-border flex aspect-square w-full max-w-full flex-col rounded-lg border p-1 transition-colors",
33
+ selected
34
+ ? "border-brand bg-brand/5 shadow-sm"
35
+ : "border-border bg-background hover:border-border/80",
36
+ )
37
+ }
38
+
39
+ function SelectionTileGraphic<T extends string>({
40
+ option,
41
+ selected,
42
+ }: {
43
+ option: SelectionTileOption<T>
44
+ selected: boolean
45
+ }) {
46
+ if (option.leading != null) {
47
+ return (
48
+ <span className="flex h-full min-h-0 w-full min-w-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:block [&_svg]:h-full [&_svg]:w-auto [&_svg]:max-h-full [&_svg]:max-w-full [&_svg]:object-contain [&_svg]:object-center">
49
+ {option.leading}
50
+ </span>
51
+ )
52
+ }
53
+ if (option.icon) {
54
+ return (
55
+ <i
56
+ className={cn(
57
+ "fa-light shrink-0 text-[18px] leading-none",
58
+ option.icon,
59
+ selected && "text-brand",
60
+ )}
61
+ aria-hidden="true"
62
+ />
63
+ )
64
+ }
65
+ return null
66
+ }
67
+
68
+ function SelectionTileLabelText<T extends string>({
69
+ option,
70
+ }: {
71
+ option: SelectionTileOption<T>
72
+ }) {
73
+ return <span className="text-center leading-tight">{option.label}</span>
74
+ }
75
+
76
+ export interface SelectionTileGridProps<T extends string> {
77
+ /** Section caption above the grid (e.g. “View type”). */
78
+ sectionLabel?: string
79
+ options: readonly SelectionTileOption<T>[]
80
+ columns?: 2 | 3 | 4
81
+ value: T
82
+ onValueChange: (value: T) => void
83
+ /** `radio` — Form / RadioGroup; `button` — toggle buttons with aria-pressed. */
84
+ interaction: "radio" | "button"
85
+ /** Prefix for radio ids (`${idPrefix}-${value}`). */
86
+ idPrefix?: string
87
+ /** Forwarded to `RadioGroup` when `interaction="radio"` (sr-only inputs; affects focus ring / state tokens). */
88
+ itemVariant?: RadioGroupProps["itemVariant"]
89
+ itemSize?: RadioGroupProps["itemSize"]
90
+ itemMotion?: RadioGroupProps["itemMotion"]
91
+ className?: string
92
+ /**
93
+ * `inside` — label in the bordered tile (default). `below` — label under the preview
94
+ * (matches system settings: preview box + caption outside).
95
+ */
96
+ labelPlacement?: "inside" | "below"
97
+ }
98
+
99
+ /**
100
+ * Icon tile grid for single selection — matches Properties “View type” and Export “File format” patterns.
101
+ */
102
+ function SelectionTileCaptionBelow<T extends string>({
103
+ option,
104
+ selected,
105
+ ariaHidden = false,
106
+ }: {
107
+ option: SelectionTileOption<T>
108
+ selected: boolean
109
+ /** When the parent `button` already has `aria-label` (avoids duplicate SR output). */
110
+ ariaHidden?: boolean
111
+ }) {
112
+ return (
113
+ <span
114
+ className={cn(
115
+ "max-w-full px-0.5 text-center text-xs leading-tight",
116
+ selected ? "font-medium text-foreground" : "text-muted-foreground",
117
+ )}
118
+ {...(ariaHidden ? { "aria-hidden": true as const } : {})}
119
+ >
120
+ {option.label}
121
+ </span>
122
+ )
123
+ }
124
+
125
+ export function SelectionTileGrid<T extends string>({
126
+ sectionLabel,
127
+ options,
128
+ columns = 4,
129
+ value,
130
+ onValueChange,
131
+ interaction,
132
+ idPrefix = "tile",
133
+ className,
134
+ labelPlacement = "inside",
135
+ itemVariant,
136
+ itemSize,
137
+ itemMotion,
138
+ }: SelectionTileGridProps<T>) {
139
+ const gridClass = cn(
140
+ "gap-2",
141
+ columns === 2 && "grid grid-cols-2",
142
+ columns === 3 && "grid grid-cols-3",
143
+ columns === 4 && "grid grid-cols-4",
144
+ )
145
+
146
+ if (interaction === "radio") {
147
+ return (
148
+ <div className={className}>
149
+ {sectionLabel ? (
150
+ <p className="mb-2 text-xs font-medium text-muted-foreground">{sectionLabel}</p>
151
+ ) : null}
152
+ <RadioGroup
153
+ value={value}
154
+ onValueChange={v => onValueChange(v as T)}
155
+ className={gridClass}
156
+ itemVariant={itemVariant}
157
+ itemSize={itemSize}
158
+ itemMotion={itemMotion}
159
+ >
160
+ {options.map(opt => {
161
+ const selected = value === opt.value
162
+ const id = `${idPrefix}-${opt.value}`
163
+ if (labelPlacement === "below") {
164
+ return (
165
+ <Label
166
+ key={opt.value}
167
+ htmlFor={id}
168
+ className={cn(
169
+ "flex min-w-0 cursor-pointer flex-col items-center gap-1.5 rounded-lg focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background",
170
+ )}
171
+ >
172
+ <span className={cn("flex w-full justify-center", selectionTilePreviewClassNames(selected))}>
173
+ <RadioGroupItem value={opt.value} id={id} className="sr-only" />
174
+ <span className="flex min-h-0 w-full min-w-0 flex-1 items-center justify-center">
175
+ <SelectionTileGraphic option={opt} selected={selected} />
176
+ </span>
177
+ </span>
178
+ <SelectionTileCaptionBelow option={opt} selected={selected} />
179
+ </Label>
180
+ )
181
+ }
182
+ return (
183
+ <Label
184
+ key={opt.value}
185
+ htmlFor={id}
186
+ className={cn(
187
+ "cursor-pointer rounded-lg focus-within:rounded-lg focus-within:ring-2 focus-within:ring-ring focus-within:outline-none",
188
+ selectionTileClassNames(selected),
189
+ )}
190
+ >
191
+ <RadioGroupItem value={opt.value} id={id} className="sr-only" />
192
+ <SelectionTileGraphic option={opt} selected={selected} />
193
+ <SelectionTileLabelText option={opt} />
194
+ </Label>
195
+ )
196
+ })}
197
+ </RadioGroup>
198
+ </div>
199
+ )
200
+ }
201
+
202
+ return (
203
+ <div className={className}>
204
+ {sectionLabel ? (
205
+ <p className="mb-2 text-xs font-medium text-muted-foreground">{sectionLabel}</p>
206
+ ) : null}
207
+ <div className={gridClass}>
208
+ {options.map(opt => {
209
+ const selected = value === opt.value
210
+ if (labelPlacement === "below") {
211
+ return (
212
+ <button
213
+ key={opt.value}
214
+ type="button"
215
+ aria-label={opt.label}
216
+ aria-pressed={selected}
217
+ onClick={() => onValueChange(opt.value)}
218
+ className="flex min-w-0 flex-col items-center gap-1.5 rounded-lg border-0 bg-transparent p-0 text-inherit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
219
+ >
220
+ <span className={cn("flex w-full justify-center", selectionTilePreviewClassNames(selected))}>
221
+ <span className="flex min-h-0 w-full min-w-0 flex-1 items-center justify-center">
222
+ <SelectionTileGraphic option={opt} selected={selected} />
223
+ </span>
224
+ </span>
225
+ <SelectionTileCaptionBelow option={opt} selected={selected} ariaHidden />
226
+ </button>
227
+ )
228
+ }
229
+ return (
230
+ <button
231
+ key={opt.value}
232
+ type="button"
233
+ aria-label={opt.label}
234
+ aria-pressed={selected}
235
+ onClick={() => onValueChange(opt.value)}
236
+ className={selectionTileClassNames(selected)}
237
+ >
238
+ <SelectionTileGraphic option={opt} selected={selected} />
239
+ <SelectionTileLabelText option={opt} />
240
+ </button>
241
+ )
242
+ })}
243
+ </div>
244
+ </div>
245
+ )
246
+ }
@@ -0,0 +1,28 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Separator as SeparatorPrimitive } from "radix-ui"
5
+
6
+ import { cn } from "../../lib/utils"
7
+
8
+ function Separator({
9
+ className,
10
+ orientation = "horizontal",
11
+ decorative = true,
12
+ ...props
13
+ }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
14
+ return (
15
+ <SeparatorPrimitive.Root
16
+ data-slot="separator"
17
+ decorative={decorative}
18
+ orientation={orientation}
19
+ className={cn(
20
+ "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
21
+ className
22
+ )}
23
+ {...props}
24
+ />
25
+ )
26
+ }
27
+
28
+ export { Separator }