@spree/dashboard-ui 6.0.3 → 6.0.4

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 (84) hide show
  1. package/package.json +20 -19
  2. package/src/index.ts +5 -0
  3. package/src/lib/field-trigger.ts +10 -0
  4. package/src/lib/same-rich-text.ts +12 -3
  5. package/src/lib/utils.ts +14 -1
  6. package/src/spree/address-block.tsx +3 -3
  7. package/src/spree/address-book-row.tsx +88 -0
  8. package/src/spree/bulk-price-table.tsx +3 -3
  9. package/src/spree/claim-resolve-dialog.tsx +1 -1
  10. package/src/spree/collapsible-card.tsx +2 -2
  11. package/src/spree/color-picker.tsx +1 -1
  12. package/src/spree/currency-label.tsx +71 -0
  13. package/src/spree/data-grid/cells.tsx +2 -2
  14. package/src/spree/data-grid/data-grid.tsx +2 -2
  15. package/src/spree/delivery-form-dialog.tsx +1 -1
  16. package/src/spree/drag-handle.tsx +1 -1
  17. package/src/spree/editable-note-card.tsx +2 -2
  18. package/src/spree/form-actions.tsx +32 -10
  19. package/src/spree/fulfill-items-form.tsx +20 -22
  20. package/src/spree/fulfillment-edit-dialog.tsx +1 -1
  21. package/src/spree/fulfillment-item-list.tsx +87 -28
  22. package/src/spree/fulfillment-panel.tsx +2 -2
  23. package/src/spree/icons.ts +8 -0
  24. package/src/spree/json-preview-drawer.tsx +32 -7
  25. package/src/spree/json-value-view.tsx +14 -4
  26. package/src/spree/label-upload-dialog.tsx +1 -1
  27. package/src/spree/language-menu-items.tsx +1 -1
  28. package/src/spree/metadata/metadata-card.tsx +2 -2
  29. package/src/spree/order-cancel-dialog.tsx +3 -3
  30. package/src/spree/post-sale-create-dialogs.tsx +36 -33
  31. package/src/spree/post-sale-fields.tsx +30 -1
  32. package/src/spree/product-identity.tsx +74 -0
  33. package/src/spree/product-membership-list.tsx +79 -41
  34. package/src/spree/products/status-card.tsx +1 -1
  35. package/src/spree/quantity-tier-editor.tsx +1 -1
  36. package/src/spree/return-dialogs.tsx +2 -2
  37. package/src/spree/search-input.tsx +3 -1
  38. package/src/spree/secret-input.tsx +1 -1
  39. package/src/spree/section.tsx +55 -0
  40. package/src/spree/stat-item.tsx +27 -0
  41. package/src/spree/tax-identifiers-card.tsx +117 -76
  42. package/src/spree/theme-toggle.tsx +1 -1
  43. package/src/spree/wizard.tsx +2 -2
  44. package/src/styles.css +226 -120
  45. package/src/ui/alert.tsx +20 -17
  46. package/src/ui/attachment.tsx +1 -1
  47. package/src/ui/avatar.tsx +2 -2
  48. package/src/ui/badge.tsx +1 -1
  49. package/src/ui/breadcrumb.tsx +1 -1
  50. package/src/ui/button.tsx +14 -11
  51. package/src/ui/calendar.tsx +2 -2
  52. package/src/ui/card.tsx +14 -37
  53. package/src/ui/chart.tsx +1 -1
  54. package/src/ui/checkbox.tsx +2 -2
  55. package/src/ui/code-editor.tsx +6 -2
  56. package/src/ui/collapsible.tsx +21 -2
  57. package/src/ui/combobox.tsx +12 -16
  58. package/src/ui/command.tsx +8 -6
  59. package/src/ui/context-menu.tsx +1 -1
  60. package/src/ui/data-table.tsx +58 -15
  61. package/src/ui/date-picker.tsx +3 -3
  62. package/src/ui/date-range-panel.tsx +102 -0
  63. package/src/ui/date-range-picker.tsx +33 -72
  64. package/src/ui/dialog.tsx +3 -3
  65. package/src/ui/dropdown-menu.tsx +6 -6
  66. package/src/ui/empty.tsx +3 -3
  67. package/src/ui/field.tsx +33 -12
  68. package/src/ui/input-group.tsx +17 -6
  69. package/src/ui/input.tsx +148 -4
  70. package/src/ui/item.tsx +7 -4
  71. package/src/ui/label.tsx +1 -1
  72. package/src/ui/media-preview.tsx +40 -5
  73. package/src/ui/pagination.tsx +16 -4
  74. package/src/ui/progress.tsx +2 -2
  75. package/src/ui/radio-group.tsx +2 -2
  76. package/src/ui/rich-text-editor.tsx +3 -3
  77. package/src/ui/select.tsx +7 -16
  78. package/src/ui/sheet.tsx +13 -5
  79. package/src/ui/sidebar.tsx +5 -5
  80. package/src/ui/switch.tsx +8 -2
  81. package/src/ui/tabs.tsx +45 -9
  82. package/src/ui/textarea.tsx +1 -1
  83. package/src/ui/toast.tsx +2 -2
  84. package/src/ui/tooltip.tsx +1 -1
@@ -35,16 +35,25 @@ interface TableProps extends React.ComponentProps<'table'> {
35
35
  */
36
36
  roundedBottom?: boolean
37
37
  /**
38
- * Rounds the header's outer corners, for a table that starts a card. The
39
- * header's own square corners otherwise sit on top of the card's radius and
40
- * clip it — the mirror of the `roundedBottom` case, and unset by default
41
- * because a table usually has a card header or some content above it.
38
+ * Rounds the header's outer corners, for a table that starts a card or any
39
+ * other bordered box. The header's own square corners otherwise sit on top
40
+ * of the card's radius and clip it — the mirror of the `roundedBottom` case,
41
+ * and unset by default because a table usually has a card header or some
42
+ * content above it. It also drops the header's top rule, which would double
43
+ * the box's own edge.
42
44
  *
43
45
  * Applied from `sm` up, matching where a full-bleed table gains its frame:
44
46
  * below that the table spans the page gutter with square edges, and curved
45
47
  * header cells there would round nothing.
46
48
  */
47
49
  roundedTop?: boolean
50
+ /**
51
+ * Draws the header as a free-standing rounded band, for a table with no
52
+ * card around it — a list page's main table. Rows then sit straight on the
53
+ * page, divided by their own rules; there is no frame for the header to
54
+ * round into, so this replaces `roundedTop`.
55
+ */
56
+ band?: boolean
48
57
  /**
49
58
  * Scrolls sideways at every width, not just on narrow viewports. For a table
50
59
  * in a bounded container — a card, a sheet, a dialog — where the default
@@ -83,6 +92,7 @@ function Table({
83
92
  stickyHeader = false,
84
93
  roundedBottom,
85
94
  roundedTop,
95
+ band = false,
86
96
  scrollX = false,
87
97
  ...props
88
98
  }: TableProps) {
@@ -148,14 +158,14 @@ function Table({
148
158
  // below that the table spans the page gutter with square edges, and rounded
149
159
  // cells there would round nothing.
150
160
  const alwaysRounded =
151
- 'sm:[&_tbody_tr:last-child_td:first-child]:rounded-bl-xl sm:[&_tbody_tr:last-child_td:last-child]:rounded-br-xl sm:[&_tbody_tr:last-child_td:only-child]:rounded-b-xl'
161
+ 'sm:[&_tbody_tr:last-child_td:first-child]:rounded-bl-[calc(var(--radius-card)-1px)] sm:[&_tbody_tr:last-child_td:last-child]:rounded-br-[calc(var(--radius-card)-1px)] sm:[&_tbody_tr:last-child_td:only-child]:rounded-b-[calc(var(--radius-card)-1px)]'
152
162
  // Applied to the outermost wrapper, not the table: `in-[:last-child]` is
153
163
  // satisfied by *any* last-child ancestor, and the sticky variant nests two
154
164
  // more divs that are both last inside it. Gating on the wrapper the
155
165
  // pagination is actually a sibling of is the only test that means "nothing
156
166
  // follows this table".
157
167
  const roundedWhenLast =
158
- 'last:[&_tbody_tr:last-child_td:first-child]:rounded-bl-xl last:[&_tbody_tr:last-child_td:last-child]:rounded-br-xl last:[&_tbody_tr:last-child_td:only-child]:rounded-b-xl'
168
+ 'last:[&_tbody_tr:last-child_td:first-child]:rounded-bl-[calc(var(--radius-card)-1px)] last:[&_tbody_tr:last-child_td:last-child]:rounded-br-[calc(var(--radius-card)-1px)] last:[&_tbody_tr:last-child_td:only-child]:rounded-b-[calc(var(--radius-card)-1px)]'
159
169
  // `true`/`false` decide on the table itself; unset defers to the wrapper's
160
170
  // own position among its siblings.
161
171
  // `thead` cells carry the header's background, so the radius belongs on the
@@ -169,9 +179,34 @@ function Table({
169
179
  // down to their order in the stylesheet — and the rounding did.
170
180
  const topRounded =
171
181
  roundedTop && !headerPinned
172
- ? 'sm:[&_thead_tr:first-child_th:first-child]:rounded-tl-xl sm:[&_thead_tr:first-child_th:last-child]:rounded-tr-xl sm:[&_thead_tr:first-child_th:only-child]:rounded-t-xl'
182
+ ? '[&_thead_th]:shadow-[inset_0_-1px_0_0_var(--border-subtle)] sm:[&_thead_tr:first-child_th:first-child]:rounded-tl-[calc(var(--radius-card)-1px)] sm:[&_thead_tr:first-child_th:last-child]:rounded-tr-[calc(var(--radius-card)-1px)] sm:[&_thead_tr:first-child_th:only-child]:rounded-t-[calc(var(--radius-card)-1px)]'
173
183
  : undefined
174
- const tableRoundedClasses = cn(roundedBottom === true ? alwaysRounded : undefined, topRounded)
184
+ // Both ends of the header band round, top and bottom, and the band drops
185
+ // the rule under it: it floats over the rows rather than capping a frame.
186
+ const bandClasses = band
187
+ ? cn(
188
+ '[&_thead_th]:shadow-none [&_thead_tr:first-child_th:first-child]:rounded-s-[10px] [&_thead_tr:first-child_th:last-child]:rounded-e-[10px] [&_thead_tr:first-child_th:only-child]:rounded-[10px]',
189
+ // Each row's fill and divider hang off its first cell — a row's own
190
+ // pseudo-element would become an extra table column — and stretch
191
+ // across the row, which is their positioning box. The divider runs
192
+ // along a row's top and the fill covers the rest; a filled row
193
+ // (hovered, selected, menu open) hides the dividers above and below
194
+ // it, so neighbouring filled rows read as separate rounded shapes
195
+ // split by a 2px gap.
196
+ '[&_tbody_tr]:relative [&_tbody_tr]:isolate [&_tbody_tr:hover]:bg-transparent [&_tbody_td]:border-b-0',
197
+ "[&_tbody_td:first-child]:before:absolute [&_tbody_td:first-child]:before:inset-x-0 [&_tbody_td:first-child]:before:top-0.5 [&_tbody_td:first-child]:before:bottom-0 [&_tbody_td:first-child]:before:-z-10 [&_tbody_td:first-child]:before:rounded-[10px] [&_tbody_td:first-child]:before:content-['']",
198
+ "[&_tbody_tr:not(:first-child)>td:first-child]:after:absolute [&_tbody_tr:not(:first-child)>td:first-child]:after:inset-x-0 [&_tbody_tr:not(:first-child)>td:first-child]:after:top-0 [&_tbody_tr:not(:first-child)>td:first-child]:after:h-px [&_tbody_tr:not(:first-child)>td:first-child]:after:bg-border-subtle [&_tbody_tr:not(:first-child)>td:first-child]:after:content-['']",
199
+ '[&_tbody_tr:hover>td:first-child]:before:bg-muted [&_tbody_tr:has([data-popup-open])>td:first-child]:before:bg-accent-strong/40',
200
+ '[&_tbody_tr[data-selected]>td:first-child]:before:bg-accent-strong/75 [&_tbody_tr[data-selected]:hover>td:first-child]:before:bg-accent-strong-hover',
201
+ '[&_tbody_tr:not(:first-child):is(:hover,[data-selected],:has([data-popup-open]))>td:first-child]:after:bg-transparent [&_tbody_tr:is(:hover,[data-selected],:has([data-popup-open]))+tr:not(:first-child)>td:first-child]:after:bg-transparent',
202
+ // A roomier header band with smaller labels.
203
+ 'sm:[&_thead_th]:h-10 sm:[&_thead_th]:py-2.5 [&_thead_th]:font-medium [&_thead_th]:text-xs',
204
+ )
205
+ : undefined
206
+ const tableRoundedClasses = cn(
207
+ roundedBottom === true ? alwaysRounded : undefined,
208
+ band ? bandClasses : topRounded,
209
+ )
175
210
  const wrapperRoundedClasses = roundedBottom === undefined ? roundedWhenLast : undefined
176
211
 
177
212
  const kids = Children.toArray(children)
@@ -430,17 +465,21 @@ function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
430
465
  }
431
466
 
432
467
  /**
433
- * The bottom rule is an inset shadow rather than `border-b` so it stays flush
434
- * with the bulk action bar, which overlays this row and draws the same shadow.
435
- * Don't add a border alongside it — a border sits outside the padding box and
436
- * the shadow inside, so the two stack into a 2px line.
468
+ * The rules above and below are inset shadows rather than borders so they stay
469
+ * flush with the bulk action bar, which overlays this row and draws the same
470
+ * shadow. Don't add a border alongside them — a border sits outside the padding
471
+ * box and the shadow inside, so the two stack into a 2px line.
472
+ *
473
+ * The top rule separates the band from whatever sits above it in the card. A
474
+ * table that starts its card drops it (`roundedTop`), as the card's own edge is
475
+ * already there, and so does the free-standing `band`.
437
476
  */
438
477
  function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
439
478
  return (
440
479
  <th
441
480
  className={cn(
442
- 'text-left text-sm font-normal text-muted-foreground bg-muted px-3 py-2.5 h-9 sm:p-2 whitespace-nowrap first:pl-4 last:pr-4',
443
- 'shadow-[inset_0_-1px_0_0_var(--border-subtle)]',
481
+ 'text-left text-base font-normal text-muted-foreground bg-muted px-3 py-2.5 h-9 sm:p-2 whitespace-nowrap first:pl-4 last:pr-4',
482
+ 'shadow-[inset_0_1px_0_0_var(--border-subtle),inset_0_-1px_0_0_var(--border-subtle)]',
444
483
  className,
445
484
  )}
446
485
  {...props}
@@ -471,7 +510,11 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
471
510
  // `h-16` on touch is a floor, not a fixed height: a table cell treats
472
511
  // `height` as a minimum, so a row with a thumbnail or a wrapped
473
512
  // second line still grows past it.
474
- 'h-16 py-3 px-3 sm:h-auto sm:py-3 sm:px-2 border-b border-border-subtle align-middle first:pl-4 last:pr-4',
513
+ 'h-16 py-3 px-3 sm:h-auto sm:py-3 sm:px-2 border-b border-border-subtle align-middle text-base first:pl-4 last:pr-4',
514
+ // Compact rows (the columns menu's density choice, set on <html>) fit
515
+ // about half as many again on a laptop screen. Desktop only: on touch
516
+ // the row stays a full-size target.
517
+ 'sm:in-data-[table-density=compact]:py-1.5 sm:in-data-[table-density=compact]:[&_[data-slot=thumbnail]]:size-7',
475
518
  className,
476
519
  )}
477
520
  {...props}
@@ -181,7 +181,7 @@ function DatePicker({
181
181
  const triggerChildren = (
182
182
  <>
183
183
  <CalendarIcon className="size-4 shrink-0 text-muted-foreground" />
184
- <span className="flex-1 truncate">{triggerLabel}</span>
184
+ <span className="flex-1 truncate text-left">{triggerLabel}</span>
185
185
  {isValidDate && (
186
186
  <span
187
187
  role="button"
@@ -235,7 +235,7 @@ function DatePicker({
235
235
  <div
236
236
  role="dialog"
237
237
  aria-label={placeholder}
238
- className="absolute top-full left-0 z-50 mt-2 w-auto rounded-lg border border-border bg-popover p-0 text-popover-foreground shadow-md"
238
+ className="absolute top-full left-0 z-50 mt-2 w-auto rounded-xl border border-border bg-popover p-0 text-popover-foreground shadow-md"
239
239
  >
240
240
  {panelChildren}
241
241
  </div>
@@ -252,7 +252,7 @@ function DatePicker({
252
252
  type="button"
253
253
  variant="outline"
254
254
  data-empty={!isValidDate}
255
- className={className}
255
+ className={cn('justify-start', className)}
256
256
  >
257
257
  {triggerChildren}
258
258
  </Button>
@@ -0,0 +1,102 @@
1
+ import { addMonths, startOfMonth, subMonths } from 'date-fns'
2
+ import { useState } from 'react'
3
+ import type { DateRange as DayPickerDateRange } from 'react-day-picker'
4
+ import { useIsMobile } from '../hooks/use-mobile'
5
+ import { cn } from '../lib/utils'
6
+ import { CheckIcon } from '../spree/icons'
7
+ import { Calendar } from './calendar'
8
+
9
+ export interface DateRangePanelPreset<K extends string = string> {
10
+ key: K
11
+ label: string
12
+ }
13
+
14
+ export interface DateRangePanelProps<K extends string> {
15
+ presets: DateRangePanelPreset<K>[]
16
+ /** The preset the applied range matches, if any. */
17
+ activePreset?: string | null
18
+ /** Picking a preset applies it straight away; the caller closes the popover. */
19
+ onPresetSelect: (key: K) => void
20
+ /** Seeds the calendar's highlight. */
21
+ initialRange?: DayPickerDateRange
22
+ /** Called once a picked range has both ends; the caller closes the popover. */
23
+ onApply: (range: { from: Date; to: Date }) => void
24
+ }
25
+
26
+ /**
27
+ * Popover body for picking a date range: named ranges on the left, a range
28
+ * calendar on the right. Mount it inside a popover's content so it reseeds
29
+ * from `initialRange` each time it opens.
30
+ */
31
+ export function DateRangePanel<K extends string>({
32
+ presets,
33
+ activePreset,
34
+ onPresetSelect,
35
+ initialRange,
36
+ onApply,
37
+ }: DateRangePanelProps<K>) {
38
+ const isMobile = useIsMobile()
39
+ const months = isMobile ? 1 : 2
40
+ const [range, setRange] = useState<DayPickerDateRange | undefined>(initialRange)
41
+ // A click starts a new range and the next one ends and applies it, the way
42
+ // a preset applies on click; the same day twice picks that one day. DayPicker's own
43
+ // selection moves the nearest end of the applied range instead, so picking
44
+ // the 10th and then the 12th over "Last 30 days" kept its start.
45
+ const [pickingEnd, setPickingEnd] = useState(false)
46
+
47
+ function pickDay(day: Date) {
48
+ if (pickingEnd && range?.from) {
49
+ const picked =
50
+ day < range.from ? { from: day, to: range.from } : { from: range.from, to: day }
51
+ setRange(picked)
52
+ setPickingEnd(false)
53
+ onApply(picked)
54
+ } else {
55
+ setRange({ from: day, to: day })
56
+ setPickingEnd(true)
57
+ }
58
+ }
59
+ // The last month shown, so a range ending today keeps this month in view
60
+ // whether one month shows or two.
61
+ const [endMonth, setEndMonth] = useState(() =>
62
+ startOfMonth(initialRange?.to ?? initialRange?.from ?? new Date()),
63
+ )
64
+
65
+ return (
66
+ // Phones get the full popover width: the presets as a list, then one
67
+ // month with finger-sized days. Wider screens put them side by side.
68
+ <div className="flex w-[min(calc(100vw-2rem),24rem)] flex-col md:w-auto md:flex-row">
69
+ <div className="flex flex-col gap-0.5 border-b p-2 md:w-44 md:border-r md:border-b-0">
70
+ {presets.map((preset) => {
71
+ const active = preset.key === activePreset
72
+ return (
73
+ <button
74
+ key={preset.key}
75
+ type="button"
76
+ aria-pressed={active}
77
+ onClick={() => onPresetSelect(preset.key)}
78
+ className={cn(
79
+ 'flex items-center justify-between gap-2 whitespace-nowrap rounded-lg px-3 py-2.5 text-left text-base md:px-2.5 md:py-1.5 transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
80
+ active ? 'bg-accent font-medium text-foreground' : 'text-muted-foreground',
81
+ )}
82
+ >
83
+ {preset.label}
84
+ {active && <CheckIcon className="size-3.5 shrink-0" />}
85
+ </button>
86
+ )
87
+ })}
88
+ </div>
89
+ <div className="flex flex-col p-3">
90
+ <Calendar
91
+ className="max-md:mx-auto max-md:[&_td>button]:size-10 max-md:[&_th]:w-10"
92
+ mode="range"
93
+ month={subMonths(endMonth, months - 1)}
94
+ onMonthChange={(first) => setEndMonth(addMonths(first, months - 1))}
95
+ selected={range}
96
+ onSelect={(_next, day) => pickDay(day)}
97
+ numberOfMonths={months}
98
+ />
99
+ </div>
100
+ </div>
101
+ )
102
+ }
@@ -1,6 +1,5 @@
1
1
  import { format } from 'date-fns'
2
2
  import { useMemo, useState } from 'react'
3
- import type { DateRange as DayPickerDateRange } from 'react-day-picker'
4
3
  import { useTranslation } from 'react-i18next'
5
4
  import { activeDateLocale } from '../lib/date-locale'
6
5
  import {
@@ -9,10 +8,10 @@ import {
9
8
  matchDateRangePreset,
10
9
  resolveDateRangePreset,
11
10
  } from '../lib/date-range-presets'
11
+ import { fieldTriggerClassName } from '../lib/field-trigger'
12
12
  import { cn } from '../lib/utils'
13
- import { CalendarIcon, ChevronDownIcon } from '../spree/icons'
14
- import { Button } from './button'
15
- import { Calendar } from './calendar'
13
+ import { CalendarIcon, ChevronsUpDownIcon } from '../spree/icons'
14
+ import { DateRangePanel } from './date-range-panel'
16
15
  import { Popover, PopoverContent, PopoverTrigger } from './popover'
17
16
 
18
17
  export interface DateRange {
@@ -43,8 +42,6 @@ export interface DateRangePickerProps {
43
42
  export function DateRangePicker({ value, onChange, timezone }: DateRangePickerProps) {
44
43
  const { t } = useTranslation()
45
44
  const [open, setOpen] = useState(false)
46
- const [calendarRange, setCalendarRange] = useState<DayPickerDateRange | undefined>()
47
- const [month, setMonth] = useState<Date>(value.from)
48
45
 
49
46
  const zone = useMemo(
50
47
  () => timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC',
@@ -62,75 +59,39 @@ export function DateRangePicker({ value, onChange, timezone }: DateRangePickerPr
62
59
  ? `${format(value.from, 'MMM d', { locale: dateLocale })} – ${format(value.to, 'MMM d', { locale: dateLocale })}`
63
60
  : t(PRESET_LABEL_KEYS[activePreset])
64
61
 
65
- function selectPreset(key: Exclude<DateRangePresetKey, 'custom'>) {
66
- const range = resolveDateRangePreset(key, zone)
67
- setCalendarRange({ from: range.from, to: range.to })
68
- setMonth(range.from)
69
- onChange(range)
70
- setOpen(false)
71
- }
72
-
73
- function applyCustomRange() {
74
- if (calendarRange?.from && calendarRange?.to) {
75
- onChange({ from: calendarRange.from, to: calendarRange.to })
76
- setOpen(false)
77
- }
78
- }
79
-
80
- const canApply = calendarRange?.from && calendarRange?.to
81
-
82
62
  return (
83
- <Popover
84
- open={open}
85
- onOpenChange={(nextOpen) => {
86
- setOpen(nextOpen)
87
- if (nextOpen) {
88
- setCalendarRange({ from: value.from, to: value.to })
89
- setMonth(value.from)
90
- }
91
- }}
92
- >
63
+ <Popover open={open} onOpenChange={setOpen}>
93
64
  <PopoverTrigger asChild>
94
- <Button size="sm" variant="outline" className="h-8 gap-2 text-sm font-normal">
95
- <CalendarIcon className="size-3.5" />
96
- {triggerLabel}
97
- <ChevronDownIcon className="size-3.5 text-muted-foreground" />
98
- </Button>
65
+ {/* Field-shaped like the select and combobox triggers it sits beside. */}
66
+ <button
67
+ type="button"
68
+ data-size="sm"
69
+ className={cn(fieldTriggerClassName, 'w-auto shrink-0 whitespace-nowrap')}
70
+ >
71
+ <span className="flex items-center gap-2">
72
+ <CalendarIcon className="size-3.5" />
73
+ {triggerLabel}
74
+ </span>
75
+ <ChevronsUpDownIcon className="size-3.5 text-muted-foreground" />
76
+ </button>
99
77
  </PopoverTrigger>
100
- <PopoverContent align="end" className="w-auto p-0">
101
- <div className="flex">
102
- <div className="flex flex-col border-r py-2">
103
- {DATE_RANGE_PRESET_KEYS.map((key) => (
104
- <button
105
- key={key}
106
- type="button"
107
- onClick={() => selectPreset(key)}
108
- className={cn(
109
- 'whitespace-nowrap px-4 py-1.5 text-left text-sm transition-colors hover:bg-accent',
110
- activePreset === key && 'font-medium text-foreground',
111
- activePreset !== key && 'text-muted-foreground',
112
- )}
113
- >
114
- {t(PRESET_LABEL_KEYS[key])}
115
- </button>
116
- ))}
117
- </div>
118
- <div className="flex flex-col p-3">
119
- <Calendar
120
- mode="range"
121
- month={month}
122
- onMonthChange={setMonth}
123
- selected={calendarRange}
124
- onSelect={setCalendarRange}
125
- numberOfMonths={2}
126
- />
127
- <div className="flex justify-end border-t pt-3">
128
- <Button size="sm" disabled={!canApply} onClick={applyCustomRange}>
129
- {t('admin.actions.apply')}
130
- </Button>
131
- </div>
132
- </div>
133
- </div>
78
+ <PopoverContent align="end" className="w-auto max-w-[calc(100vw-1rem)] p-0">
79
+ <DateRangePanel
80
+ // Reseeds the calendar whenever the applied range changes, even if
81
+ // the popover reopens before its closing animation unmounts it.
82
+ key={`${value.from.getTime()}:${value.to.getTime()}`}
83
+ presets={DATE_RANGE_PRESET_KEYS.map((key) => ({ key, label: t(PRESET_LABEL_KEYS[key]) }))}
84
+ activePreset={activePreset}
85
+ onPresetSelect={(key) => {
86
+ onChange(resolveDateRangePreset(key, zone))
87
+ setOpen(false)
88
+ }}
89
+ initialRange={{ from: value.from, to: value.to }}
90
+ onApply={(range) => {
91
+ onChange(range)
92
+ setOpen(false)
93
+ }}
94
+ />
134
95
  </PopoverContent>
135
96
  </Popover>
136
97
  )
package/src/ui/dialog.tsx CHANGED
@@ -113,7 +113,7 @@ function DialogContent({
113
113
  <DialogPrimitive.Popup
114
114
  data-slot="dialog-content"
115
115
  className={cn(
116
- '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]',
116
+ '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-2xl border border-border bg-card text-foreground text-base 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]',
117
117
  fullScreen && 'inset-3 w-auto max-w-none translate-x-0 translate-y-0 sm:max-w-none',
118
118
  className,
119
119
  )}
@@ -186,7 +186,7 @@ function DialogTitle({ className, ...props }: React.ComponentProps<typeof Dialog
186
186
  return (
187
187
  <DialogPrimitive.Title
188
188
  data-slot="dialog-title"
189
- className={cn('text-lg font-medium', className)}
189
+ className={cn('text-lg font-semibold', className)}
190
190
  {...props}
191
191
  />
192
192
  )
@@ -200,7 +200,7 @@ function DialogDescription({
200
200
  <DialogPrimitive.Description
201
201
  data-slot="dialog-description"
202
202
  className={cn(
203
- 'text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
203
+ 'text-base text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
204
204
  className,
205
205
  )}
206
206
  {...props}
@@ -119,7 +119,7 @@ function DropdownMenuItem({
119
119
  data-variant={variant}
120
120
  render={children}
121
121
  className={cn(
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",
122
+ "group/dropdown-menu-item relative flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 text-base 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",
123
123
  className,
124
124
  )}
125
125
  {...props}
@@ -133,7 +133,7 @@ function DropdownMenuItem({
133
133
  data-inset={inset}
134
134
  data-variant={variant}
135
135
  className={cn(
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",
136
+ "group/dropdown-menu-item relative flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 text-base 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",
137
137
  className,
138
138
  )}
139
139
  {...props}
@@ -160,7 +160,7 @@ function DropdownMenuCheckboxItem({
160
160
  data-slot="dropdown-menu-checkbox-item"
161
161
  data-inset={inset}
162
162
  className={cn(
163
- "group/checkbox-item relative flex cursor-pointer items-center gap-2.5 rounded-lg py-1.5 pr-2.5 pl-2.5 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent 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",
163
+ "group/checkbox-item relative flex cursor-pointer items-center gap-2.5 rounded-md py-1.5 pr-2.5 pl-2.5 text-base outline-hidden select-none transition-colors duration-75 focus:bg-accent 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",
164
164
  className,
165
165
  )}
166
166
  checked={checked}
@@ -203,7 +203,7 @@ function DropdownMenuRadioItem({
203
203
  data-slot="dropdown-menu-radio-item"
204
204
  data-inset={inset}
205
205
  className={cn(
206
- "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",
206
+ "relative flex cursor-pointer items-center gap-3 rounded-md py-1.5 pr-8 pl-2.5 text-base 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",
207
207
  className,
208
208
  )}
209
209
  {...props}
@@ -234,7 +234,7 @@ function DropdownMenuLabel({
234
234
  data-slot="dropdown-menu-label"
235
235
  data-inset={inset}
236
236
  className={cn(
237
- 'px-2 py-1.5 text-sm font-normal text-muted-foreground data-inset:pl-7',
237
+ 'px-2 py-1.5 text-base font-normal text-muted-foreground data-inset:pl-7',
238
238
  className,
239
239
  )}
240
240
  {...props}
@@ -298,7 +298,7 @@ function DropdownMenuSubTrigger({
298
298
  data-slot="dropdown-menu-sub-trigger"
299
299
  data-inset={inset}
300
300
  className={cn(
301
- "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",
301
+ "flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 text-base 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",
302
302
  className,
303
303
  )}
304
304
  {...props}
package/src/ui/empty.tsx CHANGED
@@ -59,7 +59,7 @@ function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
59
59
  return (
60
60
  <div
61
61
  data-slot="empty-title"
62
- className={cn('cn-font-heading text-sm font-medium tracking-tight', className)}
62
+ className={cn('cn-font-heading text-base font-medium tracking-tight', className)}
63
63
  {...props}
64
64
  />
65
65
  )
@@ -70,7 +70,7 @@ function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
70
70
  <div
71
71
  data-slot="empty-description"
72
72
  className={cn(
73
- 'text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
73
+ 'text-base/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
74
74
  className,
75
75
  )}
76
76
  {...props}
@@ -83,7 +83,7 @@ function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
83
83
  <div
84
84
  data-slot="empty-content"
85
85
  className={cn(
86
- 'flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance',
86
+ 'flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-base text-balance',
87
87
  className,
88
88
  )}
89
89
  {...props}
package/src/ui/field.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { cva, type VariantProps } from 'class-variance-authority'
2
2
  import { useMemo } from 'react'
3
+ import { useTranslation } from 'react-i18next'
3
4
  import { cn } from '../lib/utils'
4
5
  import { Label } from './label'
5
6
  import { Separator } from './separator'
@@ -26,10 +27,7 @@ function FieldLegend({
26
27
  <legend
27
28
  data-slot="field-legend"
28
29
  data-variant={variant}
29
- className={cn(
30
- 'mb-1.5 font-semibold data-[variant=label]:text-sm data-[variant=legend]:text-base',
31
- className,
32
- )}
30
+ className={cn('mb-1.5 font-semibold text-base', className)}
33
31
  {...props}
34
32
  />
35
33
  )
@@ -83,18 +81,36 @@ function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
83
81
  return (
84
82
  <div
85
83
  data-slot="field-content"
86
- className={cn('group/field-content flex flex-1 flex-col gap-0.5 leading-snug', className)}
84
+ className={cn('group/field-content flex flex-1 flex-col gap-0.5', className)}
87
85
  {...props}
88
86
  />
89
87
  )
90
88
  }
91
89
 
92
- function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) {
90
+ /**
91
+ * `optional` marks a field the form can be saved without. Forms mark the
92
+ * optional fields rather than the required ones: most fields are required,
93
+ * so the few that are not are the ones worth pointing out.
94
+ */
95
+ function FieldLabel({
96
+ className,
97
+ optional = false,
98
+ children,
99
+ ...props
100
+ }: React.ComponentProps<typeof Label> & { optional?: boolean }) {
101
+ const { t } = useTranslation()
102
+
93
103
  return (
94
104
  <Label
95
105
  data-slot="field-label"
96
106
  className={cn(
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-100 has-data-checked:bg-blue-50 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',
107
+ 'group/field-label peer/field-label flex w-fit gap-2 group-data-[disabled=true]/field:opacity-50',
108
+ // A label wrapping a whole `Field` is a choice card: a card's corner,
109
+ // edge, fill, lift and inset. Checked, it takes a blue edge and tint;
110
+ // those rules repeat the card-label condition so they outrank the
111
+ // edge above rather than racing it on stylesheet order.
112
+ 'has-[>[data-slot=field]]:rounded-card has-[>[data-slot=field]]:border has-[>[data-slot=field]]:border-card-edge has-[>[data-slot=field]]:bg-card has-[>[data-slot=field]]:shadow-card-lift *:data-[slot=field]:p-3.5',
113
+ 'has-[>[data-slot=field]]:has-data-checked:border-blue-500 has-[>[data-slot=field]]:has-data-checked:bg-blue-50/70 dark:has-[>[data-slot=field]]:has-data-checked:bg-blue-500/10',
98
114
  // Card-shaped labels (the ones wrapping a whole `Field`) are clickable
99
115
  // targets, so they need the affordance a bare text label doesn't:
100
116
  // a pointer and a hover tint. Scoped by `has-[>[data-slot=field]]` so
@@ -107,7 +123,12 @@ function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>)
107
123
  className,
108
124
  )}
109
125
  {...props}
110
- />
126
+ >
127
+ {children}
128
+ {optional && (
129
+ <span className="font-normal text-muted-foreground">{t('admin.common.optional')}</span>
130
+ )}
131
+ </Label>
111
132
  )
112
133
  }
113
134
 
@@ -116,7 +137,7 @@ function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
116
137
  <div
117
138
  data-slot="field-label"
118
139
  className={cn(
119
- 'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
140
+ 'flex w-fit items-center gap-2 text-base font-medium group-data-[disabled=true]/field:opacity-50',
120
141
  className,
121
142
  )}
122
143
  {...props}
@@ -129,7 +150,7 @@ function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
129
150
  <p
130
151
  data-slot="field-description"
131
152
  className={cn(
132
- 'text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5',
153
+ 'text-left text-base font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5',
133
154
  'last:mt-0 nth-last-2:-mt-1',
134
155
  '[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
135
156
  className,
@@ -151,7 +172,7 @@ function FieldSeparator({
151
172
  data-slot="field-separator"
152
173
  data-content={!!children}
153
174
  className={cn(
154
- 'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
175
+ 'relative -my-2 h-5 text-base group-data-[variant=outline]/field-group:-mb-2',
155
176
  className,
156
177
  )}
157
178
  {...props}
@@ -218,7 +239,7 @@ function FieldError({
218
239
  <div
219
240
  role="alert"
220
241
  data-slot="field-error"
221
- className={cn('text-sm font-normal text-destructive', className)}
242
+ className={cn('text-base font-normal text-destructive', className)}
222
243
  {...props}
223
244
  >
224
245
  {content}