@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
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { cn } from '../lib/utils'
|
|
4
|
+
import { Button } from '../ui/button'
|
|
5
|
+
import {
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogBody,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogFooter,
|
|
10
|
+
DialogHeader,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
} from '../ui/dialog'
|
|
13
|
+
import { useConfirm } from './confirm-dialog'
|
|
14
|
+
import { CheckIcon, XIcon } from './icons'
|
|
15
|
+
|
|
16
|
+
/** One step in a wizard: what it is called, and how far in it sits. */
|
|
17
|
+
export interface WizardStep {
|
|
18
|
+
/** Stable key, used for React identity and by the host to switch panes. */
|
|
19
|
+
key: string
|
|
20
|
+
label: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface WizardProgressProps {
|
|
24
|
+
steps: WizardStep[]
|
|
25
|
+
/** Key of the step being shown. */
|
|
26
|
+
current: string
|
|
27
|
+
/**
|
|
28
|
+
* Jump straight to an earlier step. Omit for a strictly linear wizard —
|
|
29
|
+
* a step ahead is never reachable this way, since it may depend on
|
|
30
|
+
* answers the merchant has not given yet.
|
|
31
|
+
*/
|
|
32
|
+
onStepSelect?: (key: string) => void
|
|
33
|
+
className?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The progress rail above a multi-step flow: which steps there are, which one
|
|
38
|
+
* is open, and which are behind.
|
|
39
|
+
*
|
|
40
|
+
* Headless — it renders no panes and holds no state. The host owns which step
|
|
41
|
+
* is current and what each one contains, so the same rail serves flows whose
|
|
42
|
+
* steps are validated differently.
|
|
43
|
+
*
|
|
44
|
+
* Steps already completed are clickable when `onStepSelect` is given, so
|
|
45
|
+
* going back to correct an earlier answer is one click rather than a walk
|
|
46
|
+
* backwards. Steps ahead never are: a linear flow only knows a step is
|
|
47
|
+
* reachable once the one before it is answered.
|
|
48
|
+
*/
|
|
49
|
+
export function WizardProgress({ steps, current, onStepSelect, className }: WizardProgressProps) {
|
|
50
|
+
const currentIndex = steps.findIndex((step) => step.key === current)
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<ol className={cn('flex flex-col', className)}>
|
|
54
|
+
{steps.map((step, index) => {
|
|
55
|
+
const state = index < currentIndex ? 'done' : index === currentIndex ? 'current' : 'ahead'
|
|
56
|
+
const navigable = state === 'done' && !!onStepSelect
|
|
57
|
+
const last = index === steps.length - 1
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<li key={step.key} className="flex gap-3">
|
|
61
|
+
{/* Marker and connector share a column so the line always meets
|
|
62
|
+
the dots, whatever a label wraps to. */}
|
|
63
|
+
<div className="flex flex-col items-center">
|
|
64
|
+
<span
|
|
65
|
+
aria-hidden
|
|
66
|
+
className={cn(
|
|
67
|
+
'flex size-4 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
|
|
68
|
+
state === 'done' && 'border-success bg-success text-white',
|
|
69
|
+
state === 'current' && 'border-ring bg-background',
|
|
70
|
+
state === 'ahead' && 'border-border bg-background',
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
{state === 'done' ? <CheckIcon className="size-2.5" strokeWidth={3} /> : null}
|
|
74
|
+
</span>
|
|
75
|
+
{last ? null : <span aria-hidden className="w-px flex-auto bg-border" />}
|
|
76
|
+
</div>
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
disabled={!navigable}
|
|
80
|
+
onClick={navigable ? () => onStepSelect(step.key) : undefined}
|
|
81
|
+
// The rail is a summary of a flow the host drives, so a step
|
|
82
|
+
// that cannot be jumped to is not a disabled control the
|
|
83
|
+
// keyboard should stop on.
|
|
84
|
+
tabIndex={navigable ? undefined : -1}
|
|
85
|
+
aria-current={state === 'current' ? 'step' : undefined}
|
|
86
|
+
className={cn(
|
|
87
|
+
'min-w-0 pb-7 text-left text-sm leading-none',
|
|
88
|
+
navigable && 'cursor-pointer hover:text-foreground',
|
|
89
|
+
state === 'current' ? 'font-medium text-foreground' : 'text-muted-foreground',
|
|
90
|
+
last && 'pb-0',
|
|
91
|
+
)}
|
|
92
|
+
>
|
|
93
|
+
<span className="truncate">{step.label}</span>
|
|
94
|
+
</button>
|
|
95
|
+
</li>
|
|
96
|
+
)
|
|
97
|
+
})}
|
|
98
|
+
</ol>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The chrome a multi-step flow sits in: a full-window dialog with a title, a
|
|
104
|
+
* progress rail, a scrolling body and a footer that moves between steps.
|
|
105
|
+
*
|
|
106
|
+
* Deliberately a shell. Which step is open, what each one contains and
|
|
107
|
+
* whether the flow may advance are the host's, because per-step validation
|
|
108
|
+
* differs enough between flows that a shared guard would fit the first caller
|
|
109
|
+
* and fight the second. What is shared is the shape — and the fact that a
|
|
110
|
+
* wizard goes *deeper into* the page behind it rather than navigating away,
|
|
111
|
+
* so that page keeps its state and closing returns to it.
|
|
112
|
+
*
|
|
113
|
+
* The body is a plain `<form>`: a wizard collects input, and the last step's
|
|
114
|
+
* action is a submit, so browser semantics (Enter, validation, submit events)
|
|
115
|
+
* work rather than being reimplemented.
|
|
116
|
+
*/
|
|
117
|
+
export function Wizard({
|
|
118
|
+
open,
|
|
119
|
+
onOpenChange,
|
|
120
|
+
title,
|
|
121
|
+
subtitle,
|
|
122
|
+
status,
|
|
123
|
+
steps,
|
|
124
|
+
current,
|
|
125
|
+
onStepSelect,
|
|
126
|
+
onSubmit,
|
|
127
|
+
onKeyDown,
|
|
128
|
+
contentClassName,
|
|
129
|
+
children,
|
|
130
|
+
back,
|
|
131
|
+
forward,
|
|
132
|
+
hasUnsavedChanges = false,
|
|
133
|
+
}: {
|
|
134
|
+
open: boolean
|
|
135
|
+
onOpenChange: (open: boolean) => void
|
|
136
|
+
title: string
|
|
137
|
+
/** Shown after the title behind a divider — what this run is about. */
|
|
138
|
+
subtitle?: ReactNode
|
|
139
|
+
/** Right-aligned ambient status, e.g. a save state or a badge. */
|
|
140
|
+
status?: ReactNode
|
|
141
|
+
steps: WizardStep[]
|
|
142
|
+
current: string
|
|
143
|
+
onStepSelect?: (key: string) => void
|
|
144
|
+
onSubmit: React.FormEventHandler<HTMLFormElement>
|
|
145
|
+
onKeyDown?: React.KeyboardEventHandler<HTMLFormElement>
|
|
146
|
+
/** Widths differ by step — a two-column step wants room a form does not. */
|
|
147
|
+
contentClassName?: string
|
|
148
|
+
children: ReactNode
|
|
149
|
+
/** Rendered at the footer's leading edge, opposite the forward action. */
|
|
150
|
+
back: ReactNode
|
|
151
|
+
/** Next, or the submit on the last step. */
|
|
152
|
+
forward: ReactNode
|
|
153
|
+
/**
|
|
154
|
+
* Whether closing now would discard the merchant's work. The host decides,
|
|
155
|
+
* since only it knows what its steps have collected.
|
|
156
|
+
*/
|
|
157
|
+
hasUnsavedChanges?: boolean
|
|
158
|
+
}) {
|
|
159
|
+
const { t } = useTranslation()
|
|
160
|
+
const confirm = useConfirm()
|
|
161
|
+
|
|
162
|
+
async function requestClose() {
|
|
163
|
+
if (hasUnsavedChanges) {
|
|
164
|
+
const discard = await confirm({
|
|
165
|
+
title: t('admin.components.wizard.discard_title'),
|
|
166
|
+
message: t('admin.components.wizard.discard_message'),
|
|
167
|
+
confirmLabel: t('admin.components.wizard.discard_confirm'),
|
|
168
|
+
cancelLabel: t('admin.components.wizard.discard_cancel'),
|
|
169
|
+
variant: 'destructive',
|
|
170
|
+
})
|
|
171
|
+
if (!discard) return
|
|
172
|
+
}
|
|
173
|
+
onOpenChange(false)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<Dialog
|
|
178
|
+
open={open}
|
|
179
|
+
onOpenChange={(next, details) => {
|
|
180
|
+
if (next) return onOpenChange(true)
|
|
181
|
+
// A wizard holds several steps of work, so a stray Escape or a click
|
|
182
|
+
// past its edge must not throw that away without asking.
|
|
183
|
+
if (details?.reason === 'escape-key' || details?.reason === 'outside-press') {
|
|
184
|
+
void requestClose()
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
onOpenChange(false)
|
|
188
|
+
}}
|
|
189
|
+
modal
|
|
190
|
+
>
|
|
191
|
+
<DialogContent
|
|
192
|
+
// Edge-to-edge minus a gutter, like the import wizard and the bulk
|
|
193
|
+
// price editor — every inset/translate/max is overridden.
|
|
194
|
+
className="!inset-3 !w-auto !max-w-none !translate-x-0 !translate-y-0 flex flex-col p-0"
|
|
195
|
+
style={{ maxHeight: 'none' }}
|
|
196
|
+
showCloseButton={false}
|
|
197
|
+
>
|
|
198
|
+
<DialogHeader className="h-14 shrink-0 flex-row items-center gap-3 space-y-0 border-b border-border-subtle bg-background px-4 py-0">
|
|
199
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
200
|
+
<DialogTitle className="truncate text-base">{title}</DialogTitle>
|
|
201
|
+
{subtitle ? (
|
|
202
|
+
<>
|
|
203
|
+
<span aria-hidden className="h-4 w-px shrink-0 bg-border" />
|
|
204
|
+
<span className="truncate text-muted-foreground text-sm">{subtitle}</span>
|
|
205
|
+
</>
|
|
206
|
+
) : null}
|
|
207
|
+
</div>
|
|
208
|
+
<div className="ms-auto flex shrink-0 items-center gap-3">
|
|
209
|
+
{status}
|
|
210
|
+
<Button
|
|
211
|
+
type="button"
|
|
212
|
+
size="icon-sm"
|
|
213
|
+
variant="ghost"
|
|
214
|
+
onClick={() => void requestClose()}
|
|
215
|
+
aria-label={t('admin.actions.close')}
|
|
216
|
+
>
|
|
217
|
+
<XIcon />
|
|
218
|
+
</Button>
|
|
219
|
+
</div>
|
|
220
|
+
</DialogHeader>
|
|
221
|
+
|
|
222
|
+
<form onSubmit={onSubmit} onKeyDown={onKeyDown} className="flex min-h-0 flex-1 flex-col">
|
|
223
|
+
{/* The body itself does not scroll: the rail stays put while the
|
|
224
|
+
step's own column scrolls, so a long form never carries the
|
|
225
|
+
progress list off-screen. */}
|
|
226
|
+
<DialogBody className="flex min-h-0 flex-1 gap-8 overflow-hidden p-0">
|
|
227
|
+
<WizardProgress
|
|
228
|
+
steps={steps}
|
|
229
|
+
current={current}
|
|
230
|
+
onStepSelect={onStepSelect}
|
|
231
|
+
className="hidden w-56 shrink-0 py-8 ps-8 md:flex"
|
|
232
|
+
/>
|
|
233
|
+
<div className="min-w-0 flex-1 overflow-y-auto py-8 pe-8 ps-8 md:ps-0">
|
|
234
|
+
{/* No width cap here: `contentClassName` is what sets it, and a
|
|
235
|
+
default with a breakpoint would outrank the caller's own. */}
|
|
236
|
+
<div className={cn('flex w-full min-w-0 flex-col gap-6', contentClassName)}>
|
|
237
|
+
{children}
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
</DialogBody>
|
|
241
|
+
|
|
242
|
+
{/* Back sits opposite the forward action rather than beside it, so
|
|
243
|
+
the footer's end-justification is overridden. It stays a row at
|
|
244
|
+
every width too: stacked, Back would sit above Next and read as
|
|
245
|
+
the order to press them in. */}
|
|
246
|
+
<DialogFooter className="h-16 shrink-0 flex-row items-center justify-between border-border-subtle bg-background px-4 py-0 sm:justify-between">
|
|
247
|
+
{back}
|
|
248
|
+
{forward}
|
|
249
|
+
</DialogFooter>
|
|
250
|
+
</form>
|
|
251
|
+
</DialogContent>
|
|
252
|
+
</Dialog>
|
|
253
|
+
)
|
|
254
|
+
}
|