@spree/dashboard-ui 1.0.0-beta.8 → 1.0.0-rc.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/package.json +10 -1
- package/src/hooks/use-debounced-value.ts +13 -0
- package/src/index.ts +3 -0
- package/src/spree/calculator-summary.tsx +5 -0
- package/src/spree/data-grid/fill-handle.tsx +0 -1
- package/src/spree/icons.ts +2 -0
- package/src/spree/post-sale-create-dialogs.tsx +28 -15
- package/src/spree/return-dialogs.tsx +114 -44
- package/src/spree/route-error-boundary.tsx +9 -3
- package/src/styles.css +4 -10
- package/src/ui/button.tsx +1 -1
- package/src/ui/code-editor.tsx +248 -0
- package/src/ui/combobox.tsx +27 -1
- package/src/ui/sidebar.tsx +2 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spree/dashboard-ui",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "1.0.0-
|
|
4
|
+
"version": "1.0.0-rc.1",
|
|
5
5
|
"description": "Spree Dashboard design system — shadcn primitives, headless composed components, and design tokens. Source-only package; consumers compile via their own Vite/Tailwind setup. Developer Preview.",
|
|
6
6
|
"author": "Vendo Connect Inc.",
|
|
7
7
|
"homepage": "https://spreecommerce.org",
|
|
@@ -48,6 +48,15 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@base-ui/react": "1.8.0",
|
|
51
|
+
"@codemirror/autocomplete": "6.20.3",
|
|
52
|
+
"@codemirror/commands": "6.11.1",
|
|
53
|
+
"@codemirror/lang-html": "6.4.12",
|
|
54
|
+
"@codemirror/lang-liquid": "6.3.3",
|
|
55
|
+
"@codemirror/language": "6.12.4",
|
|
56
|
+
"@codemirror/lint": "6.9.7",
|
|
57
|
+
"@codemirror/search": "6.7.2",
|
|
58
|
+
"@codemirror/state": "6.7.6",
|
|
59
|
+
"@codemirror/view": "6.43.13",
|
|
51
60
|
"@dnd-kit/core": "6.3.1",
|
|
52
61
|
"@dnd-kit/sortable": "10.0.0",
|
|
53
62
|
"@dnd-kit/utilities": "3.2.2",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/** The value, once it has stopped changing for `delayMs`. */
|
|
4
|
+
export function useDebouncedValue<T>(value: T, delayMs: number): T {
|
|
5
|
+
const [debounced, setDebounced] = useState(value)
|
|
6
|
+
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const id = setTimeout(() => setDebounced(value), delayMs)
|
|
9
|
+
return () => clearTimeout(id)
|
|
10
|
+
}, [value, delayMs])
|
|
11
|
+
|
|
12
|
+
return debounced
|
|
13
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// CSS lives at `@spree/dashboard-ui/styles.css` — import once from your Vite app.
|
|
13
13
|
|
|
14
14
|
export * from './hooks/use-copy-to-clipboard'
|
|
15
|
+
export * from './hooks/use-debounced-value'
|
|
15
16
|
export { useIsMobile } from './hooks/use-mobile'
|
|
16
17
|
export * from './hooks/use-prefers-reduced-motion'
|
|
17
18
|
export * from './hooks/use-scrolled'
|
|
@@ -65,6 +66,8 @@ export * from './spree/return-dialogs'
|
|
|
65
66
|
// `@spree/dashboard-ui/spree/json-preview-drawer` and
|
|
66
67
|
// `@spree/dashboard-ui/spree/json-value-view`. Types are available the same
|
|
67
68
|
// way — `import { type JsonPreviewDrawerProps } from '@spree/dashboard-ui/spree/json-preview-drawer'`.
|
|
69
|
+
// CodeEditor (CodeMirror) is deep-imported for the same reason:
|
|
70
|
+
// `@spree/dashboard-ui/ui/code-editor`.
|
|
68
71
|
|
|
69
72
|
export * from './spree/external-link'
|
|
70
73
|
export * from './spree/language-menu-items'
|
|
@@ -85,6 +85,7 @@ export function formatCalculatorSummary(
|
|
|
85
85
|
* - `*_percent` decimals → `N%`.
|
|
86
86
|
* - `currency` strings → omitted (folded into the money formatting).
|
|
87
87
|
* - booleans → humanized key when true; skipped when false.
|
|
88
|
+
* - lists (tiers) → their length; other objects → omitted.
|
|
88
89
|
* - everything else → `key: value`.
|
|
89
90
|
*/
|
|
90
91
|
function formatField(
|
|
@@ -105,6 +106,10 @@ function formatField(
|
|
|
105
106
|
if (field.type === 'boolean') {
|
|
106
107
|
return value ? humanize(field.key) : null
|
|
107
108
|
}
|
|
109
|
+
if (field.type === 'array') {
|
|
110
|
+
return Array.isArray(value) && value.length ? `${humanize(field.key)}: ${value.length}` : null
|
|
111
|
+
}
|
|
112
|
+
if (field.type === 'hash') return null
|
|
108
113
|
return `${humanize(field.key)}: ${value}`
|
|
109
114
|
}
|
|
110
115
|
|
|
@@ -130,7 +130,6 @@ export function FillHandle({ gridRef }: { gridRef: React.RefObject<HTMLElement |
|
|
|
130
130
|
|
|
131
131
|
return (
|
|
132
132
|
<div
|
|
133
|
-
// biome-ignore lint/a11y/useSemanticElements: this is a drag handle, not actionable on its own
|
|
134
133
|
role="presentation"
|
|
135
134
|
onPointerDown={onPointerDown}
|
|
136
135
|
onPointerMove={onPointerMove}
|
package/src/spree/icons.ts
CHANGED
|
@@ -69,6 +69,7 @@ export {
|
|
|
69
69
|
GlobeLockIcon,
|
|
70
70
|
GripVerticalIcon,
|
|
71
71
|
HandCoinsIcon,
|
|
72
|
+
HistoryIcon,
|
|
72
73
|
HomeIcon,
|
|
73
74
|
ImageIcon,
|
|
74
75
|
ImagePlusIcon,
|
|
@@ -134,6 +135,7 @@ export {
|
|
|
134
135
|
ShieldIcon,
|
|
135
136
|
ShoppingCartIcon,
|
|
136
137
|
SlidersHorizontalIcon,
|
|
138
|
+
SmartphoneIcon,
|
|
137
139
|
SparklesIcon,
|
|
138
140
|
SplitIcon,
|
|
139
141
|
SquareTextIcon,
|
|
@@ -98,10 +98,20 @@ export function CreateReturnDialog({
|
|
|
98
98
|
)
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
/** A line the claim can name, with what
|
|
101
|
+
/** A line the claim can name, with what the customer paid for it. */
|
|
102
102
|
export type ClaimableLine = PostSaleUnit & {
|
|
103
|
-
/**
|
|
104
|
-
|
|
103
|
+
/** The whole line after discounts, used to default the refund to what was actually paid. */
|
|
104
|
+
discountedAmount?: string | null
|
|
105
|
+
/** Tax charged on top of the line's price, which a claim refunds with it. */
|
|
106
|
+
additionalTaxTotal?: string | null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** What the customer paid for `quantity` units of the line, or blank when that is unknown. */
|
|
110
|
+
function paidForUnits(line: ClaimableLine, quantity: number): string {
|
|
111
|
+
const paid = Number(line.discountedAmount) + Number(line.additionalTaxTotal ?? 0)
|
|
112
|
+
if (quantity <= 0 || line.discountedAmount == null || !Number.isFinite(paid)) return ''
|
|
113
|
+
|
|
114
|
+
return ((paid * quantity) / line.quantity).toFixed(2)
|
|
105
115
|
}
|
|
106
116
|
|
|
107
117
|
/**
|
|
@@ -138,6 +148,7 @@ export function CreateClaimDialog({
|
|
|
138
148
|
const { t } = useTranslation()
|
|
139
149
|
const [selection, setSelection] = useState<PostSaleSelection>({})
|
|
140
150
|
const [amounts, setAmounts] = useState<Record<string, string>>({})
|
|
151
|
+
const [typedAmounts, setTypedAmounts] = useState<Record<string, boolean>>({})
|
|
141
152
|
const [memo, setMemo] = useState('')
|
|
142
153
|
|
|
143
154
|
const chosen = selectedUnits(selection)
|
|
@@ -173,16 +184,13 @@ export function CreateClaimDialog({
|
|
|
173
184
|
Math.min(Number(event.target.value), line.quantity),
|
|
174
185
|
)
|
|
175
186
|
setSelection({ ...selection, [line.id]: quantity })
|
|
176
|
-
//
|
|
177
|
-
// the merchant
|
|
178
|
-
if (
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
[line.id]: (unitPrice * quantity).toFixed(2),
|
|
184
|
-
}))
|
|
185
|
-
}
|
|
187
|
+
// The refund follows what was paid for the chosen units
|
|
188
|
+
// until the merchant types an amount of their own.
|
|
189
|
+
if (!typedAmounts[line.id]) {
|
|
190
|
+
setAmounts((current) => ({
|
|
191
|
+
...current,
|
|
192
|
+
[line.id]: paidForUnits(line, quantity),
|
|
193
|
+
}))
|
|
186
194
|
}
|
|
187
195
|
}}
|
|
188
196
|
/>
|
|
@@ -202,9 +210,14 @@ export function CreateClaimDialog({
|
|
|
202
210
|
step="0.01"
|
|
203
211
|
min="0"
|
|
204
212
|
value={amounts[line.id] ?? ''}
|
|
205
|
-
onChange={(event) =>
|
|
213
|
+
onChange={(event) => {
|
|
206
214
|
setAmounts({ ...amounts, [line.id]: event.target.value })
|
|
207
|
-
|
|
215
|
+
// Clearing the field hands it back to the quantity.
|
|
216
|
+
setTypedAmounts({
|
|
217
|
+
...typedAmounts,
|
|
218
|
+
[line.id]: event.target.value !== '',
|
|
219
|
+
})
|
|
220
|
+
}}
|
|
208
221
|
/>
|
|
209
222
|
</InputGroup>
|
|
210
223
|
</Field>
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
DialogHeader,
|
|
10
10
|
DialogTitle,
|
|
11
11
|
} from '../ui/dialog'
|
|
12
|
-
import { Field, FieldLabel } from '../ui/field'
|
|
12
|
+
import { Field, FieldDescription, FieldLabel } from '../ui/field'
|
|
13
13
|
import { Input } from '../ui/input'
|
|
14
14
|
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '../ui/input-group'
|
|
15
15
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
|
@@ -139,20 +139,68 @@ export function ReturnReceiveDialog({
|
|
|
139
139
|
)
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/** True when a received return is owed nothing, so completing it moves no money. */
|
|
143
|
+
export function returnOwesNothing(refundableTotal: string): boolean {
|
|
144
|
+
return Number(refundableTotal) === 0
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type ReturnRefundFigures = {
|
|
148
|
+
status: string
|
|
149
|
+
refund_total: string
|
|
150
|
+
display_refund_total: string
|
|
151
|
+
refunded_total: string
|
|
152
|
+
display_refunded_total: string
|
|
153
|
+
}
|
|
154
|
+
|
|
142
155
|
/**
|
|
143
|
-
*
|
|
156
|
+
* What a return's card reports: what it is owed until money goes back, then
|
|
157
|
+
* what actually went back — which a merchant keeping a restocking fee makes
|
|
158
|
+
* less than it was owed, so that case also names the full amount. Money that
|
|
159
|
+
* went back counts even before the return is marked refunded: a refund that
|
|
160
|
+
* succeeded on one payment and was declined on the next leaves it received.
|
|
161
|
+
*/
|
|
162
|
+
export function returnRefundSummary(
|
|
163
|
+
returnRecord: ReturnRefundFigures,
|
|
164
|
+
):
|
|
165
|
+
| { kind: 'owed'; amount: string }
|
|
166
|
+
| { kind: 'refunded'; amount: string }
|
|
167
|
+
| { kind: 'refunded_short'; amount: string; total: string } {
|
|
168
|
+
const refunded = Number(returnRecord.refunded_total)
|
|
169
|
+
|
|
170
|
+
if (returnRecord.status !== 'refunded' && refunded === 0) {
|
|
171
|
+
return { kind: 'owed', amount: returnRecord.display_refund_total }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (refunded < Number(returnRecord.refund_total)) {
|
|
175
|
+
return {
|
|
176
|
+
kind: 'refunded_short',
|
|
177
|
+
amount: returnRecord.display_refunded_total,
|
|
178
|
+
total: returnRecord.display_refund_total,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { kind: 'refunded', amount: returnRecord.display_refunded_total }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Gives the money back: how much, and by what means. A return owed nothing
|
|
187
|
+
* (a free gift sent back) is completed instead, with nothing to choose.
|
|
144
188
|
*
|
|
145
189
|
* The currency symbol comes from the caller — the operator's panel reads it
|
|
146
190
|
* from the store it is looking at, and a seller has no currency of their own.
|
|
191
|
+
* `refundTaxTotal` is the formatted tax inside `refundableTotal`, shown so a
|
|
192
|
+
* merchant knows the pre-filled amount gives the tax back too.
|
|
147
193
|
*/
|
|
148
194
|
export function ReturnRefundDialog({
|
|
149
195
|
refundableTotal,
|
|
196
|
+
refundTaxTotal,
|
|
150
197
|
currencySymbol,
|
|
151
198
|
onClose,
|
|
152
199
|
onSubmit,
|
|
153
200
|
pending = false,
|
|
154
201
|
}: {
|
|
155
202
|
refundableTotal: string
|
|
203
|
+
refundTaxTotal?: string
|
|
156
204
|
currencySymbol: string
|
|
157
205
|
onClose: () => void
|
|
158
206
|
onSubmit: (params: { refundMethod: RefundMethod; amount?: string }) => void
|
|
@@ -161,6 +209,7 @@ export function ReturnRefundDialog({
|
|
|
161
209
|
const { t } = useTranslation()
|
|
162
210
|
const [refundMethod, setRefundMethod] = useState<RefundMethod>('original_payment')
|
|
163
211
|
const [amount, setAmount] = useState(refundableTotal)
|
|
212
|
+
const owesNothing = returnOwesNothing(refundableTotal)
|
|
164
213
|
|
|
165
214
|
const methodOptions = [
|
|
166
215
|
{
|
|
@@ -177,48 +226,67 @@ export function ReturnRefundDialog({
|
|
|
177
226
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
|
178
227
|
<DialogContent>
|
|
179
228
|
<DialogHeader>
|
|
180
|
-
<DialogTitle>
|
|
229
|
+
<DialogTitle>
|
|
230
|
+
{owesNothing
|
|
231
|
+
? t('admin.pages.orders.detail.returns.complete_title')
|
|
232
|
+
: t('admin.pages.orders.detail.returns.refund_title')}
|
|
233
|
+
</DialogTitle>
|
|
181
234
|
</DialogHeader>
|
|
182
235
|
<DialogBody className="flex flex-col gap-4">
|
|
183
|
-
|
|
184
|
-
<
|
|
185
|
-
{t('admin.pages.orders.detail.returns.
|
|
186
|
-
</
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
236
|
+
{owesNothing ? (
|
|
237
|
+
<p className="text-muted-foreground text-sm">
|
|
238
|
+
{t('admin.pages.orders.detail.returns.nothing_owed')}
|
|
239
|
+
</p>
|
|
240
|
+
) : (
|
|
241
|
+
<>
|
|
242
|
+
<Field>
|
|
243
|
+
<FieldLabel htmlFor="refund-amount">
|
|
244
|
+
{t('admin.pages.orders.detail.returns.refund_amount')}
|
|
245
|
+
</FieldLabel>
|
|
246
|
+
<InputGroup>
|
|
247
|
+
<InputGroupAddon>
|
|
248
|
+
<InputGroupText>{currencySymbol}</InputGroupText>
|
|
249
|
+
</InputGroupAddon>
|
|
250
|
+
<InputGroupInput
|
|
251
|
+
id="refund-amount"
|
|
252
|
+
type="number"
|
|
253
|
+
step="0.01"
|
|
254
|
+
min="0"
|
|
255
|
+
value={amount}
|
|
256
|
+
onChange={(event) => setAmount(event.target.value)}
|
|
257
|
+
/>
|
|
258
|
+
</InputGroup>
|
|
259
|
+
{refundTaxTotal && (
|
|
260
|
+
<FieldDescription>
|
|
261
|
+
{t('admin.pages.orders.detail.returns.refund_includes_tax', {
|
|
262
|
+
tax: refundTaxTotal,
|
|
263
|
+
})}
|
|
264
|
+
</FieldDescription>
|
|
265
|
+
)}
|
|
266
|
+
</Field>
|
|
267
|
+
<Field>
|
|
268
|
+
<FieldLabel htmlFor="refund-method">
|
|
269
|
+
{t('admin.pages.orders.detail.returns.refund_method')}
|
|
270
|
+
</FieldLabel>
|
|
271
|
+
<Select
|
|
272
|
+
items={methodOptions}
|
|
273
|
+
value={refundMethod}
|
|
274
|
+
onValueChange={(value) => setRefundMethod(value as RefundMethod)}
|
|
275
|
+
>
|
|
276
|
+
<SelectTrigger id="refund-method">
|
|
277
|
+
<SelectValue />
|
|
278
|
+
</SelectTrigger>
|
|
279
|
+
<SelectContent>
|
|
280
|
+
{methodOptions.map((option) => (
|
|
281
|
+
<SelectItem key={option.value} value={option.value}>
|
|
282
|
+
{option.label}
|
|
283
|
+
</SelectItem>
|
|
284
|
+
))}
|
|
285
|
+
</SelectContent>
|
|
286
|
+
</Select>
|
|
287
|
+
</Field>
|
|
288
|
+
</>
|
|
289
|
+
)}
|
|
222
290
|
</DialogBody>
|
|
223
291
|
<DialogFooter>
|
|
224
292
|
<Button type="button" variant="outline" onClick={onClose} disabled={pending}>
|
|
@@ -227,9 +295,11 @@ export function ReturnRefundDialog({
|
|
|
227
295
|
<Button
|
|
228
296
|
type="button"
|
|
229
297
|
disabled={pending}
|
|
230
|
-
onClick={() => onSubmit({ refundMethod, amount })}
|
|
298
|
+
onClick={() => onSubmit({ refundMethod, amount: owesNothing ? undefined : amount })}
|
|
231
299
|
>
|
|
232
|
-
{
|
|
300
|
+
{owesNothing
|
|
301
|
+
? t('admin.pages.orders.detail.returns.actions.complete')
|
|
302
|
+
: t('admin.pages.orders.detail.returns.actions.refund')}
|
|
233
303
|
</Button>
|
|
234
304
|
</DialogFooter>
|
|
235
305
|
</DialogContent>
|
|
@@ -16,8 +16,11 @@ interface ErrorStateProps {
|
|
|
16
16
|
title?: ReactNode
|
|
17
17
|
/** Smaller text below the title. Pass an Error and we'll show its message. */
|
|
18
18
|
description?: ReactNode
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Whatever was thrown — routers hand it over as `unknown`. When it is an
|
|
21
|
+
* Error, its message is shown if `description` is omitted.
|
|
22
|
+
*/
|
|
23
|
+
error?: unknown
|
|
21
24
|
/** Called by the retry button. When omitted, the button is hidden. */
|
|
22
25
|
onRetry?: () => void
|
|
23
26
|
/** Override the retry label. Default "Try again". */
|
|
@@ -33,7 +36,10 @@ export function ErrorState({ title, description, error, onRetry, retryLabel }: E
|
|
|
33
36
|
const { t } = useTranslation()
|
|
34
37
|
const resolvedTitle = title ?? t('admin.errors.generic')
|
|
35
38
|
const resolvedRetryLabel = retryLabel ?? t('admin.components.error_state.retry')
|
|
36
|
-
const message =
|
|
39
|
+
const message =
|
|
40
|
+
description ??
|
|
41
|
+
(error instanceof Error ? error.message : undefined) ??
|
|
42
|
+
t('admin.errors.unexpected_retry')
|
|
37
43
|
|
|
38
44
|
return (
|
|
39
45
|
<Empty>
|
package/src/styles.css
CHANGED
|
@@ -153,7 +153,7 @@
|
|
|
153
153
|
sheet rather than a surface floating above it, so its edge only has to
|
|
154
154
|
suggest where the region ends. Used by `CardContent`'s frame; a dialog or
|
|
155
155
|
the sheet itself must keep --border, or the shell loses its outline. */
|
|
156
|
-
--border-card: color-mix(in srgb, var(--border-base)
|
|
156
|
+
--border-card: color-mix(in srgb, var(--border-base) 70%, transparent);
|
|
157
157
|
/* Dividers *inside* a container — card header/footer splits, dialog and
|
|
158
158
|
sheet separators, table row rules.
|
|
159
159
|
|
|
@@ -224,11 +224,9 @@
|
|
|
224
224
|
--sidebar-foreground: oklch(26.8% 0.007 34.298);
|
|
225
225
|
--sidebar-primary: var(--primary);
|
|
226
226
|
--sidebar-primary-foreground: var(--primary-foreground);
|
|
227
|
-
--sidebar-accent:
|
|
228
|
-
--sidebar-accent-foreground: var(--accent-foreground)
|
|
229
|
-
--sidebar-accent-strong: var(--accent-strong);
|
|
227
|
+
--sidebar-accent: oklch(92.3% 0.003 48.717);
|
|
228
|
+
--sidebar-accent-foreground: var(--accent-foreground);;
|
|
230
229
|
--sidebar-accent-hover: var(--accent-strong);
|
|
231
|
-
--sidebar-accent-strong-hover: var(--accent-strong-hover);
|
|
232
230
|
/* Matches the rule the top bar draws, so every edge framing the shell —
|
|
233
231
|
the sidebar's, the top bar's, the settings header's — is one weight. */
|
|
234
232
|
--sidebar-border: color-mix(in srgb, var(--border) 75%, transparent);
|
|
@@ -327,7 +325,7 @@
|
|
|
327
325
|
hairline weight. */
|
|
328
326
|
--border-base: oklch(1 0 0 / 16%);
|
|
329
327
|
--border: var(--border-base);
|
|
330
|
-
--border-card: color-mix(in srgb, var(--border-base)
|
|
328
|
+
--border-card: color-mix(in srgb, var(--border-base) 70%, transparent);
|
|
331
329
|
--border-subtle: color-mix(in srgb, var(--border-base) 50%, transparent);
|
|
332
330
|
/* Light mode darkens the control edge away from the card; dark mode lightens
|
|
333
331
|
it. Not the light value inverted — measured on this theme's own surfaces:
|
|
@@ -376,9 +374,7 @@
|
|
|
376
374
|
--sidebar-primary-foreground: var(--primary-foreground);
|
|
377
375
|
--sidebar-accent: var(--accent);
|
|
378
376
|
--sidebar-accent-foreground: var(--accent-foreground);
|
|
379
|
-
--sidebar-accent-strong: var(--accent-strong);
|
|
380
377
|
--sidebar-accent-hover: var(--accent-hover);
|
|
381
|
-
--sidebar-accent-strong-hover: var(--accent-strong-hover);
|
|
382
378
|
/* Matches the rule the top bar draws, so every edge framing the shell —
|
|
383
379
|
the sidebar's, the top bar's, the settings header's — is one weight. */
|
|
384
380
|
--sidebar-border: color-mix(in srgb, var(--border) 75%, transparent);
|
|
@@ -486,9 +482,7 @@
|
|
|
486
482
|
--color-sidebar-ring: var(--sidebar-ring);
|
|
487
483
|
--color-sidebar-border: var(--sidebar-border);
|
|
488
484
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
489
|
-
--color-sidebar-accent-strong: var(--sidebar-accent-strong);
|
|
490
485
|
--color-sidebar-accent-hover: var(--sidebar-accent-hover);
|
|
491
|
-
--color-sidebar-accent-strong-hover: var(--sidebar-accent-strong-hover);
|
|
492
486
|
--color-sidebar-accent: var(--sidebar-accent);
|
|
493
487
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
494
488
|
--color-sidebar-primary: var(--sidebar-primary);
|
package/src/ui/button.tsx
CHANGED
|
@@ -13,7 +13,7 @@ const buttonVariants = cva(
|
|
|
13
13
|
// and it was the only variant with no lift at all.
|
|
14
14
|
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/85',
|
|
15
15
|
outline:
|
|
16
|
-
'border-border bg-card shadow-xs text-foreground hover:bg-accent hover:text-foreground aria-expanded:bg-accent',
|
|
16
|
+
'border-border/80 bg-card shadow-xs text-foreground hover:bg-accent hover:text-foreground aria-expanded:bg-accent',
|
|
17
17
|
// `group-hover/row:` — inside a hovered table row the row already wears
|
|
18
18
|
// --accent, so repeating it here leaves the button at 1.03:1 against its
|
|
19
19
|
// own background: invisible exactly when the pointer is on it. On a
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import {
|
|
2
|
+
autocompletion,
|
|
3
|
+
closeBrackets,
|
|
4
|
+
closeBracketsKeymap,
|
|
5
|
+
completionKeymap,
|
|
6
|
+
} from '@codemirror/autocomplete'
|
|
7
|
+
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
|
|
8
|
+
import { html } from '@codemirror/lang-html'
|
|
9
|
+
import { closePercentBrace, liquid } from '@codemirror/lang-liquid'
|
|
10
|
+
import {
|
|
11
|
+
bracketMatching,
|
|
12
|
+
defaultHighlightStyle,
|
|
13
|
+
foldGutter,
|
|
14
|
+
indentOnInput,
|
|
15
|
+
syntaxHighlighting,
|
|
16
|
+
} from '@codemirror/language'
|
|
17
|
+
import { type Diagnostic, lintGutter, setDiagnostics } from '@codemirror/lint'
|
|
18
|
+
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search'
|
|
19
|
+
import { Compartment, EditorState } from '@codemirror/state'
|
|
20
|
+
import {
|
|
21
|
+
drawSelection,
|
|
22
|
+
EditorView,
|
|
23
|
+
highlightActiveLine,
|
|
24
|
+
highlightActiveLineGutter,
|
|
25
|
+
keymap,
|
|
26
|
+
lineNumbers,
|
|
27
|
+
} from '@codemirror/view'
|
|
28
|
+
import { type Ref, useEffect, useImperativeHandle, useRef } from 'react'
|
|
29
|
+
import { cn } from '../lib/utils'
|
|
30
|
+
|
|
31
|
+
export interface CodeEditorCompletion {
|
|
32
|
+
/** A variable, or a dotted path to one of its fields (`order.number`). */
|
|
33
|
+
label: string
|
|
34
|
+
/** Short type or value shown next to the label. */
|
|
35
|
+
detail?: string
|
|
36
|
+
/** Longer description shown beside the list. */
|
|
37
|
+
info?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CodeEditorDiagnostic {
|
|
41
|
+
/** 1-based line the problem is on; problems without one mark the first line. */
|
|
42
|
+
line?: number | null
|
|
43
|
+
message: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface CodeEditorHandle {
|
|
47
|
+
/** Replaces the selection with `text`, leaves the cursor after it and focuses the editor. */
|
|
48
|
+
insert: (text: string) => void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CodeEditorProps {
|
|
52
|
+
ref?: Ref<CodeEditorHandle>
|
|
53
|
+
value: string
|
|
54
|
+
onChange?: (value: string) => void
|
|
55
|
+
/** Variables offered after `{{` and their fields after a dot. */
|
|
56
|
+
completions?: CodeEditorCompletion[]
|
|
57
|
+
/** Filters offered after `|`. */
|
|
58
|
+
filters?: CodeEditorCompletion[]
|
|
59
|
+
diagnostics?: CodeEditorDiagnostic[]
|
|
60
|
+
readOnly?: boolean
|
|
61
|
+
/** Called on Cmd/Ctrl+S. */
|
|
62
|
+
onSave?: () => void
|
|
63
|
+
'aria-label'?: string
|
|
64
|
+
className?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const theme = EditorView.theme({
|
|
68
|
+
'&': {
|
|
69
|
+
backgroundColor: 'var(--card)',
|
|
70
|
+
color: 'var(--foreground)',
|
|
71
|
+
fontSize: '13px',
|
|
72
|
+
height: '100%',
|
|
73
|
+
},
|
|
74
|
+
'.cm-scroller': { fontFamily: 'var(--font-mono, ui-monospace, monospace)', lineHeight: '1.6' },
|
|
75
|
+
'.cm-content': { caretColor: 'var(--foreground)' },
|
|
76
|
+
'.cm-gutters': {
|
|
77
|
+
backgroundColor: 'var(--background)',
|
|
78
|
+
color: 'var(--muted-foreground)',
|
|
79
|
+
borderRight: '1px solid var(--border)',
|
|
80
|
+
},
|
|
81
|
+
'.cm-activeLine, .cm-activeLineGutter': {
|
|
82
|
+
backgroundColor: 'color-mix(in srgb, var(--accent) 60%, transparent)',
|
|
83
|
+
},
|
|
84
|
+
'&.cm-focused': { outline: 'none' },
|
|
85
|
+
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
|
|
86
|
+
backgroundColor: 'color-mix(in srgb, var(--ring) 20%, transparent)',
|
|
87
|
+
},
|
|
88
|
+
'.cm-tooltip': {
|
|
89
|
+
backgroundColor: 'var(--popover)',
|
|
90
|
+
color: 'var(--popover-foreground)',
|
|
91
|
+
border: '1px solid var(--border)',
|
|
92
|
+
borderRadius: '8px',
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const NO_COMPLETIONS: CodeEditorCompletion[] = []
|
|
97
|
+
const NO_DIAGNOSTICS: CodeEditorDiagnostic[] = []
|
|
98
|
+
|
|
99
|
+
function toDiagnostics(state: EditorState, problems: CodeEditorDiagnostic[]): Diagnostic[] {
|
|
100
|
+
return problems.map((problem) => {
|
|
101
|
+
const lineNumber = Math.min(Math.max(problem.line ?? 1, 1), state.doc.lines)
|
|
102
|
+
const line = state.doc.line(lineNumber)
|
|
103
|
+
return { from: line.from, to: line.to, severity: 'error', message: problem.message }
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A code editor for Liquid templates written in HTML or MJML, with variable
|
|
109
|
+
* and filter completion, inline problems and the usual editing keys.
|
|
110
|
+
*/
|
|
111
|
+
function CodeEditor({
|
|
112
|
+
value,
|
|
113
|
+
onChange,
|
|
114
|
+
completions = NO_COMPLETIONS,
|
|
115
|
+
filters = NO_COMPLETIONS,
|
|
116
|
+
diagnostics = NO_DIAGNOSTICS,
|
|
117
|
+
readOnly = false,
|
|
118
|
+
onSave,
|
|
119
|
+
className,
|
|
120
|
+
ref,
|
|
121
|
+
...props
|
|
122
|
+
}: CodeEditorProps) {
|
|
123
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
124
|
+
const viewRef = useRef<EditorView | null>(null)
|
|
125
|
+
|
|
126
|
+
useImperativeHandle(
|
|
127
|
+
ref,
|
|
128
|
+
() => ({
|
|
129
|
+
insert: (text) => {
|
|
130
|
+
const view = viewRef.current
|
|
131
|
+
if (!view || view.state.readOnly) return
|
|
132
|
+
view.dispatch(view.state.replaceSelection(text), { scrollIntoView: true })
|
|
133
|
+
view.focus()
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
[],
|
|
137
|
+
)
|
|
138
|
+
const languageCompartment = useRef(new Compartment())
|
|
139
|
+
const readOnlyCompartment = useRef(new Compartment())
|
|
140
|
+
const callbacks = useRef({ onChange, onSave })
|
|
141
|
+
callbacks.current = { onChange, onSave }
|
|
142
|
+
|
|
143
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the view is created once; later props flow in through the effects below
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (!containerRef.current) return
|
|
146
|
+
|
|
147
|
+
const view = new EditorView({
|
|
148
|
+
parent: containerRef.current,
|
|
149
|
+
state: EditorState.create({
|
|
150
|
+
doc: value,
|
|
151
|
+
extensions: [
|
|
152
|
+
lineNumbers(),
|
|
153
|
+
highlightActiveLineGutter(),
|
|
154
|
+
foldGutter(),
|
|
155
|
+
history(),
|
|
156
|
+
drawSelection(),
|
|
157
|
+
indentOnInput(),
|
|
158
|
+
bracketMatching(),
|
|
159
|
+
closeBrackets(),
|
|
160
|
+
closePercentBrace,
|
|
161
|
+
highlightActiveLine(),
|
|
162
|
+
highlightSelectionMatches(),
|
|
163
|
+
autocompletion(),
|
|
164
|
+
lintGutter(),
|
|
165
|
+
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
|
166
|
+
EditorView.lineWrapping,
|
|
167
|
+
theme,
|
|
168
|
+
languageCompartment.current.of([]),
|
|
169
|
+
readOnlyCompartment.current.of(EditorState.readOnly.of(readOnly)),
|
|
170
|
+
keymap.of([
|
|
171
|
+
{
|
|
172
|
+
key: 'Mod-s',
|
|
173
|
+
preventDefault: true,
|
|
174
|
+
run: () => {
|
|
175
|
+
callbacks.current.onSave?.()
|
|
176
|
+
return true
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
...closeBracketsKeymap,
|
|
180
|
+
...defaultKeymap,
|
|
181
|
+
...searchKeymap,
|
|
182
|
+
...historyKeymap,
|
|
183
|
+
...completionKeymap,
|
|
184
|
+
indentWithTab,
|
|
185
|
+
]),
|
|
186
|
+
EditorView.updateListener.of((update) => {
|
|
187
|
+
if (update.docChanged) callbacks.current.onChange?.(update.state.doc.toString())
|
|
188
|
+
}),
|
|
189
|
+
EditorView.contentAttributes.of({ 'aria-label': props['aria-label'] ?? '' }),
|
|
190
|
+
],
|
|
191
|
+
}),
|
|
192
|
+
})
|
|
193
|
+
viewRef.current = view
|
|
194
|
+
|
|
195
|
+
return () => {
|
|
196
|
+
view.destroy()
|
|
197
|
+
viewRef.current = null
|
|
198
|
+
}
|
|
199
|
+
}, [])
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
const view = viewRef.current
|
|
203
|
+
if (!view || view.state.doc.toString() === value) return
|
|
204
|
+
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } })
|
|
205
|
+
}, [value])
|
|
206
|
+
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
const variables = completions.filter((completion) => !completion.label.includes('.'))
|
|
209
|
+
const language = liquid({
|
|
210
|
+
base: html(),
|
|
211
|
+
variables,
|
|
212
|
+
filters,
|
|
213
|
+
properties: (path) => {
|
|
214
|
+
const prefix = `${path.join('.')}.`
|
|
215
|
+
return completions
|
|
216
|
+
.filter((completion) => completion.label.startsWith(prefix))
|
|
217
|
+
.map((completion) => ({ ...completion, label: completion.label.slice(prefix.length) }))
|
|
218
|
+
.filter((completion) => !completion.label.includes('.'))
|
|
219
|
+
},
|
|
220
|
+
})
|
|
221
|
+
viewRef.current?.dispatch({ effects: languageCompartment.current.reconfigure(language) })
|
|
222
|
+
}, [completions, filters])
|
|
223
|
+
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
viewRef.current?.dispatch({
|
|
226
|
+
effects: readOnlyCompartment.current.reconfigure(EditorState.readOnly.of(readOnly)),
|
|
227
|
+
})
|
|
228
|
+
}, [readOnly])
|
|
229
|
+
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
const view = viewRef.current
|
|
232
|
+
if (!view) return
|
|
233
|
+
view.dispatch(setDiagnostics(view.state, toDiagnostics(view.state, diagnostics)))
|
|
234
|
+
}, [diagnostics])
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<div
|
|
238
|
+
ref={containerRef}
|
|
239
|
+
data-slot="code-editor"
|
|
240
|
+
className={cn(
|
|
241
|
+
'min-h-64 overflow-hidden rounded-md border border-border bg-card shadow-control focus-within:border-blue-500 focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)]',
|
|
242
|
+
className,
|
|
243
|
+
)}
|
|
244
|
+
/>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export { CodeEditor }
|
package/src/ui/combobox.tsx
CHANGED
|
@@ -8,7 +8,33 @@ import { CheckIcon, ChevronDownIcon, XIcon } from '../spree/icons'
|
|
|
8
8
|
import { Button } from './button'
|
|
9
9
|
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from './input-group'
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Base UI empties a multi-select when Escape is pressed with its popup closed,
|
|
13
|
+
* and picking a searched result closes the popup, so the Escape meant to
|
|
14
|
+
* dismiss the search silently wiped every chip. Chips are removed only through
|
|
15
|
+
* their own remove button or Backspace, and the Escape passes on to whatever
|
|
16
|
+
* encloses the picker, such as a sheet, as it does for an empty one.
|
|
17
|
+
*/
|
|
18
|
+
function Combobox<Value, Multiple extends boolean | undefined = false, Item = Value>(
|
|
19
|
+
props: ComboboxPrimitive.Root.Props<Value, Multiple, Item>,
|
|
20
|
+
) {
|
|
21
|
+
const { multiple, onValueChange } = props
|
|
22
|
+
if (!multiple) return <ComboboxPrimitive.Root {...props} />
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<ComboboxPrimitive.Root
|
|
26
|
+
{...props}
|
|
27
|
+
onValueChange={(value, eventDetails) => {
|
|
28
|
+
if (eventDetails.reason === 'escape-key') {
|
|
29
|
+
eventDetails.cancel()
|
|
30
|
+
eventDetails.allowPropagation()
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
onValueChange?.(value, eventDetails)
|
|
34
|
+
}}
|
|
35
|
+
/>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
12
38
|
|
|
13
39
|
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
|
14
40
|
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
|
package/src/ui/sidebar.tsx
CHANGED
|
@@ -276,7 +276,7 @@ function Sidebar({
|
|
|
276
276
|
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex',
|
|
277
277
|
// Adjust the padding for floating and inset variants.
|
|
278
278
|
variant === 'floating' || variant === 'inset'
|
|
279
|
-
? '
|
|
279
|
+
? 'py-2 px-1 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
|
280
280
|
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
|
|
281
281
|
className,
|
|
282
282
|
)}
|
|
@@ -402,7 +402,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
|
|
402
402
|
data-slot="sidebar-footer"
|
|
403
403
|
data-sidebar="footer"
|
|
404
404
|
className={cn(
|
|
405
|
-
'flex flex-col gap-0 p-
|
|
405
|
+
'flex flex-col gap-0 p-1.5 mt-auto border-t border-border-subtle group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:items-center',
|
|
406
406
|
className,
|
|
407
407
|
)}
|
|
408
408
|
{...props}
|