@spree/dashboard-ui 0.13.1 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +15 -9
- package/src/hooks/use-prefers-reduced-motion.ts +26 -0
- package/src/hooks/use-scrolled.ts +34 -14
- package/src/index.ts +39 -1
- package/src/lib/date-range-presets.ts +79 -0
- package/src/lib/same-rich-text.ts +145 -0
- package/src/spree/address-block.tsx +5 -4
- package/src/spree/address-book-row.tsx +96 -0
- package/src/spree/address-map.tsx +62 -0
- package/src/spree/back-button.tsx +14 -9
- package/src/spree/bulk-dialog.tsx +2 -2
- package/src/spree/bulk-price-table.tsx +145 -14
- package/src/spree/bulk-variants-table.tsx +434 -0
- package/src/spree/calculator-summary.tsx +5 -1
- package/src/spree/claim-resolve-dialog.tsx +219 -0
- package/src/spree/confirm-dialog.tsx +1 -1
- package/src/spree/copy-to-clipboard-button.tsx +1 -1
- package/src/spree/data-grid/cells.tsx +199 -25
- package/src/spree/data-grid/context.ts +6 -0
- package/src/spree/data-grid/data-grid.tsx +149 -7
- package/src/spree/data-grid/helpers.ts +6 -2
- package/src/spree/data-grid/index.ts +9 -1
- package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
- package/src/spree/delivery-form-dialog.tsx +166 -0
- package/src/spree/drag-handle.tsx +2 -2
- package/src/spree/editable-note-card.tsx +103 -0
- package/src/spree/external-link.tsx +56 -0
- package/src/spree/file-type-icon.tsx +68 -0
- package/src/spree/form-actions.tsx +2 -3
- package/src/spree/form-section.tsx +46 -0
- package/src/spree/fulfill-items-form.tsx +282 -0
- package/src/spree/fulfillment-edit-dialog.tsx +211 -0
- package/src/spree/fulfillment-item-list.tsx +61 -0
- package/src/spree/fulfillment-panel.tsx +53 -0
- package/src/spree/icons.ts +167 -0
- package/src/spree/json-preview-drawer.tsx +4 -4
- package/src/spree/label-upload-dialog.tsx +195 -0
- package/src/spree/language-menu-items.tsx +37 -30
- package/src/spree/metadata/metadata-card.tsx +1 -1
- package/src/spree/order-cancel-dialog.tsx +149 -0
- package/src/spree/post-sale-create-dialogs.tsx +253 -0
- package/src/spree/post-sale-fields.tsx +133 -0
- package/src/spree/product-membership-list.tsx +453 -0
- package/src/spree/products/status-card.tsx +79 -0
- package/src/spree/quantity-tier-editor.tsx +162 -0
- package/src/spree/resource-combobox.tsx +12 -1
- package/src/spree/resource-name-cell.tsx +21 -4
- package/src/spree/return-dialogs.tsx +238 -0
- package/src/spree/route-error-boundary.tsx +1 -1
- package/src/spree/row-actions.tsx +2 -2
- package/src/spree/search-input.tsx +76 -0
- package/src/spree/secret-input.tsx +26 -40
- package/src/spree/tax-identifiers-card.tsx +418 -0
- package/src/spree/theme-toggle.tsx +36 -23
- package/src/spree/wizard.tsx +254 -0
- package/src/styles.css +750 -75
- package/src/ui/accordion.tsx +83 -0
- package/src/ui/alert.tsx +79 -0
- package/src/ui/attachment.tsx +1 -1
- package/src/ui/badge.tsx +198 -56
- package/src/ui/breadcrumb.tsx +1 -1
- package/src/ui/button.tsx +22 -10
- package/src/ui/calendar.tsx +1 -1
- package/src/ui/card.tsx +52 -6
- package/src/ui/checkbox.tsx +13 -5
- package/src/ui/combobox.tsx +125 -6
- package/src/ui/command.tsx +149 -23
- package/src/ui/context-menu.tsx +99 -0
- package/src/ui/data-table.tsx +448 -9
- package/src/ui/date-picker.tsx +18 -3
- package/src/ui/date-range-picker.tsx +47 -60
- package/src/ui/dialog.tsx +6 -6
- package/src/ui/dropdown-menu.tsx +18 -11
- package/src/ui/field.tsx +10 -2
- package/src/ui/input-group.tsx +1 -1
- package/src/ui/input.tsx +1 -1
- package/src/ui/map.tsx +2110 -0
- package/src/ui/media-preview.tsx +105 -0
- package/src/ui/pagination.tsx +19 -11
- package/src/ui/popover.tsx +4 -2
- package/src/ui/progress.tsx +5 -2
- package/src/ui/radio-group.tsx +4 -4
- package/src/ui/rich-text-editor.tsx +214 -88
- package/src/ui/scroll-area.tsx +65 -0
- package/src/ui/select.tsx +17 -10
- package/src/ui/separator.tsx +1 -1
- package/src/ui/sheet.tsx +49 -7
- package/src/ui/sidebar.tsx +138 -37
- package/src/ui/skeleton.tsx +3 -1
- package/src/ui/tabs.tsx +84 -0
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/thumbnail.tsx +80 -0
- package/src/ui/toast.tsx +112 -0
- package/src/ui/tooltip.tsx +3 -1
- 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
|
+
}
|
package/src/ui/pagination.tsx
CHANGED
|
@@ -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 '
|
|
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 (
|
|
39
|
+
if (!showPageSize && !showPageNav) return null
|
|
38
40
|
|
|
39
41
|
return (
|
|
40
|
-
<div className="flex items-center justify-between
|
|
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
|
-
{
|
|
50
|
-
<div className="
|
|
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
|
-
{
|
|
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"
|
package/src/ui/popover.tsx
CHANGED
|
@@ -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-
|
|
60
|
-
|
|
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}
|
package/src/ui/progress.tsx
CHANGED
|
@@ -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-
|
|
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
|
-
'
|
|
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
|
)}
|
package/src/ui/radio-group.tsx
CHANGED
|
@@ -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-
|
|
22
|
-
'focus
|
|
23
|
-
'disabled:cursor-not-allowed disabled:
|
|
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:
|
|
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 {
|
|
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 '
|
|
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
|
|
69
|
-
|
|
70
|
-
StarterKit.
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
206
|
+
<EditorContent editor={editor} className="px-3 py-2" />
|
|
207
|
+
</div>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
172
210
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
title={t('admin.components.rich_text_editor.
|
|
321
|
+
onClick={() => editor.chain().focus().undo().run()}
|
|
322
|
+
disabled={!toolbar.canUndo}
|
|
323
|
+
title={t('admin.components.rich_text_editor.undo')}
|
|
191
324
|
>
|
|
192
|
-
<
|
|
325
|
+
<UndoIcon className="size-4" />
|
|
193
326
|
</ToolbarButton>
|
|
194
|
-
|
|
195
|
-
<ToolbarSeparator />
|
|
196
|
-
|
|
197
327
|
<ToolbarButton
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
title={t('admin.components.rich_text_editor.
|
|
328
|
+
onClick={() => editor.chain().focus().redo().run()}
|
|
329
|
+
disabled={!toolbar.canRedo}
|
|
330
|
+
title={t('admin.components.rich_text_editor.redo')}
|
|
201
331
|
>
|
|
202
|
-
<
|
|
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
|
-
|
|
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}
|