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

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 (94) hide show
  1. package/README.md +1 -1
  2. package/package.json +13 -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 +38 -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/back-button.tsx +14 -9
  11. package/src/spree/bulk-dialog.tsx +2 -2
  12. package/src/spree/bulk-price-table.tsx +145 -14
  13. package/src/spree/bulk-variants-table.tsx +434 -0
  14. package/src/spree/calculator-summary.tsx +5 -1
  15. package/src/spree/claim-resolve-dialog.tsx +219 -0
  16. package/src/spree/confirm-dialog.tsx +1 -1
  17. package/src/spree/copy-to-clipboard-button.tsx +1 -1
  18. package/src/spree/data-grid/cells.tsx +199 -25
  19. package/src/spree/data-grid/context.ts +6 -0
  20. package/src/spree/data-grid/data-grid.tsx +149 -7
  21. package/src/spree/data-grid/helpers.ts +6 -2
  22. package/src/spree/data-grid/index.ts +9 -1
  23. package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
  24. package/src/spree/delivery-form-dialog.tsx +166 -0
  25. package/src/spree/drag-handle.tsx +2 -2
  26. package/src/spree/editable-note-card.tsx +103 -0
  27. package/src/spree/external-link.tsx +56 -0
  28. package/src/spree/file-type-icon.tsx +68 -0
  29. package/src/spree/form-actions.tsx +2 -3
  30. package/src/spree/form-section.tsx +46 -0
  31. package/src/spree/fulfill-items-form.tsx +282 -0
  32. package/src/spree/fulfillment-edit-dialog.tsx +211 -0
  33. package/src/spree/fulfillment-item-list.tsx +61 -0
  34. package/src/spree/fulfillment-panel.tsx +53 -0
  35. package/src/spree/icons.ts +167 -0
  36. package/src/spree/json-preview-drawer.tsx +4 -4
  37. package/src/spree/label-upload-dialog.tsx +195 -0
  38. package/src/spree/language-menu-items.tsx +37 -30
  39. package/src/spree/metadata/metadata-card.tsx +1 -1
  40. package/src/spree/order-cancel-dialog.tsx +149 -0
  41. package/src/spree/post-sale-create-dialogs.tsx +253 -0
  42. package/src/spree/post-sale-fields.tsx +133 -0
  43. package/src/spree/product-membership-list.tsx +453 -0
  44. package/src/spree/products/status-card.tsx +79 -0
  45. package/src/spree/quantity-tier-editor.tsx +162 -0
  46. package/src/spree/resource-combobox.tsx +12 -1
  47. package/src/spree/resource-name-cell.tsx +21 -4
  48. package/src/spree/return-dialogs.tsx +238 -0
  49. package/src/spree/route-error-boundary.tsx +1 -1
  50. package/src/spree/row-actions.tsx +2 -2
  51. package/src/spree/search-input.tsx +76 -0
  52. package/src/spree/secret-input.tsx +26 -40
  53. package/src/spree/tax-identifiers-card.tsx +418 -0
  54. package/src/spree/theme-toggle.tsx +36 -23
  55. package/src/spree/wizard.tsx +254 -0
  56. package/src/styles.css +750 -75
  57. package/src/ui/accordion.tsx +83 -0
  58. package/src/ui/alert.tsx +79 -0
  59. package/src/ui/attachment.tsx +1 -1
  60. package/src/ui/badge.tsx +198 -56
  61. package/src/ui/breadcrumb.tsx +1 -1
  62. package/src/ui/button.tsx +22 -10
  63. package/src/ui/calendar.tsx +1 -1
  64. package/src/ui/card.tsx +52 -6
  65. package/src/ui/checkbox.tsx +13 -5
  66. package/src/ui/combobox.tsx +125 -6
  67. package/src/ui/command.tsx +149 -23
  68. package/src/ui/context-menu.tsx +99 -0
  69. package/src/ui/data-table.tsx +448 -9
  70. package/src/ui/date-picker.tsx +18 -3
  71. package/src/ui/date-range-picker.tsx +47 -60
  72. package/src/ui/dialog.tsx +6 -6
  73. package/src/ui/dropdown-menu.tsx +18 -11
  74. package/src/ui/field.tsx +10 -2
  75. package/src/ui/input-group.tsx +1 -1
  76. package/src/ui/input.tsx +1 -1
  77. package/src/ui/media-preview.tsx +105 -0
  78. package/src/ui/pagination.tsx +19 -11
  79. package/src/ui/popover.tsx +4 -2
  80. package/src/ui/progress.tsx +5 -2
  81. package/src/ui/radio-group.tsx +4 -4
  82. package/src/ui/rich-text-editor.tsx +214 -88
  83. package/src/ui/scroll-area.tsx +65 -0
  84. package/src/ui/select.tsx +17 -10
  85. package/src/ui/separator.tsx +1 -1
  86. package/src/ui/sheet.tsx +49 -7
  87. package/src/ui/sidebar.tsx +138 -37
  88. package/src/ui/skeleton.tsx +3 -1
  89. package/src/ui/tabs.tsx +84 -0
  90. package/src/ui/textarea.tsx +1 -1
  91. package/src/ui/thumbnail.tsx +80 -0
  92. package/src/ui/toast.tsx +112 -0
  93. package/src/ui/tooltip.tsx +3 -1
  94. package/src/ui/sonner.tsx +0 -47
@@ -18,12 +18,12 @@ 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 rounded-full border border-input align-[-3px] shadow-xs outline-none transition-shadow',
22
- 'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
23
- 'disabled:cursor-not-allowed disabled:opacity-50',
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)]',
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',
26
- 'dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[checked]:bg-blue-500',
26
+ 'dark:aria-invalid:ring-destructive/40 dark:data-[checked]:bg-blue-500',
27
27
  className,
28
28
  )}
29
29
  {...props}
@@ -1,9 +1,16 @@
1
+ import Image from '@tiptap/extension-image'
1
2
  import Link from '@tiptap/extension-link'
2
3
  import Placeholder from '@tiptap/extension-placeholder'
3
- import { EditorContent, useEditor } from '@tiptap/react'
4
+ import type { Editor } from '@tiptap/react'
5
+ import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
4
6
  import StarterKit from '@tiptap/starter-kit'
7
+ import { useCallback, useEffect, useMemo, useRef } from 'react'
8
+ import { useTranslation } from 'react-i18next'
9
+ import { sameRichText, shouldEmitRichTextChange } from '../lib/same-rich-text'
10
+ import { cn } from '../lib/utils'
5
11
  import {
6
12
  BoldIcon,
13
+ ImageIcon,
7
14
  ItalicIcon,
8
15
  LinkIcon,
9
16
  ListIcon,
@@ -12,12 +19,9 @@ import {
12
19
  RedoIcon,
13
20
  StrikethroughIcon,
14
21
  UndoIcon,
15
- } from 'lucide-react'
16
- import { useCallback, useEffect, useRef } from 'react'
17
- import { useTranslation } from 'react-i18next'
18
- import { cn } from '../lib/utils'
22
+ } from '../spree/icons'
19
23
 
20
- interface RichTextEditorProps {
24
+ export interface RichTextEditorProps {
21
25
  value?: string
22
26
  onChange?: (html: string) => void
23
27
  /**
@@ -37,6 +41,16 @@ interface RichTextEditorProps {
37
41
  * test locators.
38
42
  */
39
43
  id?: string
44
+ /**
45
+ * Asks the caller for an image to insert, resolving to its URL and alt text
46
+ * (or null if the merchant backs out). Supplying it puts an image button in
47
+ * the toolbar; without it the editor has no way to add one.
48
+ *
49
+ * The editor stays headless — the media library lives in the app, so
50
+ * choosing the picture is the caller's job and this component only places
51
+ * what comes back.
52
+ */
53
+ onRequestImage?: () => Promise<{ url: string; alt?: string | null } | null>
40
54
  }
41
55
 
42
56
  export function RichTextEditor({
@@ -48,6 +62,7 @@ export function RichTextEditor({
48
62
  disabled = false,
49
63
  ariaLabel,
50
64
  id,
65
+ onRequestImage,
51
66
  }: RichTextEditorProps) {
52
67
  const { t } = useTranslation()
53
68
  const resolvedPlaceholder = placeholder ?? t('admin.components.rich_text_editor.placeholder')
@@ -60,17 +75,29 @@ export function RichTextEditor({
60
75
  // attempts another create instead of an update.
61
76
  const onChangeRef = useRef(onChange)
62
77
  const onBlurRef = useRef(onBlur)
78
+ const valueRef = useRef(value)
63
79
  useEffect(() => {
64
80
  onChangeRef.current = onChange
65
81
  onBlurRef.current = onBlur
82
+ valueRef.current = value
66
83
  })
67
84
 
68
- const editor = useEditor({
69
- extensions: [
70
- StarterKit.configure({ link: false }),
85
+ const extensions = useMemo(
86
+ () => [
87
+ // TrailingNode is on by default in StarterKit 3.x and keeps an extra
88
+ // empty paragraph that list/quote commands then miss or wrap instead
89
+ // of the text the merchant just typed.
90
+ StarterKit.configure({ link: false, trailingNode: false }),
71
91
  Placeholder.configure({ placeholder: resolvedPlaceholder }),
72
92
  Link.configure({ openOnClick: false }),
93
+ // Inline (not block) so an image can sit inside a paragraph, which is
94
+ // how the sanitizer's allowlist expects to find it.
95
+ Image.configure({ inline: true }),
73
96
  ],
97
+ [resolvedPlaceholder],
98
+ )
99
+ const editor = useEditor({
100
+ extensions,
74
101
  content: value,
75
102
  editable: !disabled,
76
103
  editorProps: {
@@ -80,7 +107,9 @@ export function RichTextEditor({
80
107
  },
81
108
  },
82
109
  onUpdate: ({ editor }) => {
83
- onChangeRef.current?.(editor.getHTML())
110
+ const html = editor.getHTML()
111
+ if (!shouldEmitRichTextChange(html, valueRef.current)) return
112
+ onChangeRef.current?.(html)
84
113
  },
85
114
  onBlur: ({ event }) => {
86
115
  // Tiptap fires `onBlur` whenever the contenteditable loses focus —
@@ -112,9 +141,13 @@ export function RichTextEditor({
112
141
  // Sync external value changes (e.g. form reset)
113
142
  useEffect(() => {
114
143
  if (!editor) return
115
- if (editor.getHTML() !== value) {
116
- editor.commands.setContent(value, { emitUpdate: false })
117
- }
144
+ const html = editor.getHTML()
145
+ if (html === value) return
146
+ // Enter adds an empty paragraph the form may not have stored yet.
147
+ // sameRichText treats that as equal, so a naïve !== reset would merge
148
+ // the next typed line back into the previous block.
149
+ if (sameRichText(html, value) && shouldEmitRichTextChange(html, value)) return
150
+ editor.commands.setContent(value, { emitUpdate: false })
118
151
  }, [editor, value])
119
152
 
120
153
  useEffect(() => {
@@ -133,98 +166,172 @@ export function RichTextEditor({
133
166
  }
134
167
  }, [editor, t])
135
168
 
169
+ const insertImage = useCallback(async () => {
170
+ if (!editor || !onRequestImage) return
171
+
172
+ const picked = await onRequestImage()
173
+ if (!picked) return
174
+
175
+ editor
176
+ .chain()
177
+ .focus()
178
+ .setImage({ src: picked.url, alt: picked.alt ?? undefined })
179
+ .run()
180
+ }, [editor, onRequestImage])
181
+
136
182
  if (!editor) return null
137
183
 
138
184
  return (
139
185
  <div
140
186
  ref={wrapperRef}
187
+ data-slot="rich-text-editor"
141
188
  className={cn(
142
- 'rounded-lg border border-border bg-card text-foreground shadow-xs transition-all duration-100 ease-in-out focus-within:border-blue-500 focus-within:shadow-[0_0_0_3px_rgba(59,130,246,0.15)]',
143
- disabled && 'pointer-events-none bg-muted border-border',
189
+ // The same shell as `<Input>` and `<Textarea>` — border, fill, resting
190
+ // shadow, transition and focus treatment — so a description field sits
191
+ // in a form beside them without reading as a different kind of control.
192
+ // `focus-within` rather than `focus`: the element that takes the caret
193
+ // is Tiptap's inner contenteditable, not this wrapper.
194
+ 'rounded-md border border-border 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)]',
195
+ disabled && 'pointer-events-none bg-muted text-muted-foreground shadow-none',
144
196
  className,
145
197
  )}
146
198
  >
147
- {/* Toolbar */}
148
- <div className="flex items-center gap-0.5 border-b border-border px-2 py-1.5">
149
- <ToolbarButton
150
- active={editor.isActive('bold')}
151
- onClick={() => editor.chain().focus().toggleBold().run()}
152
- title={t('admin.components.rich_text_editor.bold')}
153
- >
154
- <BoldIcon className="size-4" />
155
- </ToolbarButton>
156
- <ToolbarButton
157
- active={editor.isActive('italic')}
158
- onClick={() => editor.chain().focus().toggleItalic().run()}
159
- title={t('admin.components.rich_text_editor.italic')}
160
- >
161
- <ItalicIcon className="size-4" />
162
- </ToolbarButton>
163
- <ToolbarButton
164
- active={editor.isActive('strike')}
165
- onClick={() => editor.chain().focus().toggleStrike().run()}
166
- title={t('admin.components.rich_text_editor.strikethrough')}
167
- >
168
- <StrikethroughIcon className="size-4" />
169
- </ToolbarButton>
199
+ <EditorToolbar
200
+ editor={editor}
201
+ onRequestImage={onRequestImage}
202
+ onSetLink={setLink}
203
+ onInsertImage={insertImage}
204
+ />
170
205
 
171
- <ToolbarSeparator />
206
+ <EditorContent editor={editor} className="px-3 py-2" />
207
+ </div>
208
+ )
209
+ }
172
210
 
173
- <ToolbarButton
174
- active={editor.isActive('bulletList')}
175
- onClick={() => editor.chain().focus().toggleBulletList().run()}
176
- title={t('admin.components.rich_text_editor.bullet_list')}
177
- >
178
- <ListIcon className="size-4" />
179
- </ToolbarButton>
180
- <ToolbarButton
181
- active={editor.isActive('orderedList')}
182
- onClick={() => editor.chain().focus().toggleOrderedList().run()}
183
- title={t('admin.components.rich_text_editor.ordered_list')}
184
- >
185
- <ListOrderedIcon className="size-4" />
211
+ /**
212
+ * Isolated so `useEditorState` can re-render the buttons when the selection
213
+ * moves. Tiptap v3 no longer re-renders `useEditor` on every transaction, so
214
+ * reading `editor.isActive(...)` in the parent would stay stale.
215
+ */
216
+ function EditorToolbar({
217
+ editor,
218
+ onRequestImage,
219
+ onSetLink,
220
+ onInsertImage,
221
+ }: {
222
+ editor: Editor
223
+ onRequestImage?: RichTextEditorProps['onRequestImage']
224
+ onSetLink: () => void
225
+ onInsertImage: () => Promise<void>
226
+ }) {
227
+ const { t } = useTranslation()
228
+ const toolbar = useEditorState({
229
+ editor,
230
+ selector: ({ editor: current }) => ({
231
+ isBold: current.isActive('bold'),
232
+ isItalic: current.isActive('italic'),
233
+ isStrike: current.isActive('strike'),
234
+ isBulletList: current.isActive('bulletList'),
235
+ isOrderedList: current.isActive('orderedList'),
236
+ isBlockquote: current.isActive('blockquote'),
237
+ isLink: current.isActive('link'),
238
+ canUndo: current.can().undo(),
239
+ canRedo: current.can().redo(),
240
+ }),
241
+ })
242
+
243
+ const runToolbarCommand = (command: () => void) => {
244
+ command()
245
+ // Ctrl/Cmd+A is an AllSelection. Collapse it so the button the
246
+ // merchant just used lights up against the formatted text.
247
+ if (editor.state.selection.toJSON().type === 'all') {
248
+ editor.commands.setTextSelection(1)
249
+ }
250
+ }
251
+
252
+ return (
253
+ <div
254
+ data-slot="rich-text-editor-toolbar"
255
+ className="flex items-center gap-0.5 border-b border-border px-2 py-1.5"
256
+ >
257
+ <ToolbarButton
258
+ active={toolbar.isBold}
259
+ onClick={() => runToolbarCommand(() => editor.chain().focus().toggleBold().run())}
260
+ title={t('admin.components.rich_text_editor.bold')}
261
+ >
262
+ <BoldIcon className="size-4" />
263
+ </ToolbarButton>
264
+ <ToolbarButton
265
+ active={toolbar.isItalic}
266
+ onClick={() => runToolbarCommand(() => editor.chain().focus().toggleItalic().run())}
267
+ title={t('admin.components.rich_text_editor.italic')}
268
+ >
269
+ <ItalicIcon className="size-4" />
270
+ </ToolbarButton>
271
+ <ToolbarButton
272
+ active={toolbar.isStrike}
273
+ onClick={() => runToolbarCommand(() => editor.chain().focus().toggleStrike().run())}
274
+ title={t('admin.components.rich_text_editor.strikethrough')}
275
+ >
276
+ <StrikethroughIcon className="size-4" />
277
+ </ToolbarButton>
278
+
279
+ <ToolbarSeparator />
280
+
281
+ <ToolbarButton
282
+ active={toolbar.isBulletList}
283
+ onClick={() => runToolbarCommand(() => editor.chain().focus().toggleBulletList().run())}
284
+ title={t('admin.components.rich_text_editor.bullet_list')}
285
+ >
286
+ <ListIcon className="size-4" />
287
+ </ToolbarButton>
288
+ <ToolbarButton
289
+ active={toolbar.isOrderedList}
290
+ onClick={() => runToolbarCommand(() => editor.chain().focus().toggleOrderedList().run())}
291
+ title={t('admin.components.rich_text_editor.ordered_list')}
292
+ >
293
+ <ListOrderedIcon className="size-4" />
294
+ </ToolbarButton>
295
+ <ToolbarButton
296
+ active={toolbar.isBlockquote}
297
+ onClick={() => runToolbarCommand(() => editor.chain().focus().toggleBlockquote().run())}
298
+ title={t('admin.components.rich_text_editor.blockquote')}
299
+ >
300
+ <QuoteIcon className="size-4" />
301
+ </ToolbarButton>
302
+
303
+ <ToolbarSeparator />
304
+
305
+ <ToolbarButton
306
+ active={toolbar.isLink}
307
+ onClick={() => runToolbarCommand(onSetLink)}
308
+ title={t('admin.components.rich_text_editor.link')}
309
+ >
310
+ <LinkIcon className="size-4" />
311
+ </ToolbarButton>
312
+
313
+ {onRequestImage && (
314
+ <ToolbarButton onClick={onInsertImage} title={t('admin.components.rich_text_editor.image')}>
315
+ <ImageIcon className="size-4" />
186
316
  </ToolbarButton>
317
+ )}
318
+
319
+ <div className="ml-auto flex items-center gap-0.5">
187
320
  <ToolbarButton
188
- active={editor.isActive('blockquote')}
189
- onClick={() => editor.chain().focus().toggleBlockquote().run()}
190
- title={t('admin.components.rich_text_editor.blockquote')}
321
+ onClick={() => editor.chain().focus().undo().run()}
322
+ disabled={!toolbar.canUndo}
323
+ title={t('admin.components.rich_text_editor.undo')}
191
324
  >
192
- <QuoteIcon className="size-4" />
325
+ <UndoIcon className="size-4" />
193
326
  </ToolbarButton>
194
-
195
- <ToolbarSeparator />
196
-
197
327
  <ToolbarButton
198
- active={editor.isActive('link')}
199
- onClick={setLink}
200
- title={t('admin.components.rich_text_editor.link')}
328
+ onClick={() => editor.chain().focus().redo().run()}
329
+ disabled={!toolbar.canRedo}
330
+ title={t('admin.components.rich_text_editor.redo')}
201
331
  >
202
- <LinkIcon className="size-4" />
332
+ <RedoIcon className="size-4" />
203
333
  </ToolbarButton>
204
-
205
- <div className="ml-auto flex items-center gap-0.5">
206
- <ToolbarButton
207
- onClick={() => editor.chain().focus().undo().run()}
208
- disabled={!editor.can().undo()}
209
- title={t('admin.components.rich_text_editor.undo')}
210
- >
211
- <UndoIcon className="size-4" />
212
- </ToolbarButton>
213
- <ToolbarButton
214
- onClick={() => editor.chain().focus().redo().run()}
215
- disabled={!editor.can().redo()}
216
- title={t('admin.components.rich_text_editor.redo')}
217
- >
218
- <RedoIcon className="size-4" />
219
- </ToolbarButton>
220
- </div>
221
334
  </div>
222
-
223
- {/* Editor */}
224
- <EditorContent
225
- editor={editor}
226
- className="prose prose-sm max-w-none px-3 py-2 dark:prose-invert [&_.tiptap]:min-h-32 [&_.tiptap]:outline-none [&_.tiptap.is-editor-empty:first-child::before]:text-muted-foreground [&_.tiptap.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.tiptap.is-editor-empty:first-child::before]:float-left [&_.tiptap.is-editor-empty:first-child::before]:h-0 [&_.tiptap.is-editor-empty:first-child::before]:pointer-events-none"
227
- />
228
335
  </div>
229
336
  )
230
337
  }
@@ -245,12 +352,31 @@ function ToolbarButton({
245
352
  return (
246
353
  <button
247
354
  type="button"
248
- onClick={onClick}
355
+ onPointerDown={(event) => {
356
+ if (event.button !== 0) return
357
+ // Keep the editor selection and run the command here. preventDefault
358
+ // on pointer/mouse down stops the contenteditable from blurring, but
359
+ // it also swallows the following click in Chromium — which is how
360
+ // Playwright activates the button.
361
+ event.preventDefault()
362
+ onClick()
363
+ }}
364
+ onClick={(event) => {
365
+ // Pointer activations already ran above (`detail` is 0 for keyboard
366
+ // and assistive-technology clicks). Do not keep a flag across
367
+ // events: undo/redo can disable this button before the paired
368
+ // click arrives, and a leftover flag would discard the next
369
+ // keyboard activation.
370
+ if (event.detail !== 0) return
371
+ onClick()
372
+ }}
249
373
  disabled={disabled}
250
374
  title={title}
375
+ aria-label={title}
376
+ aria-pressed={active}
251
377
  className={cn(
252
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',
253
- active && 'bg-accent text-foreground',
379
+ active && 'bg-accent-strong text-foreground',
254
380
  )}
255
381
  >
256
382
  {children}
@@ -0,0 +1,65 @@
1
+ import { ScrollArea as ScrollAreaPrimitive } from '@base-ui/react/scroll-area'
2
+ import type * as React from 'react'
3
+ import { cn } from '../lib/utils'
4
+
5
+ /**
6
+ * shadcn's scroll-area, on Base UI rather than Radix — the registry ships the
7
+ * Radix build for this project's style, and nothing else here imports Radix.
8
+ * Same parts and the same slot names; the scrollbar fades in on hover or
9
+ * scroll, which is Base UI's own behaviour rather than a Radix `type` prop.
10
+ *
11
+ * Height belongs to the caller: set it here, or let a flex parent size it.
12
+ */
13
+ function ScrollArea({
14
+ className,
15
+ children,
16
+ ...props
17
+ }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
18
+ return (
19
+ <ScrollAreaPrimitive.Root
20
+ data-slot="scroll-area"
21
+ className={cn('relative', className)}
22
+ {...props}
23
+ >
24
+ <ScrollAreaPrimitive.Viewport
25
+ data-slot="scroll-area-viewport"
26
+ className="size-full overscroll-contain rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)]"
27
+ >
28
+ {children}
29
+ </ScrollAreaPrimitive.Viewport>
30
+ <ScrollBar />
31
+ <ScrollAreaPrimitive.Corner />
32
+ </ScrollAreaPrimitive.Root>
33
+ )
34
+ }
35
+
36
+ function ScrollBar({
37
+ className,
38
+ orientation = 'vertical',
39
+ ...props
40
+ }: React.ComponentProps<typeof ScrollAreaPrimitive.Scrollbar>) {
41
+ return (
42
+ <ScrollAreaPrimitive.Scrollbar
43
+ data-slot="scroll-area-scrollbar"
44
+ orientation={orientation}
45
+ className={cn(
46
+ 'flex touch-none select-none p-px',
47
+ 'data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent',
48
+ 'data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2.5 data-[orientation=vertical]:border-l data-[orientation=vertical]:border-l-transparent',
49
+ // Base UI marks the bar while it is scrolled or hovered, so it can
50
+ // stay out of the way of a short list until it is wanted.
51
+ 'opacity-0 transition-opacity duration-150 ease-out data-[hovering]:opacity-100 data-[scrolling]:opacity-100',
52
+ 'motion-reduce:transition-none',
53
+ className,
54
+ )}
55
+ {...props}
56
+ >
57
+ <ScrollAreaPrimitive.Thumb
58
+ data-slot="scroll-area-thumb"
59
+ className="relative flex-1 rounded-full bg-border-control"
60
+ />
61
+ </ScrollAreaPrimitive.Scrollbar>
62
+ )
63
+ }
64
+
65
+ export { ScrollArea, ScrollBar }
package/src/ui/select.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Select as SelectPrimitive } from '@base-ui/react/select'
2
- import { CheckIcon, ChevronDownIcon } from 'lucide-react'
3
2
  import type * as React from 'react'
4
3
  import { cn } from '../lib/utils'
4
+ import { CheckIcon, ChevronDownIcon } from '../spree/icons'
5
5
 
6
6
  function Select({
7
7
  children,
@@ -54,19 +54,26 @@ function SelectTrigger({
54
54
  data-slot="select-trigger"
55
55
  data-size={size}
56
56
  className={cn(
57
- // `min-h-9.5` (default) and `data-[size=sm]:min-h-[1.9375rem]` lock
58
- // the trigger to the same natural height as `<Input>` so the field
59
- // doesn't collapse to chevron-height when the SelectValue render-prop
60
- // returns an empty string (e.g., when value is unset and the
61
- // placeholder isn't surfaced through the render-prop).
62
- "flex w-full min-h-8.5 items-center justify-between gap-1.5 rounded-lg border border-border bg-card py-1.5 pr-2 pl-2.5 text-base font-normal leading-normal text-foreground shadow-xs transition-all duration-100 ease-in-out outline-none select-none 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 data-[placeholder]:text-muted-foreground data-[size=sm]:py-1 data-[size=sm]:px-2 data-[size=sm]:text-sm data-[size=sm]:min-h-[1.9375rem] *: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",
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 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-xs 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",
63
70
  className,
64
71
  )}
65
72
  {...props}
66
73
  >
67
74
  {children}
68
75
  <SelectPrimitive.Icon>
69
- <ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
76
+ <ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground group-data-[size=sm]/select-trigger:size-3.5" />
70
77
  </SelectPrimitive.Icon>
71
78
  </SelectPrimitive.Trigger>
72
79
  )
@@ -98,7 +105,7 @@ function SelectContent({
98
105
  data-slot="select-content"
99
106
  data-align-trigger={position === 'item-aligned'}
100
107
  className={cn(
101
- '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-md 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]',
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]',
102
109
  position === 'popper' &&
103
110
  'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
104
111
  className,
@@ -137,7 +144,7 @@ function SelectItem({
137
144
  // Padding + font sized to match `<Input>` and `<SelectTrigger>` so
138
145
  // the dropdown rows feel like a continuation of the trigger field.
139
146
  // `pr-8` reserves room for the absolute-positioned check indicator.
140
- "relative flex w-full cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2.5 text-base font-normal leading-normal outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
147
+ "relative flex w-full cursor-pointer items-center gap-2 rounded-md py-1.5 pr-8 pl-2.5 text-base font-normal leading-normal outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
141
148
  className,
142
149
  )}
143
150
  {...props}
@@ -20,7 +20,7 @@ function Separator({
20
20
  orientation={orientation}
21
21
  data-orientation={orientation}
22
22
  className={cn(
23
- 'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px',
23
+ 'shrink-0 bg-border-subtle data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px',
24
24
  className,
25
25
  )}
26
26
  {...props}
package/src/ui/sheet.tsx CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  import { Dialog as SheetPrimitive } from '@base-ui/react/dialog'
4
4
  import i18n from 'i18next'
5
- import { XIcon } from 'lucide-react'
6
5
  import * as React from 'react'
6
+ import { useScrolled } from '../hooks/use-scrolled'
7
7
  import { cn } from '../lib/utils'
8
+ import { XIcon } from '../spree/icons'
8
9
  import { Button } from './button'
9
10
 
10
11
  function Sheet({
@@ -94,7 +95,7 @@ function SheetOverlay({
94
95
  <SheetPrimitive.Backdrop
95
96
  data-slot="sheet-overlay"
96
97
  className={cn(
97
- 'fixed inset-0 z-50 bg-gray-100/75 dark:bg-black/60 duration-200 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 transition-opacity',
98
+ 'fixed inset-0 z-50 bg-background/60 dark:bg-black/60 duration-200 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 transition-opacity',
98
99
  className,
99
100
  )}
100
101
  {...props}
@@ -122,7 +123,13 @@ function SheetContent({
122
123
  data-side={side}
123
124
  dir={dir}
124
125
  className={cn(
125
- 'fixed z-50 flex flex-col border border-border bg-card text-foreground text-sm shadow-lg transition duration-250 ease-out',
126
+ // Tailwind v4 compiles `translate-*`/`scale-*` to the standalone
127
+ // `translate`/`scale` properties rather than folding them into
128
+ // `transform`, so all four are listed. The slide below needs
129
+ // `translate`; the rest are listed because `className` is merged, so
130
+ // a caller can animate any of them — and a missing entry fails
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',
126
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',
127
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',
128
135
  'data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t',
@@ -138,7 +145,13 @@ function SheetContent({
138
145
  <SheetPrimitive.Close
139
146
  data-slot="sheet-close"
140
147
  render={
141
- <Button variant="ghost" className="absolute top-3 right-3" size="icon-sm">
148
+ // `size-11` on touch: at `icon-sm`'s 28px this is well under the
149
+ // 44px minimum, and on a nav sheet it is the primary way out.
150
+ <Button
151
+ variant="ghost"
152
+ className="absolute top-2 right-2 size-11 md:top-3 md:right-3 md:size-7"
153
+ size="icon-sm"
154
+ >
142
155
  <XIcon />
143
156
  <span className="sr-only">{i18n.t('admin.actions.close')}</span>
144
157
  </Button>
@@ -150,11 +163,40 @@ function SheetContent({
150
163
  )
151
164
  }
152
165
 
166
+ /**
167
+ * A sheet's title bar. It keeps its separating rule at all times and gains a
168
+ * soft lift once the body scrolls under it, on the same trigger and curve as
169
+ * `PageHeader`.
170
+ */
153
171
  function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
172
+ // A caller passing `sr-only` wants the title announced but not drawn. Merging
173
+ // it with the visible chrome does not achieve that: `cn` keeps `flex` (a
174
+ // different utility group from `sr-only`'s `absolute`), so the element stays
175
+ // a laid-out box — a 33px bar with a border, above the nav. Detect the intent
176
+ // and render the bare utility instead.
177
+ const srOnly = className?.split(' ').includes('sr-only')
178
+ // Hooks cannot be called conditionally, so this reads the flag before the
179
+ // early return even though the bare variant never uses it.
180
+ const scrolled = useScrolled()
181
+ if (srOnly) return <div data-slot="sheet-header" className={className} {...props} />
182
+
154
183
  return (
155
184
  <div
156
185
  data-slot="sheet-header"
157
- className={cn('relative flex flex-col gap-1.5 border-b p-4 pr-12', className)}
186
+ className={cn(
187
+ // No `z-index` here: the close button is a sibling rendered BEFORE this,
188
+ // so any stacking context on the header paints over it and swallows the
189
+ // clicks. The header needs no lift anyway — it is a flex sibling of the
190
+ // body rather than an overlay, so nothing scrolls under it.
191
+ 'flex shrink-0 flex-col gap-1.5 border-b border-border-subtle p-4 pr-12',
192
+ // The rule stays: a sheet header is a fixed band over its own scroller,
193
+ // so it is divided from the body whether or not anything has moved. The
194
+ // lift is what scrolling adds, on the same trigger and curve as
195
+ // `PageHeader`.
196
+ 'transition-shadow duration-200 ease-out motion-reduce:transition-none',
197
+ scrolled && 'shadow-xs',
198
+ className,
199
+ )}
158
200
  {...props}
159
201
  />
160
202
  )
@@ -165,7 +207,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
165
207
  <div
166
208
  data-slot="sheet-footer"
167
209
  className={cn(
168
- 'flex flex-col-reverse gap-2 border-t bg-muted/50 p-4 sm:flex-row sm:justify-end rounded-b-xl',
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',
169
211
  className,
170
212
  )}
171
213
  {...props}
@@ -177,7 +219,7 @@ function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPr
177
219
  return (
178
220
  <SheetPrimitive.Title
179
221
  data-slot="sheet-title"
180
- className={cn('text-base leading-none font-medium', className)}
222
+ className={cn('text-lg font-medium', className)}
181
223
  {...props}
182
224
  />
183
225
  )