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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +1 -1
  2. package/package.json +15 -9
  3. package/src/hooks/use-prefers-reduced-motion.ts +26 -0
  4. package/src/hooks/use-scrolled.ts +34 -14
  5. package/src/index.ts +39 -1
  6. package/src/lib/date-range-presets.ts +79 -0
  7. package/src/lib/same-rich-text.ts +145 -0
  8. package/src/spree/address-block.tsx +5 -4
  9. package/src/spree/address-book-row.tsx +96 -0
  10. package/src/spree/address-map.tsx +62 -0
  11. package/src/spree/back-button.tsx +14 -9
  12. package/src/spree/bulk-dialog.tsx +2 -2
  13. package/src/spree/bulk-price-table.tsx +145 -14
  14. package/src/spree/bulk-variants-table.tsx +434 -0
  15. package/src/spree/calculator-summary.tsx +5 -1
  16. package/src/spree/claim-resolve-dialog.tsx +219 -0
  17. package/src/spree/confirm-dialog.tsx +1 -1
  18. package/src/spree/copy-to-clipboard-button.tsx +1 -1
  19. package/src/spree/data-grid/cells.tsx +199 -25
  20. package/src/spree/data-grid/context.ts +6 -0
  21. package/src/spree/data-grid/data-grid.tsx +149 -7
  22. package/src/spree/data-grid/helpers.ts +6 -2
  23. package/src/spree/data-grid/index.ts +9 -1
  24. package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
  25. package/src/spree/delivery-form-dialog.tsx +166 -0
  26. package/src/spree/drag-handle.tsx +2 -2
  27. package/src/spree/editable-note-card.tsx +103 -0
  28. package/src/spree/external-link.tsx +56 -0
  29. package/src/spree/file-type-icon.tsx +68 -0
  30. package/src/spree/form-actions.tsx +2 -3
  31. package/src/spree/form-section.tsx +46 -0
  32. package/src/spree/fulfill-items-form.tsx +282 -0
  33. package/src/spree/fulfillment-edit-dialog.tsx +211 -0
  34. package/src/spree/fulfillment-item-list.tsx +61 -0
  35. package/src/spree/fulfillment-panel.tsx +53 -0
  36. package/src/spree/icons.ts +167 -0
  37. package/src/spree/json-preview-drawer.tsx +4 -4
  38. package/src/spree/label-upload-dialog.tsx +195 -0
  39. package/src/spree/language-menu-items.tsx +37 -30
  40. package/src/spree/metadata/metadata-card.tsx +1 -1
  41. package/src/spree/order-cancel-dialog.tsx +149 -0
  42. package/src/spree/post-sale-create-dialogs.tsx +253 -0
  43. package/src/spree/post-sale-fields.tsx +133 -0
  44. package/src/spree/product-membership-list.tsx +453 -0
  45. package/src/spree/products/status-card.tsx +79 -0
  46. package/src/spree/quantity-tier-editor.tsx +162 -0
  47. package/src/spree/resource-combobox.tsx +12 -1
  48. package/src/spree/resource-name-cell.tsx +21 -4
  49. package/src/spree/return-dialogs.tsx +238 -0
  50. package/src/spree/route-error-boundary.tsx +1 -1
  51. package/src/spree/row-actions.tsx +2 -2
  52. package/src/spree/search-input.tsx +76 -0
  53. package/src/spree/secret-input.tsx +26 -40
  54. package/src/spree/tax-identifiers-card.tsx +418 -0
  55. package/src/spree/theme-toggle.tsx +36 -23
  56. package/src/spree/wizard.tsx +254 -0
  57. package/src/styles.css +750 -75
  58. package/src/ui/accordion.tsx +83 -0
  59. package/src/ui/alert.tsx +79 -0
  60. package/src/ui/attachment.tsx +1 -1
  61. package/src/ui/badge.tsx +198 -56
  62. package/src/ui/breadcrumb.tsx +1 -1
  63. package/src/ui/button.tsx +22 -10
  64. package/src/ui/calendar.tsx +1 -1
  65. package/src/ui/card.tsx +52 -6
  66. package/src/ui/checkbox.tsx +13 -5
  67. package/src/ui/combobox.tsx +125 -6
  68. package/src/ui/command.tsx +149 -23
  69. package/src/ui/context-menu.tsx +99 -0
  70. package/src/ui/data-table.tsx +448 -9
  71. package/src/ui/date-picker.tsx +18 -3
  72. package/src/ui/date-range-picker.tsx +47 -60
  73. package/src/ui/dialog.tsx +6 -6
  74. package/src/ui/dropdown-menu.tsx +18 -11
  75. package/src/ui/field.tsx +10 -2
  76. package/src/ui/input-group.tsx +1 -1
  77. package/src/ui/input.tsx +1 -1
  78. package/src/ui/map.tsx +2110 -0
  79. package/src/ui/media-preview.tsx +105 -0
  80. package/src/ui/pagination.tsx +19 -11
  81. package/src/ui/popover.tsx +4 -2
  82. package/src/ui/progress.tsx +5 -2
  83. package/src/ui/radio-group.tsx +4 -4
  84. package/src/ui/rich-text-editor.tsx +214 -88
  85. package/src/ui/scroll-area.tsx +65 -0
  86. package/src/ui/select.tsx +17 -10
  87. package/src/ui/separator.tsx +1 -1
  88. package/src/ui/sheet.tsx +49 -7
  89. package/src/ui/sidebar.tsx +138 -37
  90. package/src/ui/skeleton.tsx +3 -1
  91. package/src/ui/tabs.tsx +84 -0
  92. package/src/ui/textarea.tsx +1 -1
  93. package/src/ui/thumbnail.tsx +80 -0
  94. package/src/ui/toast.tsx +112 -0
  95. package/src/ui/tooltip.tsx +3 -1
  96. package/src/ui/sonner.tsx +0 -47
@@ -0,0 +1,105 @@
1
+ import type * as React from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { cn } from '../lib/utils'
4
+ import { FilmIcon, ImagePlusIcon } from '../spree/icons'
5
+
6
+ export interface MediaPreviewProps {
7
+ mediaType?: 'image' | 'video' | 'external_video' | string
8
+ /** Still image URL — the image itself, or a video's poster. */
9
+ previewUrl?: string | null
10
+ /** Playable file for an uploaded video. */
11
+ videoUrl?: string | null
12
+ /**
13
+ * Player URL for an external video. Parsing the provider link is the
14
+ * caller's job — the server is the authority on what is embeddable, and
15
+ * this component only renders what it is handed.
16
+ */
17
+ embedUrl?: string | null
18
+ alt?: string
19
+ /** Normalized 0–1. Drawn as a marker over an image. */
20
+ focalPoint?: { x: number; y: number } | null
21
+ /** Click handler for setting the focal point; images only. */
22
+ onFocalPointClick?: (event: React.MouseEvent<HTMLElement>) => void
23
+ className?: string
24
+ }
25
+
26
+ /**
27
+ * What a media row looks like, whatever its kind: an external video as its
28
+ * provider's embed, an uploaded video as a player, an image as a picture —
29
+ * with a focal point marker when one is set.
30
+ *
31
+ * Headless by the package rule: everything arrives as props, so the same
32
+ * component serves a saved record and a form row that only has blob URLs
33
+ * because it has not been saved yet.
34
+ */
35
+ export function MediaPreview({
36
+ mediaType = 'image',
37
+ previewUrl,
38
+ videoUrl,
39
+ embedUrl,
40
+ alt = '',
41
+ focalPoint,
42
+ onFocalPointClick,
43
+ className = 'max-h-[60vh]',
44
+ }: MediaPreviewProps) {
45
+ const { t } = useTranslation()
46
+
47
+ const isImage = mediaType === 'image'
48
+
49
+ if (mediaType === 'external_video' && embedUrl) {
50
+ return (
51
+ <div className="aspect-video w-full shrink-0 overflow-hidden rounded-lg border border-border bg-black">
52
+ <iframe
53
+ src={embedUrl}
54
+ title={alt || t('admin.components.media_preview.video_title')}
55
+ allowFullScreen
56
+ className="size-full"
57
+ />
58
+ </div>
59
+ )
60
+ }
61
+
62
+ if (mediaType === 'video' && videoUrl) {
63
+ return (
64
+ // biome-ignore lint/a11y/useMediaCaption: merchant-supplied footage has no track to offer
65
+ <video
66
+ src={videoUrl}
67
+ poster={previewUrl ?? undefined}
68
+ controls
69
+ playsInline
70
+ preload="metadata"
71
+ className={cn('w-full shrink-0 rounded-lg bg-black', className)}
72
+ />
73
+ )
74
+ }
75
+
76
+ const canSetFocalPoint = isImage && !!previewUrl && !!onFocalPointClick
77
+
78
+ return (
79
+ <button
80
+ type="button"
81
+ disabled={!canSetFocalPoint}
82
+ onClick={onFocalPointClick}
83
+ className="group relative block w-full shrink-0 overflow-hidden rounded-lg border border-border bg-muted disabled:cursor-default"
84
+ >
85
+ {previewUrl ? (
86
+ <>
87
+ <img src={previewUrl} alt={alt} className={cn('w-full object-contain', className)} />
88
+ {isImage && focalPoint && (
89
+ <span
90
+ aria-hidden
91
+ className="pointer-events-none absolute size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_2px_rgba(0,0,0,0.4)]"
92
+ style={{ left: `${focalPoint.x * 100}%`, top: `${focalPoint.y * 100}%` }}
93
+ />
94
+ )}
95
+ </>
96
+ ) : (
97
+ // A video with no poster has no still to show; say which kind of file
98
+ // it is rather than rendering a broken image.
99
+ <div className="flex aspect-square w-full items-center justify-center text-muted-foreground">
100
+ {isImage ? <ImagePlusIcon className="size-8" /> : <FilmIcon className="size-8" />}
101
+ </div>
102
+ )}
103
+ </button>
104
+ )
105
+ }
@@ -1,10 +1,10 @@
1
+ import { useTranslation } from 'react-i18next'
1
2
  import {
2
3
  ChevronLeftIcon,
3
4
  ChevronRightIcon,
4
5
  ChevronsLeftIcon,
5
6
  ChevronsRightIcon,
6
- } from 'lucide-react'
7
- import { useTranslation } from 'react-i18next'
7
+ } from '../spree/icons'
8
8
  import { Button } from './button'
9
9
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'
10
10
 
@@ -33,27 +33,29 @@ export function Pagination({
33
33
  pageSizeOptions = [10, 20, 25, 30, 50],
34
34
  }: PaginationProps) {
35
35
  const { t } = useTranslation()
36
+ const showPageSize = Boolean(onPageSizeChange) && meta.count > 0
37
+ const showPageNav = meta.pages > 1
36
38
 
37
- if (meta.pages <= 1 && !onPageSizeChange) return null
39
+ if (!showPageSize && !showPageNav) return null
38
40
 
39
41
  return (
40
- <div className="flex items-center justify-between border-t border-border px-4 py-3">
41
- <div className="text-sm text-muted-foreground">
42
+ <div className="flex items-center justify-between ps-2 py-2">
43
+ <div className="whitespace-nowrap text-sm text-muted-foreground">
42
44
  {t('admin.components.pagination.range', {
43
45
  from: meta.from,
44
46
  to: meta.to,
45
47
  count: meta.count,
46
48
  })}
47
49
  </div>
48
- <div className="flex items-center gap-6">
49
- {onPageSizeChange && (
50
- <div className="flex items-center gap-2">
50
+ <div className="flex items-center gap-2 sm:gap-6">
51
+ {showPageSize && (
52
+ <div className="hidden items-center gap-2 sm:flex">
51
53
  <span className="text-sm text-muted-foreground">
52
54
  {t('admin.components.pagination.rows_per_page')}
53
55
  </span>
54
56
  <Select
55
57
  value={`${meta.limit}`}
56
- onValueChange={(value) => onPageSizeChange(Number(value))}
58
+ onValueChange={(value) => onPageSizeChange?.(Number(value))}
57
59
  >
58
60
  <SelectTrigger className="h-8 w-[70px]">
59
61
  <SelectValue />
@@ -68,13 +70,14 @@ export function Pagination({
68
70
  </Select>
69
71
  </div>
70
72
  )}
71
- {meta.pages > 1 && (
73
+ {showPageNav && (
72
74
  <>
73
- <span className="text-sm text-muted-foreground">
75
+ <span className="hidden whitespace-nowrap text-sm text-muted-foreground sm:inline">
74
76
  {t('admin.common.page_of', { page: meta.page, total: meta.pages })}
75
77
  </span>
76
78
  <div className="flex items-center gap-1">
77
79
  <Button
80
+ type="button"
78
81
  size="icon-sm"
79
82
  variant="outline"
80
83
  className="hidden lg:flex"
@@ -85,8 +88,10 @@ export function Pagination({
85
88
  <ChevronsLeftIcon />
86
89
  </Button>
87
90
  <Button
91
+ type="button"
88
92
  size="icon-sm"
89
93
  variant="outline"
94
+ className="size-9 sm:size-8"
90
95
  disabled={!meta.previous}
91
96
  onClick={() => meta.previous && onPageChange(meta.previous)}
92
97
  >
@@ -94,8 +99,10 @@ export function Pagination({
94
99
  <ChevronLeftIcon />
95
100
  </Button>
96
101
  <Button
102
+ type="button"
97
103
  size="icon-sm"
98
104
  variant="outline"
105
+ className="size-9 sm:size-8"
99
106
  disabled={!meta.next}
100
107
  onClick={() => meta.next && onPageChange(meta.next)}
101
108
  >
@@ -103,6 +110,7 @@ export function Pagination({
103
110
  <ChevronRightIcon />
104
111
  </Button>
105
112
  <Button
113
+ type="button"
106
114
  size="icon-sm"
107
115
  className="hidden lg:flex"
108
116
  variant="outline"
@@ -56,8 +56,10 @@ function PopoverContent({
56
56
  <PopoverPrimitive.Popup
57
57
  data-slot="popover-content"
58
58
  className={cn(
59
- 'z-50 w-auto rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-md',
60
- '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]',
59
+ 'z-50 w-auto rounded-xl border border-border bg-popover p-2 text-popover-foreground shadow-lg',
60
+ // `origin-(--transform-origin)` (published by Base UI's Positioner)
61
+ // so the panel grows out of its trigger rather than its own centre.
62
+ 'origin-(--transform-origin) duration-100 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 transition-[opacity,transform,translate,scale]',
61
63
  className,
62
64
  )}
63
65
  {...props}
@@ -30,7 +30,7 @@ function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
30
30
  return (
31
31
  <ProgressPrimitive.Track
32
32
  data-slot="progress-track"
33
- className={cn('relative h-2 w-full overflow-hidden rounded-full bg-primary/20', className)}
33
+ className={cn('relative h-2 w-full overflow-hidden rounded-full bg-blue-500/20', className)}
34
34
  {...props}
35
35
  />
36
36
  )
@@ -41,7 +41,10 @@ function ProgressIndicator({ className, ...props }: ProgressPrimitive.Indicator.
41
41
  <ProgressPrimitive.Indicator
42
42
  data-slot="progress-indicator"
43
43
  className={cn(
44
- 'h-full rounded-full bg-primary transition-all',
44
+ // `width` is a layout property, but it's the right one here: a
45
+ // determinate bar has to grow, and `scaleX` would distort the fill's
46
+ // rounded ends. Narrowed from `transition-all` so only width animates.
47
+ 'h-full rounded-full bg-blue-500 transition-[width] duration-200 ease-out',
45
48
  'data-[indeterminate]:w-1/3 data-[indeterminate]:animate-pulse',
46
49
  className,
47
50
  )}
@@ -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}