@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spree/dashboard-ui",
3
3
  "license": "MIT",
4
- "version": "1.0.0-beta.8",
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}
@@ -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 one unit of it cost. */
101
+ /** A line the claim can name, with what the customer paid for it. */
102
102
  export type ClaimableLine = PostSaleUnit & {
103
- /** Unit price, used to default the refund to what was actually paid. */
104
- price?: string | null
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
- // Default the refund to what was paid for those units;
177
- // the merchant can still overwrite it.
178
- if (quantity > 0 && !amounts[line.id]) {
179
- const unitPrice = Number(line.price)
180
- if (Number.isFinite(unitPrice)) {
181
- setAmounts((current) => ({
182
- ...current,
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
- * Gives the money back: how much, and by what means.
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>{t('admin.pages.orders.detail.returns.refund_title')}</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
- <Field>
184
- <FieldLabel htmlFor="refund-amount">
185
- {t('admin.pages.orders.detail.returns.refund_amount')}
186
- </FieldLabel>
187
- <InputGroup>
188
- <InputGroupAddon>
189
- <InputGroupText>{currencySymbol}</InputGroupText>
190
- </InputGroupAddon>
191
- <InputGroupInput
192
- id="refund-amount"
193
- type="number"
194
- step="0.01"
195
- min="0"
196
- value={amount}
197
- onChange={(event) => setAmount(event.target.value)}
198
- />
199
- </InputGroup>
200
- </Field>
201
- <Field>
202
- <FieldLabel htmlFor="refund-method">
203
- {t('admin.pages.orders.detail.returns.refund_method')}
204
- </FieldLabel>
205
- <Select
206
- items={methodOptions}
207
- value={refundMethod}
208
- onValueChange={(value) => setRefundMethod(value as RefundMethod)}
209
- >
210
- <SelectTrigger id="refund-method">
211
- <SelectValue />
212
- </SelectTrigger>
213
- <SelectContent>
214
- {methodOptions.map((option) => (
215
- <SelectItem key={option.value} value={option.value}>
216
- {option.label}
217
- </SelectItem>
218
- ))}
219
- </SelectContent>
220
- </Select>
221
- </Field>
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
- {t('admin.pages.orders.detail.returns.actions.refund')}
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
- /** Error object — when supplied, its message is shown if `description` is omitted. */
20
- error?: Error | null
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 = description ?? error?.message ?? t('admin.errors.unexpected_retry')
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) 60%, transparent);
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: var(--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) 50%, transparent);
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 }
@@ -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
- const Combobox = ComboboxPrimitive.Root
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} />
@@ -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
- ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
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-2 mt-auto border-t border-border-subtle group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:items-center',
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}