@spree/dashboard-ui 0.13.1 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +1 -1
  2. package/package.json +15 -9
  3. package/src/hooks/use-prefers-reduced-motion.ts +26 -0
  4. package/src/hooks/use-scrolled.ts +34 -14
  5. package/src/index.ts +39 -1
  6. package/src/lib/date-range-presets.ts +79 -0
  7. package/src/lib/same-rich-text.ts +145 -0
  8. package/src/spree/address-block.tsx +5 -4
  9. package/src/spree/address-book-row.tsx +96 -0
  10. package/src/spree/address-map.tsx +62 -0
  11. package/src/spree/back-button.tsx +14 -9
  12. package/src/spree/bulk-dialog.tsx +2 -2
  13. package/src/spree/bulk-price-table.tsx +145 -14
  14. package/src/spree/bulk-variants-table.tsx +434 -0
  15. package/src/spree/calculator-summary.tsx +5 -1
  16. package/src/spree/claim-resolve-dialog.tsx +219 -0
  17. package/src/spree/confirm-dialog.tsx +1 -1
  18. package/src/spree/copy-to-clipboard-button.tsx +1 -1
  19. package/src/spree/data-grid/cells.tsx +199 -25
  20. package/src/spree/data-grid/context.ts +6 -0
  21. package/src/spree/data-grid/data-grid.tsx +149 -7
  22. package/src/spree/data-grid/helpers.ts +6 -2
  23. package/src/spree/data-grid/index.ts +9 -1
  24. package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
  25. package/src/spree/delivery-form-dialog.tsx +166 -0
  26. package/src/spree/drag-handle.tsx +2 -2
  27. package/src/spree/editable-note-card.tsx +103 -0
  28. package/src/spree/external-link.tsx +56 -0
  29. package/src/spree/file-type-icon.tsx +68 -0
  30. package/src/spree/form-actions.tsx +2 -3
  31. package/src/spree/form-section.tsx +46 -0
  32. package/src/spree/fulfill-items-form.tsx +282 -0
  33. package/src/spree/fulfillment-edit-dialog.tsx +211 -0
  34. package/src/spree/fulfillment-item-list.tsx +61 -0
  35. package/src/spree/fulfillment-panel.tsx +53 -0
  36. package/src/spree/icons.ts +167 -0
  37. package/src/spree/json-preview-drawer.tsx +4 -4
  38. package/src/spree/label-upload-dialog.tsx +195 -0
  39. package/src/spree/language-menu-items.tsx +37 -30
  40. package/src/spree/metadata/metadata-card.tsx +1 -1
  41. package/src/spree/order-cancel-dialog.tsx +149 -0
  42. package/src/spree/post-sale-create-dialogs.tsx +253 -0
  43. package/src/spree/post-sale-fields.tsx +133 -0
  44. package/src/spree/product-membership-list.tsx +453 -0
  45. package/src/spree/products/status-card.tsx +79 -0
  46. package/src/spree/quantity-tier-editor.tsx +162 -0
  47. package/src/spree/resource-combobox.tsx +12 -1
  48. package/src/spree/resource-name-cell.tsx +21 -4
  49. package/src/spree/return-dialogs.tsx +238 -0
  50. package/src/spree/route-error-boundary.tsx +1 -1
  51. package/src/spree/row-actions.tsx +2 -2
  52. package/src/spree/search-input.tsx +76 -0
  53. package/src/spree/secret-input.tsx +26 -40
  54. package/src/spree/tax-identifiers-card.tsx +418 -0
  55. package/src/spree/theme-toggle.tsx +36 -23
  56. package/src/spree/wizard.tsx +254 -0
  57. package/src/styles.css +750 -75
  58. package/src/ui/accordion.tsx +83 -0
  59. package/src/ui/alert.tsx +79 -0
  60. package/src/ui/attachment.tsx +1 -1
  61. package/src/ui/badge.tsx +198 -56
  62. package/src/ui/breadcrumb.tsx +1 -1
  63. package/src/ui/button.tsx +22 -10
  64. package/src/ui/calendar.tsx +1 -1
  65. package/src/ui/card.tsx +52 -6
  66. package/src/ui/checkbox.tsx +13 -5
  67. package/src/ui/combobox.tsx +125 -6
  68. package/src/ui/command.tsx +149 -23
  69. package/src/ui/context-menu.tsx +99 -0
  70. package/src/ui/data-table.tsx +448 -9
  71. package/src/ui/date-picker.tsx +18 -3
  72. package/src/ui/date-range-picker.tsx +47 -60
  73. package/src/ui/dialog.tsx +6 -6
  74. package/src/ui/dropdown-menu.tsx +18 -11
  75. package/src/ui/field.tsx +10 -2
  76. package/src/ui/input-group.tsx +1 -1
  77. package/src/ui/input.tsx +1 -1
  78. package/src/ui/map.tsx +2110 -0
  79. package/src/ui/media-preview.tsx +105 -0
  80. package/src/ui/pagination.tsx +19 -11
  81. package/src/ui/popover.tsx +4 -2
  82. package/src/ui/progress.tsx +5 -2
  83. package/src/ui/radio-group.tsx +4 -4
  84. package/src/ui/rich-text-editor.tsx +214 -88
  85. package/src/ui/scroll-area.tsx +65 -0
  86. package/src/ui/select.tsx +17 -10
  87. package/src/ui/separator.tsx +1 -1
  88. package/src/ui/sheet.tsx +49 -7
  89. package/src/ui/sidebar.tsx +138 -37
  90. package/src/ui/skeleton.tsx +3 -1
  91. package/src/ui/tabs.tsx +84 -0
  92. package/src/ui/textarea.tsx +1 -1
  93. package/src/ui/thumbnail.tsx +80 -0
  94. package/src/ui/toast.tsx +112 -0
  95. package/src/ui/tooltip.tsx +3 -1
  96. package/src/ui/sonner.tsx +0 -47
@@ -1,10 +1,16 @@
1
- import { format, startOfMonth, startOfYear, subDays } from 'date-fns'
2
- import { CalendarIcon, ChevronDownIcon } from 'lucide-react'
3
- import { useState } from 'react'
1
+ import { format } from 'date-fns'
2
+ import { useMemo, useState } from 'react'
4
3
  import type { DateRange as DayPickerDateRange } from 'react-day-picker'
5
4
  import { useTranslation } from 'react-i18next'
6
5
  import { activeDateLocale } from '../lib/date-locale'
6
+ import {
7
+ DATE_RANGE_PRESET_KEYS,
8
+ type DateRangePresetKey,
9
+ matchDateRangePreset,
10
+ resolveDateRangePreset,
11
+ } from '../lib/date-range-presets'
7
12
  import { cn } from '../lib/utils'
13
+ import { CalendarIcon, ChevronDownIcon } from '../spree/icons'
8
14
  import { Button } from './button'
9
15
  import { Calendar } from './calendar'
10
16
  import { Popover, PopoverContent, PopoverTrigger } from './popover'
@@ -14,79 +20,58 @@ export interface DateRange {
14
20
  to: Date
15
21
  }
16
22
 
17
- type PresetKey = '7d' | '14d' | '30d' | '90d' | 'this_month' | 'ytd' | 'custom'
18
-
19
- interface Preset {
20
- key: PresetKey
21
- labelKey: string
22
- value: () => DateRange
23
+ const PRESET_LABEL_KEYS: Record<Exclude<DateRangePresetKey, 'custom'>, string> = {
24
+ '7d': 'admin.components.date_range_picker.presets.last_7_days',
25
+ '14d': 'admin.components.date_range_picker.presets.last_14_days',
26
+ '30d': 'admin.components.date_range_picker.presets.last_30_days',
27
+ '90d': 'admin.components.date_range_picker.presets.last_90_days',
28
+ this_month: 'admin.components.date_range_picker.presets.this_month',
29
+ ytd: 'admin.components.date_range_picker.presets.year_to_date',
23
30
  }
24
31
 
25
- const presets: Preset[] = [
26
- {
27
- key: '7d',
28
- labelKey: 'admin.components.date_range_picker.presets.last_7_days',
29
- value: () => ({ from: subDays(new Date(), 7), to: new Date() }),
30
- },
31
- {
32
- key: '14d',
33
- labelKey: 'admin.components.date_range_picker.presets.last_14_days',
34
- value: () => ({ from: subDays(new Date(), 14), to: new Date() }),
35
- },
36
- {
37
- key: '30d',
38
- labelKey: 'admin.components.date_range_picker.presets.last_30_days',
39
- value: () => ({ from: subDays(new Date(), 30), to: new Date() }),
40
- },
41
- {
42
- key: '90d',
43
- labelKey: 'admin.components.date_range_picker.presets.last_90_days',
44
- value: () => ({ from: subDays(new Date(), 90), to: new Date() }),
45
- },
46
- {
47
- key: 'this_month',
48
- labelKey: 'admin.components.date_range_picker.presets.this_month',
49
- value: () => ({ from: startOfMonth(new Date()), to: new Date() }),
50
- },
51
- {
52
- key: 'ytd',
53
- labelKey: 'admin.components.date_range_picker.presets.year_to_date',
54
- value: () => ({ from: startOfYear(new Date()), to: new Date() }),
55
- },
56
- ]
57
-
58
- interface DateRangePickerProps {
32
+ export interface DateRangePickerProps {
59
33
  value: DateRange
60
34
  onChange: (range: DateRange) => void
35
+ /**
36
+ * IANA timezone used to resolve relative presets ("last 30 days").
37
+ * Defaults to the browser zone. Pass the store timezone on admin
38
+ * surfaces so every merchant sees the same span.
39
+ */
40
+ timezone?: string
61
41
  }
62
42
 
63
- export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
43
+ export function DateRangePicker({ value, onChange, timezone }: DateRangePickerProps) {
64
44
  const { t } = useTranslation()
65
45
  const [open, setOpen] = useState(false)
66
- const [activePreset, setActivePreset] = useState<PresetKey>('30d')
67
46
  const [calendarRange, setCalendarRange] = useState<DayPickerDateRange | undefined>()
47
+ const [month, setMonth] = useState<Date>(value.from)
68
48
 
69
- const activePresetLabelKey =
70
- presets.find((p) => p.key === activePreset)?.labelKey ??
71
- 'admin.components.date_range_picker.presets.last_30_days'
49
+ const zone = useMemo(
50
+ () => timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC',
51
+ [timezone],
52
+ )
53
+ // Derived from `value` so a remount — or a range written by the parent —
54
+ // keeps the trigger and the highlighted preset in sync. A second piece of
55
+ // preset state is what made "Last 90 days" revert to "Last 30 days"
56
+ // after the dashboard swapped the picker for a skeleton.
57
+ const activePreset = matchDateRangePreset(value, zone)
72
58
 
73
59
  const dateLocale = activeDateLocale()
74
60
  const triggerLabel =
75
61
  activePreset === 'custom'
76
62
  ? `${format(value.from, 'MMM d', { locale: dateLocale })} – ${format(value.to, 'MMM d', { locale: dateLocale })}`
77
- : t(activePresetLabelKey)
63
+ : t(PRESET_LABEL_KEYS[activePreset])
78
64
 
79
- function selectPreset(preset: Preset) {
80
- const range = preset.value()
81
- setActivePreset(preset.key)
65
+ function selectPreset(key: Exclude<DateRangePresetKey, 'custom'>) {
66
+ const range = resolveDateRangePreset(key, zone)
82
67
  setCalendarRange({ from: range.from, to: range.to })
68
+ setMonth(range.from)
83
69
  onChange(range)
84
70
  setOpen(false)
85
71
  }
86
72
 
87
73
  function applyCustomRange() {
88
74
  if (calendarRange?.from && calendarRange?.to) {
89
- setActivePreset('custom')
90
75
  onChange({ from: calendarRange.from, to: calendarRange.to })
91
76
  setOpen(false)
92
77
  }
@@ -101,6 +86,7 @@ export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
101
86
  setOpen(nextOpen)
102
87
  if (nextOpen) {
103
88
  setCalendarRange({ from: value.from, to: value.to })
89
+ setMonth(value.from)
104
90
  }
105
91
  }}
106
92
  >
@@ -114,25 +100,26 @@ export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
114
100
  <PopoverContent align="end" className="w-auto p-0">
115
101
  <div className="flex">
116
102
  <div className="flex flex-col border-r py-2">
117
- {presets.map((preset) => (
103
+ {DATE_RANGE_PRESET_KEYS.map((key) => (
118
104
  <button
119
- key={preset.key}
105
+ key={key}
120
106
  type="button"
121
- onClick={() => selectPreset(preset)}
107
+ onClick={() => selectPreset(key)}
122
108
  className={cn(
123
109
  'whitespace-nowrap px-4 py-1.5 text-left text-sm transition-colors hover:bg-accent',
124
- activePreset === preset.key && 'font-medium text-foreground',
125
- activePreset !== preset.key && 'text-muted-foreground',
110
+ activePreset === key && 'font-medium text-foreground',
111
+ activePreset !== key && 'text-muted-foreground',
126
112
  )}
127
113
  >
128
- {t(preset.labelKey)}
114
+ {t(PRESET_LABEL_KEYS[key])}
129
115
  </button>
130
116
  ))}
131
117
  </div>
132
118
  <div className="flex flex-col p-3">
133
119
  <Calendar
134
120
  mode="range"
135
- defaultMonth={calendarRange?.from}
121
+ month={month}
122
+ onMonthChange={setMonth}
136
123
  selected={calendarRange}
137
124
  onSelect={setCalendarRange}
138
125
  numberOfMonths={2}
package/src/ui/dialog.tsx CHANGED
@@ -1,8 +1,8 @@
1
1
  import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
2
2
  import i18n from 'i18next'
3
- import { XIcon } from 'lucide-react'
4
3
  import * as React from 'react'
5
4
  import { cn } from '../lib/utils'
5
+ import { XIcon } from '../spree/icons'
6
6
  import { Button } from './button'
7
7
 
8
8
  function Dialog({
@@ -83,7 +83,7 @@ function DialogOverlay({
83
83
  <DialogPrimitive.Backdrop
84
84
  data-slot="dialog-overlay"
85
85
  className={cn(
86
- 'fixed inset-0 isolate z-50 bg-gray-100/75 dark:bg-black/60 duration-100 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 transition-opacity',
86
+ 'fixed inset-0 isolate z-50 bg-background/60 dark:bg-black/60 duration-100 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 transition-opacity',
87
87
  className,
88
88
  )}
89
89
  {...props}
@@ -105,7 +105,7 @@ function DialogContent({
105
105
  <DialogPrimitive.Popup
106
106
  data-slot="dialog-content"
107
107
  className={cn(
108
- 'fixed top-1/2 left-1/2 z-50 flex w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl border border-border bg-card text-foreground text-sm shadow-lg duration-100 outline-none sm:max-w-[500px] data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 transition-[opacity,transform]',
108
+ 'fixed top-1/2 left-1/2 z-50 flex w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl border border-border bg-card text-foreground text-sm shadow-lg duration-100 outline-none sm:max-w-[min(500px,calc(100%-2rem))] data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 transition-[opacity,transform,translate,scale]',
109
109
  className,
110
110
  )}
111
111
  style={{ maxHeight: '90vh' }}
@@ -132,7 +132,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
132
132
  return (
133
133
  <div
134
134
  data-slot="dialog-header"
135
- className={cn('flex flex-col gap-1.5 border-b p-4', className)}
135
+ className={cn('flex flex-col gap-1.5 border-b border-border-subtle p-4', className)}
136
136
  {...props}
137
137
  />
138
138
  )
@@ -160,7 +160,7 @@ function DialogFooter({
160
160
  <div
161
161
  data-slot="dialog-footer"
162
162
  className={cn(
163
- 'flex flex-col-reverse gap-2 border-t bg-muted/50 p-4 sm:flex-row sm:justify-end',
163
+ 'flex flex-col-reverse gap-2 border-t border-border-subtle bg-muted/50 p-4 sm:flex-row sm:justify-end',
164
164
  className,
165
165
  )}
166
166
  {...props}
@@ -177,7 +177,7 @@ function DialogTitle({ className, ...props }: React.ComponentProps<typeof Dialog
177
177
  return (
178
178
  <DialogPrimitive.Title
179
179
  data-slot="dialog-title"
180
- className={cn('text-base leading-none font-medium', className)}
180
+ className={cn('text-lg font-medium', className)}
181
181
  {...props}
182
182
  />
183
183
  )
@@ -1,9 +1,9 @@
1
1
  'use client'
2
2
 
3
3
  import { Menu as MenuPrimitive } from '@base-ui/react/menu'
4
- import { CheckIcon, ChevronRightIcon } from 'lucide-react'
5
4
  import * as React from 'react'
6
5
  import { cn } from '../lib/utils'
6
+ import { CheckIcon, ChevronRightIcon } from '../spree/icons'
7
7
 
8
8
  function DropdownMenu({
9
9
  children,
@@ -81,7 +81,9 @@ function DropdownMenuContent({
81
81
  <MenuPrimitive.Popup
82
82
  data-slot="dropdown-menu-content"
83
83
  className={cn(
84
- 'z-50 max-h-[var(--available-height)] min-w-40 max-w-80 overflow-x-hidden overflow-y-auto rounded-2xl border border-border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 transition-[opacity,transform]',
84
+ // `origin-(--transform-origin)` so the menu grows out of its
85
+ // trigger rather than its own centre.
86
+ 'z-50 max-h-[var(--available-height)] min-w-40 max-w-80 overflow-x-hidden overflow-y-auto rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-lg origin-(--transform-origin) duration-100 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 transition-[opacity,transform,translate,scale]',
85
87
  className,
86
88
  )}
87
89
  {...props}
@@ -117,7 +119,7 @@ function DropdownMenuItem({
117
119
  data-variant={variant}
118
120
  render={children}
119
121
  className={cn(
120
- "group/dropdown-menu-item relative flex cursor-default items-center gap-3 rounded-xl px-2.5 py-2 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
122
+ "group/dropdown-menu-item relative flex cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
121
123
  className,
122
124
  )}
123
125
  {...props}
@@ -131,7 +133,7 @@ function DropdownMenuItem({
131
133
  data-inset={inset}
132
134
  data-variant={variant}
133
135
  className={cn(
134
- "group/dropdown-menu-item relative flex cursor-default items-center gap-3 rounded-xl px-2.5 py-2 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
136
+ "group/dropdown-menu-item relative flex cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
135
137
  className,
136
138
  )}
137
139
  {...props}
@@ -158,7 +160,7 @@ function DropdownMenuCheckboxItem({
158
160
  data-slot="dropdown-menu-checkbox-item"
159
161
  data-inset={inset}
160
162
  className={cn(
161
- "relative flex cursor-default items-center gap-3 rounded-xl py-2 pr-8 pl-2.5 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
163
+ "relative flex cursor-pointer items-center gap-3 rounded-lg py-1.5 pr-8 pl-2.5 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
162
164
  className,
163
165
  )}
164
166
  checked={checked}
@@ -197,7 +199,7 @@ function DropdownMenuRadioItem({
197
199
  data-slot="dropdown-menu-radio-item"
198
200
  data-inset={inset}
199
201
  className={cn(
200
- "relative flex cursor-default items-center gap-3 rounded-xl py-2 pr-8 pl-2.5 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
202
+ "relative flex cursor-pointer items-center gap-3 rounded-lg py-1.5 pr-8 pl-2.5 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
201
203
  className,
202
204
  )}
203
205
  {...props}
@@ -228,7 +230,7 @@ function DropdownMenuLabel({
228
230
  data-slot="dropdown-menu-label"
229
231
  data-inset={inset}
230
232
  className={cn(
231
- 'px-2.5 py-2 text-sm font-normal text-muted-foreground data-inset:pl-7',
233
+ 'px-2 py-1.5 text-sm font-normal text-muted-foreground data-inset:pl-7',
232
234
  className,
233
235
  )}
234
236
  {...props}
@@ -244,7 +246,7 @@ function DropdownMenuSeparator({
244
246
  return (
245
247
  <MenuPrimitive.Separator
246
248
  data-slot="dropdown-menu-separator"
247
- className={cn('-mx-1 my-1 h-px bg-border', className)}
249
+ className={cn('mx-1 my-1 h-px bg-border-subtle', className)}
248
250
  {...props}
249
251
  />
250
252
  )
@@ -280,22 +282,25 @@ function DropdownMenuSubTrigger({
280
282
  className,
281
283
  inset,
282
284
  children,
285
+ hideChevron,
283
286
  ...props
284
287
  }: React.ComponentProps<typeof MenuPrimitive.SubmenuTrigger> & {
285
288
  inset?: boolean
289
+ /** Suppress the default trailing chevron when the trigger renders its own affordance. */
290
+ hideChevron?: boolean
286
291
  }) {
287
292
  return (
288
293
  <MenuPrimitive.SubmenuTrigger
289
294
  data-slot="dropdown-menu-sub-trigger"
290
295
  data-inset={inset}
291
296
  className={cn(
292
- "flex cursor-default items-center gap-3 rounded-xl px-2.5 py-2 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[popup-open]:bg-accent data-[popup-open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
297
+ "flex cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[popup-open]:bg-accent data-[popup-open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
293
298
  className,
294
299
  )}
295
300
  {...props}
296
301
  >
297
302
  {children}
298
- <ChevronRightIcon className="ml-auto" />
303
+ {!hideChevron && <ChevronRightIcon className="ml-auto" />}
299
304
  </MenuPrimitive.SubmenuTrigger>
300
305
  )
301
306
  }
@@ -314,7 +319,9 @@ function DropdownMenuSubContent({
314
319
  <MenuPrimitive.Popup
315
320
  data-slot="dropdown-menu-sub-content"
316
321
  className={cn(
317
- 'z-50 min-w-[96px] overflow-hidden rounded-2xl border border-border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 transition-[opacity,transform]',
322
+ // `origin-(--transform-origin)` so the submenu grows out of its
323
+ // parent item rather than its own centre.
324
+ 'z-50 min-w-[96px] overflow-hidden rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-lg origin-(--transform-origin) duration-100 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 transition-[opacity,transform,translate,scale]',
318
325
  className,
319
326
  )}
320
327
  {...props}
package/src/ui/field.tsx CHANGED
@@ -9,7 +9,7 @@ function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
9
9
  <fieldset
10
10
  data-slot="field-set"
11
11
  className={cn(
12
- 'flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
12
+ 'flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 rounded-md p-3 border border-border-subtle shadow-xs bg-card',
13
13
  className,
14
14
  )}
15
15
  {...props}
@@ -27,7 +27,7 @@ function FieldLegend({
27
27
  data-slot="field-legend"
28
28
  data-variant={variant}
29
29
  className={cn(
30
- 'mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base',
30
+ 'mb-1.5 font-semibold data-[variant=label]:text-sm data-[variant=legend]:text-base',
31
31
  className,
32
32
  )}
33
33
  {...props}
@@ -95,6 +95,14 @@ function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>)
95
95
  data-slot="field-label"
96
96
  className={cn(
97
97
  'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-blue-500/30 has-data-checked:bg-blue-500/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-blue-500/20 dark:has-data-checked:bg-blue-500/10',
98
+ // Card-shaped labels (the ones wrapping a whole `Field`) are clickable
99
+ // targets, so they need the affordance a bare text label doesn't:
100
+ // a pointer and a hover tint. Scoped by `has-[>[data-slot=field]]` so
101
+ // plain field labels keep their default cursor, and excluded on the
102
+ // checked card by `not-has-data-checked` — a competing hover rule of
103
+ // equal specificity would just race the selected tint and win.
104
+ 'has-[>[data-slot=field]]:cursor-pointer has-[>[data-slot=field]]:transition-colors',
105
+ 'has-[>[data-slot=field]]:not-has-data-checked:hover:border-border has-[>[data-slot=field]]:not-has-data-checked:hover:bg-accent/60',
98
106
  'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col',
99
107
  className,
100
108
  )}
@@ -11,7 +11,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
11
11
  data-slot="input-group"
12
12
  role="group"
13
13
  className={cn(
14
- 'group/input-group relative flex w-full min-w-0 items-center rounded-lg border border-border bg-card text-foreground shadow-xs transition-all duration-100 ease-in-out outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:shadow-none has-disabled:bg-muted has-disabled:border-border has-disabled:opacity-50 has-disabled:shadow-none has-[[data-slot=input-group-control]:focus-visible]:border-blue-500 has-[[data-slot=input-group-control]:focus-visible]:shadow-[0_0_0_3px_rgba(59,130,246,0.15)] has-[[data-slot][aria-invalid=true]]:border-destructive has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5',
14
+ 'group/input-group relative flex w-full min-w-0 items-center rounded-md border border-border bg-card text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:shadow-none has-disabled:bg-muted has-disabled:cursor-not-allowed has-disabled:text-muted-foreground has-[[data-slot=input-group-control]:focus-visible]:border-blue-500 has-[[data-slot=input-group-control]:focus-visible]:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] has-[[data-slot][aria-invalid=true]]:border-destructive has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5',
15
15
  className,
16
16
  )}
17
17
  {...props}
package/src/ui/input.tsx CHANGED
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
8
8
  type={type}
9
9
  data-slot="input"
10
10
  className={cn(
11
- 'block w-full min-w-0 min-h-8 rounded-lg border border-border bg-card py-1.5 px-2.5 text-base font-normal leading-normal text-foreground shadow-xs transition-all duration-100 ease-in-out outline-none placeholder:text-muted-foreground focus:border-blue-500 focus:shadow-[0_0_0_3px_rgba(59,130,246,0.15)] disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:border-border disabled:text-muted-foreground disabled:shadow-none aria-invalid:border-destructive file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground',
11
+ 'block w-full min-w-0 min-h-8 rounded-md border border-border bg-card py-1.5 px-2.5 text-base font-normal leading-normal text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out outline-none placeholder:text-muted-foreground focus:border-blue-500 focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground aria-invalid:border-destructive file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground',
12
12
  className,
13
13
  )}
14
14
  {...props}