@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.
- package/README.md +1 -1
- package/package.json +13 -9
- package/src/hooks/use-prefers-reduced-motion.ts +26 -0
- package/src/hooks/use-scrolled.ts +34 -14
- package/src/index.ts +38 -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/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/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
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}
|
|
@@ -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-
|
|
58
|
-
// the
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
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-
|
|
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-
|
|
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}
|
package/src/ui/separator.tsx
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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-
|
|
222
|
+
className={cn('text-lg font-medium', className)}
|
|
181
223
|
{...props}
|
|
182
224
|
/>
|
|
183
225
|
)
|