@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
@@ -2,7 +2,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
2
2
  import * as React from 'react'
3
3
  import { cn } from '../lib/utils'
4
4
  import { Button } from './button'
5
- import { Input } from './input'
5
+ import { Input, NumberInput } from './input'
6
6
  import { Textarea } from './textarea'
7
7
 
8
8
  function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
@@ -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-md border border-border shadow-control 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',
14
+ 'group/input-group relative flex w-full min-w-0 items-center rounded-xl border border-field-border shadow-control 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 not-has-disabled:not-focus-within:hover:bg-field-hover 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-field-focus 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}
@@ -20,7 +20,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
20
20
  }
21
21
 
22
22
  const inputGroupAddonVariants = cva(
23
- "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
23
+ "flex h-auto cursor-default group-focus-within/input-group:cursor-text items-center justify-center gap-2 py-1.5 text-base font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
24
24
  {
25
25
  variants: {
26
26
  align: {
@@ -60,7 +60,7 @@ function InputGroupAddon({
60
60
  )
61
61
  }
62
62
 
63
- const inputGroupButtonVariants = cva('flex items-center gap-2 text-sm shadow-none', {
63
+ const inputGroupButtonVariants = cva('flex items-center gap-2 text-base shadow-none', {
64
64
  variants: {
65
65
  size: {
66
66
  xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
@@ -97,7 +97,7 @@ function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
97
97
  return (
98
98
  <span
99
99
  className={cn(
100
- "flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
100
+ "flex items-center gap-2 text-base text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
101
101
  className,
102
102
  )}
103
103
  {...props}
@@ -106,11 +106,22 @@ function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
106
106
  }
107
107
 
108
108
  function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
109
+ if (props.type === 'number') {
110
+ return (
111
+ <NumberInput
112
+ bare
113
+ data-slot="input-group-control"
114
+ className={cn('flex-1 self-stretch', className)}
115
+ {...props}
116
+ />
117
+ )
118
+ }
119
+
109
120
  return (
110
121
  <Input
111
122
  data-slot="input-group-control"
112
123
  className={cn(
113
- 'flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus:border-0 focus:shadow-none focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0',
124
+ 'flex-1 rounded-none border-0 bg-transparent not-disabled:not-read-only:not-focus:hover:bg-transparent shadow-none ring-0 focus:border-0 focus:shadow-none focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0',
114
125
  className,
115
126
  )}
116
127
  {...props}
package/src/ui/input.tsx CHANGED
@@ -1,14 +1,30 @@
1
- import type * as React from 'react'
1
+ import * as React from 'react'
2
2
 
3
3
  import { cn } from '../lib/utils'
4
+ import { ChevronDownIcon, ChevronUpIcon } from '../spree/icons'
5
+
6
+ const FIELD_BOX =
7
+ 'rounded-xl border border-field-border shadow-control bg-card transition-[color,background-color,border-color,box-shadow] duration-100 ease-out'
8
+
9
+ function Input({
10
+ className,
11
+ type,
12
+ stepper,
13
+ ...props
14
+ }: React.ComponentProps<'input'> & {
15
+ /** `false` keeps a number field plain, where a column has no room for the buttons. */
16
+ stepper?: boolean
17
+ }) {
18
+ if (type === 'number' && stepper !== false)
19
+ return <NumberInput className={className} {...props} />
4
20
 
5
- function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
6
21
  return (
7
22
  <input
8
23
  type={type}
9
24
  data-slot="input"
10
25
  className={cn(
11
- 'block w-full min-w-0 min-h-8 rounded-md border border-border shadow-control 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',
26
+ FIELD_BOX,
27
+ 'block w-full min-w-0 min-h-8 cursor-default focus:cursor-text py-1.5 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none px-2.5 text-base font-normal leading-normal text-foreground outline-none placeholder:text-muted-foreground not-disabled:not-read-only:not-focus:hover:bg-field-hover focus:border-blue-500 focus:shadow-field-focus 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-base file:font-medium file:text-foreground',
12
28
  className,
13
29
  )}
14
30
  {...props}
@@ -16,4 +32,132 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
16
32
  )
17
33
  }
18
34
 
19
- export { Input }
35
+ // The two buttons stack into one pill 3px inside the field's edge. Its outer
36
+ // corners follow the field's (radius-xl, less the 1px border and the 3px gap),
37
+ // so the curves stay parallel; the corners where the buttons meet stay tight.
38
+ const STEP_RADIUS_UP = 'rounded-t-[calc(var(--radius-xl)-4px)] rounded-b-[2px]'
39
+ const STEP_RADIUS_DOWN = 'rounded-b-[calc(var(--radius-xl)-4px)] rounded-t-[2px]'
40
+
41
+ type NumberInputProps = Omit<React.ComponentProps<'input'>, 'type'> & {
42
+ /** Drops the field's own box, for an input that sits inside an `InputGroup`. */
43
+ bare?: boolean
44
+ }
45
+
46
+ /**
47
+ * A number field with increase and decrease buttons. Every `<Input
48
+ * type="number">` renders this, so forms get it without opting in.
49
+ *
50
+ * `className` lands on the outer box, so width, margin and border classes
51
+ * work as they do on a plain input; text alignment and numeral style carry
52
+ * through to the value. The buttons are skipped by Tab — the arrow keys
53
+ * already step the value.
54
+ */
55
+ function NumberInput({ className, bare, disabled, readOnly, ref, ...props }: NumberInputProps) {
56
+ const inputRef = React.useRef<HTMLInputElement>(null)
57
+ // A form library passes its own ref; the buttons need the element too.
58
+ const setRef = (node: HTMLInputElement | null) => {
59
+ inputRef.current = node
60
+ if (typeof ref === 'function') ref(node)
61
+ else if (ref) ref.current = node
62
+ }
63
+ const repeat = React.useRef<ReturnType<typeof setTimeout> | null>(null)
64
+ const interactive = !disabled && !readOnly
65
+
66
+ const step = (direction: 1 | -1) => {
67
+ const input = inputRef.current
68
+ if (!input) return
69
+ try {
70
+ direction === 1 ? input.stepUp() : input.stepDown()
71
+ } catch {
72
+ // `step="any"` has no step to take; move by one, inside min and max.
73
+ let next = (Number.parseFloat(input.value) || 0) + direction
74
+ if (input.min !== '') next = Math.max(next, Number(input.min))
75
+ if (input.max !== '') next = Math.min(next, Number(input.max))
76
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(
77
+ input,
78
+ String(next),
79
+ )
80
+ }
81
+ // The value changed outside React, so announce it the way typing would.
82
+ input.dispatchEvent(new Event('input', { bubbles: true }))
83
+ input.dispatchEvent(new Event('change', { bubbles: true }))
84
+ }
85
+
86
+ const stopRepeat = () => {
87
+ if (repeat.current) clearTimeout(repeat.current)
88
+ repeat.current = null
89
+ }
90
+
91
+ // Holding a button keeps stepping, slowly at first.
92
+ const startRepeat = (direction: 1 | -1) => (event: React.PointerEvent) => {
93
+ if (event.button !== 0) return
94
+ event.preventDefault()
95
+ inputRef.current?.focus()
96
+ step(direction)
97
+ const tick = (delay: number) => {
98
+ repeat.current = setTimeout(() => {
99
+ step(direction)
100
+ tick(60)
101
+ }, delay)
102
+ }
103
+ tick(400)
104
+ }
105
+
106
+ React.useEffect(() => stopRepeat, [])
107
+
108
+ const button = (direction: 1 | -1) => (
109
+ <button
110
+ type="button"
111
+ tabIndex={-1}
112
+ aria-hidden
113
+ disabled={!interactive}
114
+ onPointerDown={startRepeat(direction)}
115
+ onPointerUp={stopRepeat}
116
+ onPointerLeave={stopRepeat}
117
+ onPointerCancel={stopRepeat}
118
+ className={cn(
119
+ 'flex flex-1 cursor-pointer items-center justify-center bg-stepper text-muted-foreground transition-colors hover:bg-stepper-hover hover:text-foreground active:bg-stepper-active active:text-foreground disabled:cursor-not-allowed [&_svg]:size-3',
120
+ direction === 1 ? STEP_RADIUS_UP : STEP_RADIUS_DOWN,
121
+ )}
122
+ >
123
+ {direction === 1 ? (
124
+ <ChevronUpIcon strokeWidth={2.5} />
125
+ ) : (
126
+ <ChevronDownIcon strokeWidth={2.5} />
127
+ )}
128
+ </button>
129
+ )
130
+
131
+ return (
132
+ <span
133
+ data-slot="number-field"
134
+ className={cn(
135
+ 'relative flex w-full min-w-0 items-stretch text-foreground',
136
+ !bare &&
137
+ cn(
138
+ FIELD_BOX,
139
+ 'min-h-8 not-has-[input:disabled]:not-has-[input:read-only]:not-focus-within:hover:bg-field-hover focus-within:border-blue-500 focus-within:shadow-field-focus has-[input:disabled]:cursor-not-allowed has-[input:disabled]:bg-muted has-[input:disabled]:text-muted-foreground has-aria-invalid:border-destructive',
140
+ ),
141
+ className,
142
+ )}
143
+ >
144
+ <input
145
+ ref={setRef}
146
+ type="number"
147
+ data-slot="input"
148
+ disabled={disabled}
149
+ readOnly={readOnly}
150
+ {...props}
151
+ className="block w-full min-w-0 flex-1 cursor-default focus:cursor-text appearance-none bg-transparent py-1.5 pl-2.5 pr-1 text-base font-normal leading-normal text-inherit [font-variant-numeric:inherit] [text-align:inherit] outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
152
+ />
153
+ {interactive && (
154
+ <span className="flex w-6 shrink-0 flex-col gap-px py-[3px] pr-[3px]">
155
+ {button(1)}
156
+ {button(-1)}
157
+ </span>
158
+ )}
159
+ </span>
160
+ )
161
+ }
162
+
163
+ export { Input, NumberInput }
package/src/ui/item.tsx CHANGED
@@ -32,12 +32,13 @@ function ItemSeparator({ className, ...props }: React.ComponentProps<typeof Sepa
32
32
  }
33
33
 
34
34
  const itemVariants = cva(
35
- 'group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted',
35
+ 'group/item flex w-full flex-wrap items-center rounded-lg border text-base transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted',
36
36
  {
37
37
  variants: {
38
38
  variant: {
39
39
  default: 'border-transparent',
40
- outline: 'border-border',
40
+ // A card in small: its corner, faint border, fill and soft lift.
41
+ outline: 'rounded-card border-card-edge bg-card shadow-card-lift',
41
42
  muted: 'border-transparent bg-muted/50',
42
43
  },
43
44
  size: {
@@ -46,6 +47,8 @@ const itemVariants = cva(
46
47
  xs: 'gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0',
47
48
  },
48
49
  },
50
+ // An outline item takes a card's 14px inset, so the two line up.
51
+ compoundVariants: [{ variant: 'outline', size: 'default', className: 'px-3.5 py-3' }],
49
52
  defaultVariants: {
50
53
  variant: 'default',
51
54
  size: 'default',
@@ -127,7 +130,7 @@ function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
127
130
  <div
128
131
  data-slot="item-title"
129
132
  className={cn(
130
- 'line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4',
133
+ 'line-clamp-1 flex w-fit items-center gap-2 text-base font-medium underline-offset-4',
131
134
  className,
132
135
  )}
133
136
  {...props}
@@ -140,7 +143,7 @@ function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
140
143
  <p
141
144
  data-slot="item-description"
142
145
  className={cn(
143
- 'line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
146
+ 'line-clamp-2 text-left text-base font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
144
147
  className,
145
148
  )}
146
149
  {...props}
package/src/ui/label.tsx CHANGED
@@ -15,7 +15,7 @@ function Label({ className, children, ...props }: React.ComponentProps<'label'>)
15
15
  <Field.Label
16
16
  data-slot="label"
17
17
  className={cn(
18
- 'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
18
+ 'flex items-center gap-2 text-base font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
19
19
  className,
20
20
  )}
21
21
  {...props}
@@ -21,6 +21,12 @@ export interface MediaPreviewProps {
21
21
  /** Click handler for setting the focal point; images only. */
22
22
  onFocalPointClick?: (event: React.MouseEvent<HTMLElement>) => void
23
23
  className?: string
24
+ /**
25
+ * `width` fills its container's width (a panel); `stage` shrinks the frame
26
+ * to the picture and fits it within the viewport, for a full-screen editor —
27
+ * so a focal point click always lands on the image, never on letterboxing.
28
+ */
29
+ fit?: 'width' | 'stage'
24
30
  }
25
31
 
26
32
  /**
@@ -41,14 +47,23 @@ export function MediaPreview({
41
47
  focalPoint,
42
48
  onFocalPointClick,
43
49
  className = 'max-h-[60vh]',
50
+ fit = 'width',
44
51
  }: MediaPreviewProps) {
45
52
  const { t } = useTranslation()
46
53
 
47
54
  const isImage = mediaType === 'image'
55
+ const stage = fit === 'stage'
56
+ // The stage's own padding and the editor's top bar, so the picture fits.
57
+ const stageHeight = 'max-h-[calc(100dvh-9rem)]'
48
58
 
49
59
  if (mediaType === 'external_video' && embedUrl) {
50
60
  return (
51
- <div className="aspect-video w-full shrink-0 overflow-hidden rounded-lg border border-border bg-black">
61
+ <div
62
+ className={cn(
63
+ 'aspect-video w-full shrink-0 overflow-hidden rounded-lg border border-border bg-black',
64
+ stage && 'max-w-5xl',
65
+ )}
66
+ >
52
67
  <iframe
53
68
  src={embedUrl}
54
69
  title={alt || t('admin.components.media_preview.video_title')}
@@ -68,7 +83,10 @@ export function MediaPreview({
68
83
  controls
69
84
  playsInline
70
85
  preload="metadata"
71
- className={cn('w-full shrink-0 rounded-lg bg-black', className)}
86
+ className={cn(
87
+ 'shrink-0 rounded-lg bg-black',
88
+ stage ? cn('max-w-full', stageHeight) : cn('w-full', className),
89
+ )}
72
90
  />
73
91
  )
74
92
  }
@@ -80,11 +98,23 @@ export function MediaPreview({
80
98
  type="button"
81
99
  disabled={!canSetFocalPoint}
82
100
  onClick={onFocalPointClick}
83
- className="group relative block w-full shrink-0 overflow-hidden rounded-lg border border-border bg-muted disabled:cursor-default"
101
+ className={cn(
102
+ 'group relative shrink-0 overflow-hidden rounded-lg border border-border bg-muted disabled:cursor-default',
103
+ stage ? 'inline-block max-w-full border-0 bg-transparent' : 'block w-full',
104
+ canSetFocalPoint && 'cursor-crosshair',
105
+ )}
84
106
  >
85
107
  {previewUrl ? (
86
108
  <>
87
- <img src={previewUrl} alt={alt} className={cn('w-full object-contain', className)} />
109
+ <img
110
+ src={previewUrl}
111
+ alt={alt}
112
+ className={
113
+ stage
114
+ ? cn('block h-auto w-auto max-w-full object-contain', stageHeight)
115
+ : cn('w-full object-contain', className)
116
+ }
117
+ />
88
118
  {isImage && focalPoint && (
89
119
  <span
90
120
  aria-hidden
@@ -96,7 +126,12 @@ export function MediaPreview({
96
126
  ) : (
97
127
  // A video with no poster has no still to show; say which kind of file
98
128
  // it is rather than rendering a broken image.
99
- <div className="flex aspect-square w-full items-center justify-center text-muted-foreground">
129
+ <div
130
+ className={cn(
131
+ 'flex aspect-square items-center justify-center text-muted-foreground',
132
+ stage ? 'w-64' : 'w-full',
133
+ )}
134
+ >
100
135
  {isImage ? <ImagePlusIcon className="size-8" /> : <FilmIcon className="size-8" />}
101
136
  </div>
102
137
  )}
@@ -1,4 +1,5 @@
1
1
  import { useTranslation } from 'react-i18next'
2
+ import { cn } from '../lib/utils'
2
3
  import {
3
4
  ChevronLeftIcon,
4
5
  ChevronRightIcon,
@@ -24,6 +25,11 @@ interface PaginationProps {
24
25
  onPageChange: (page: number) => void
25
26
  onPageSizeChange?: (size: number) => void
26
27
  pageSizeOptions?: number[]
28
+ /**
29
+ * For the foot of a card whose table runs edge to edge: a divider above and
30
+ * padding on every side, so the range and buttons don't touch the card edge.
31
+ */
32
+ inset?: boolean
27
33
  }
28
34
 
29
35
  export function Pagination({
@@ -31,6 +37,7 @@ export function Pagination({
31
37
  onPageChange,
32
38
  onPageSizeChange,
33
39
  pageSizeOptions = [10, 20, 25, 30, 50],
40
+ inset = false,
34
41
  }: PaginationProps) {
35
42
  const { t } = useTranslation()
36
43
  const showPageSize = Boolean(onPageSizeChange) && meta.count > 0
@@ -39,8 +46,13 @@ export function Pagination({
39
46
  if (!showPageSize && !showPageNav) return null
40
47
 
41
48
  return (
42
- <div className="flex items-center justify-between ps-2 py-2">
43
- <div className="whitespace-nowrap text-sm text-muted-foreground">
49
+ <div
50
+ className={cn(
51
+ 'flex items-center justify-between',
52
+ inset ? 'border-border-subtle border-t px-4 py-2.5' : 'ps-2 py-2',
53
+ )}
54
+ >
55
+ <div className="whitespace-nowrap text-base text-muted-foreground">
44
56
  {t('admin.components.pagination.range', {
45
57
  from: meta.from,
46
58
  to: meta.to,
@@ -50,7 +62,7 @@ export function Pagination({
50
62
  <div className="flex items-center gap-2 sm:gap-6">
51
63
  {showPageSize && (
52
64
  <div className="hidden items-center gap-2 sm:flex">
53
- <span className="text-sm text-muted-foreground">
65
+ <span className="text-base text-muted-foreground">
54
66
  {t('admin.components.pagination.rows_per_page')}
55
67
  </span>
56
68
  <Select
@@ -72,7 +84,7 @@ export function Pagination({
72
84
  )}
73
85
  {showPageNav && (
74
86
  <>
75
- <span className="hidden whitespace-nowrap text-sm text-muted-foreground sm:inline">
87
+ <span className="hidden whitespace-nowrap text-base text-muted-foreground sm:inline">
76
88
  {t('admin.common.page_of', { page: meta.page, total: meta.pages })}
77
89
  </span>
78
90
  <div className="flex items-center gap-1">
@@ -57,7 +57,7 @@ function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
57
57
  return (
58
58
  <ProgressPrimitive.Label
59
59
  data-slot="progress-label"
60
- className={cn('font-medium text-sm', className)}
60
+ className={cn('font-medium text-base', className)}
61
61
  {...props}
62
62
  />
63
63
  )
@@ -67,7 +67,7 @@ function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
67
67
  return (
68
68
  <ProgressPrimitive.Value
69
69
  data-slot="progress-value"
70
- className={cn('ml-auto text-muted-foreground text-sm', className)}
70
+ className={cn('ml-auto text-muted-foreground text-base', className)}
71
71
  {...props}
72
72
  />
73
73
  )
@@ -18,8 +18,8 @@ function RadioGroupItem({ className, ...props }: React.ComponentProps<typeof Rad
18
18
  <RadioPrimitive.Root
19
19
  data-slot="radio-group-item"
20
20
  className={cn(
21
- 'peer relative inline-block size-4 shrink-0 cursor-pointer rounded-full border border-border bg-card align-[-3px] outline-none transition-[color,background-color,border-color,box-shadow] duration-100 ease-out',
22
- 'focus:border-blue-500 focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)]',
21
+ 'peer relative inline-block size-4 shrink-0 cursor-pointer rounded-full border border-field-border shadow-control bg-card align-[-3px] outline-none transition-[color,background-color,border-color,box-shadow] duration-100 ease-out',
22
+ 'focus:border-blue-500 focus:shadow-field-focus',
23
23
  'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground',
24
24
  'aria-invalid:border-destructive aria-invalid:ring-destructive/20',
25
25
  'data-[checked]:border-blue-500 data-[checked]:bg-blue-500 data-[checked]:text-white',
@@ -191,7 +191,7 @@ export function RichTextEditor({
191
191
  // in a form beside them without reading as a different kind of control.
192
192
  // `focus-within` rather than `focus`: the element that takes the caret
193
193
  // is Tiptap's inner contenteditable, not this wrapper.
194
- 'rounded-md border border-border shadow-control bg-card text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out focus-within:border-blue-500 focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)]',
194
+ 'rounded-xl border border-field-border shadow-control bg-card text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out focus-within:border-blue-500 focus-within:shadow-field-focus',
195
195
  disabled && 'pointer-events-none bg-muted text-muted-foreground shadow-none',
196
196
  className,
197
197
  )}
@@ -252,7 +252,7 @@ function EditorToolbar({
252
252
  return (
253
253
  <div
254
254
  data-slot="rich-text-editor-toolbar"
255
- className="flex items-center gap-0.5 border-b border-border-subtle px-2 py-1.5"
255
+ className="flex items-center gap-0.5 rounded-t-[inherit] bg-secondary px-2 py-1.5"
256
256
  >
257
257
  <ToolbarButton
258
258
  active={toolbar.isBold}
@@ -375,7 +375,7 @@ function ToolbarButton({
375
375
  aria-label={title}
376
376
  aria-pressed={active}
377
377
  className={cn(
378
- 'inline-flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-40 disabled:pointer-events-none',
378
+ 'inline-flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-accent-strong-hover hover:text-foreground transition-colors disabled:opacity-40 disabled:pointer-events-none',
379
379
  active && 'bg-accent-strong text-foreground',
380
380
  )}
381
381
  >
package/src/ui/select.tsx CHANGED
@@ -1,7 +1,8 @@
1
1
  import { Select as SelectPrimitive } from '@base-ui/react/select'
2
2
  import type * as React from 'react'
3
+ import { fieldTriggerClassName } from '../lib/field-trigger'
3
4
  import { cn } from '../lib/utils'
4
- import { CheckIcon, ChevronDownIcon } from '../spree/icons'
5
+ import { CheckIcon, ChevronDownIcon, ChevronsUpDownIcon } from '../spree/icons'
5
6
 
6
7
  function Select({
7
8
  children,
@@ -54,26 +55,16 @@ function SelectTrigger({
54
55
  data-slot="select-trigger"
55
56
  data-size={size}
56
57
  className={cn(
57
- // `min-h-8` matches `<Input>`'s own minimum, so a select and a text
58
- // field on the same row are the same height. It also stops the trigger
59
- // collapsing to chevron-height when the SelectValue render-prop returns
60
- // an empty string (value unset and the placeholder not surfaced through
61
- // the render-prop); `data-[size=sm]` keeps its own floor for the same
62
- // reason.
63
- //
64
- // `sm` is the toolbar size, matching `Button size="sm" variant="outline"`
65
- // exactly — height, gap, padding, text, icon size and the resting lift.
66
- // A row mixing a select with a popover trigger (the dashboard's channel
67
- // picker beside its date range) then reads as one set of controls
68
- // rather than two. The default size is a form field and stays flat.
69
- "group/select-trigger flex w-full min-h-8 cursor-pointer items-center justify-between gap-1.5 rounded-md border border-border shadow-control bg-card py-1.5 pr-2 pl-2.5 text-base font-normal leading-normal text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out outline-none select-none 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 data-[placeholder]:text-muted-foreground data-[size=sm]:shadow-control data-[size=sm]:h-8 data-[size=sm]:min-h-8 data-[size=sm]:gap-2 data-[size=sm]:px-2 data-[size=sm]:py-1 data-[size=sm]:text-sm data-[size=sm]:[&_svg:not([class*='size-'])]:size-3.5 *: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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
58
+ 'group/select-trigger',
59
+ fieldTriggerClassName,
60
+ "data-[placeholder]:text-muted-foreground data-[size=sm]:[&_svg:not([class*='size-'])]:size-3.5 *: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",
70
61
  className,
71
62
  )}
72
63
  {...props}
73
64
  >
74
65
  {children}
75
66
  <SelectPrimitive.Icon>
76
- <ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground group-data-[size=sm]/select-trigger:size-3.5" />
67
+ <ChevronsUpDownIcon className="pointer-events-none size-4 text-muted-foreground group-data-[size=sm]/select-trigger:size-3.5" />
77
68
  </SelectPrimitive.Icon>
78
69
  </SelectPrimitive.Trigger>
79
70
  )
@@ -105,7 +96,7 @@ function SelectContent({
105
96
  data-slot="select-content"
106
97
  data-align-trigger={position === 'item-aligned'}
107
98
  className={cn(
108
- 'relative z-[100] max-h-[var(--available-height)] min-w-[var(--anchor-width)] overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 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]',
99
+ 'relative z-[100] max-h-[var(--available-height)] min-w-[var(--anchor-width)] overflow-x-hidden overflow-y-auto rounded-xl bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 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]',
109
100
  position === 'popper' &&
110
101
  'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
111
102
  className,
package/src/ui/sheet.tsx CHANGED
@@ -129,13 +129,21 @@ function SheetContent({
129
129
  // `translate`; the rest are listed because `className` is merged, so
130
130
  // a caller can animate any of them — and a missing entry fails
131
131
  // silently, as a jump rather than an error.
132
- 'fixed z-50 flex flex-col border border-border bg-card text-foreground text-sm shadow-lg transition-[opacity,transform,translate,scale] duration-250 ease-out',
133
- 'data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-[calc(100dvh-1rem)] data-[side=right]:max-h-[calc(100dvh-1rem)] data-[side=right]:w-[calc(100vw-1rem)] data-[side=right]:max-w-[600px] data-[side=right]:min-w-[320px] data-[side=right]:m-2 data-[side=right]:rounded-xl',
132
+ 'fixed z-50 flex flex-col border border-border bg-card text-foreground text-base shadow-lg transition-[opacity,transform,translate,scale] duration-250 ease-out',
133
+ 'data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-[calc(100dvh-1rem)] data-[side=right]:max-h-[calc(100dvh-1rem)] data-[side=right]:w-[calc(100vw-1rem)] data-[side=right]:max-w-[600px] data-[side=right]:min-w-[320px] data-[side=right]:m-2 data-[side=right]:rounded-2xl',
134
134
  'data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:sm:max-w-sm',
135
135
  'data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t',
136
136
  'data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b',
137
137
  'data-[starting-style]:opacity-0 data-[side=right]:data-[starting-style]:translate-x-10 data-[side=left]:data-[starting-style]:-translate-x-10 data-[side=bottom]:data-[starting-style]:translate-y-10 data-[side=top]:data-[starting-style]:-translate-y-10',
138
138
  'data-[ending-style]:opacity-0 data-[side=right]:data-[ending-style]:translate-x-10 data-[side=left]:data-[ending-style]:-translate-x-10 data-[side=bottom]:data-[ending-style]:translate-y-10 data-[side=top]:data-[ending-style]:-translate-y-10',
139
+ // A sheet opened from inside this one stacks on top, as toasts do:
140
+ // this one shrinks from its outer edge and slides out far enough to
141
+ // peek from behind, a step per open sheet above it, and dims.
142
+ 'after:pointer-events-none after:absolute after:inset-0 after:z-20 after:rounded-[inherit] after:bg-black/0 after:transition-colors after:duration-250',
143
+ 'data-[nested-dialog-open]:after:bg-black/5 dark:data-[nested-dialog-open]:after:bg-black/30',
144
+ 'data-[side=right]:origin-left data-[side=left]:origin-right',
145
+ 'data-[nested-dialog-open]:scale-[calc(1-0.05*var(--nested-dialogs))]',
146
+ 'data-[side=right]:data-[nested-dialog-open]:translate-x-[calc(-20px*var(--nested-dialogs))] data-[side=left]:data-[nested-dialog-open]:translate-x-[calc(20px*var(--nested-dialogs))]',
139
147
  className,
140
148
  )}
141
149
  {...props}
@@ -207,7 +215,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
207
215
  <div
208
216
  data-slot="sheet-footer"
209
217
  className={cn(
210
- 'flex flex-col-reverse gap-2 border-t border-border-subtle bg-muted/50 p-4 sm:flex-row sm:justify-end rounded-b-xl',
218
+ 'flex flex-col-reverse gap-2 border-t border-border-subtle bg-muted/50 p-4 sm:flex-row sm:justify-end rounded-b-2xl',
211
219
  className,
212
220
  )}
213
221
  {...props}
@@ -219,7 +227,7 @@ function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPr
219
227
  return (
220
228
  <SheetPrimitive.Title
221
229
  data-slot="sheet-title"
222
- className={cn('text-lg font-medium', className)}
230
+ className={cn('text-lg font-semibold', className)}
223
231
  {...props}
224
232
  />
225
233
  )
@@ -232,7 +240,7 @@ function SheetDescription({
232
240
  return (
233
241
  <SheetPrimitive.Description
234
242
  data-slot="sheet-description"
235
- className={cn('text-sm text-muted-foreground', className)}
243
+ className={cn('text-base text-muted-foreground', className)}
236
244
  {...props}
237
245
  />
238
246
  )