@podoba/react 0.0.32 → 0.0.35

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.
Files changed (70) hide show
  1. package/package.json +3 -3
  2. package/src/components/asset-masonry-grid.examples.tsx +9 -0
  3. package/src/components/asset-masonry-grid.tsx +62 -0
  4. package/src/components/asset-selection-surface.examples.tsx +32 -0
  5. package/src/components/asset-selection-surface.tsx +184 -0
  6. package/src/components/brand-page-header.tsx +297 -67
  7. package/src/components/button.tsx +27 -4
  8. package/src/components/combobox.tsx +1 -1
  9. package/src/components/compact-action-button.examples.tsx +7 -0
  10. package/src/components/compact-action-button.tsx +21 -0
  11. package/src/components/compact-settings-dialog.examples.tsx +15 -0
  12. package/src/components/compact-settings-dialog.tsx +48 -0
  13. package/src/components/context-action-glyph.examples.tsx +9 -0
  14. package/src/components/context-action-glyph.tsx +61 -0
  15. package/src/components/context-menu.tsx +191 -107
  16. package/src/components/context-search-panel.examples.tsx +9 -0
  17. package/src/components/context-search-panel.tsx +50 -0
  18. package/src/components/csv-binding-presentation.examples.tsx +14 -0
  19. package/src/components/csv-binding-presentation.tsx +40 -0
  20. package/src/components/cta-pill.tsx +19 -4
  21. package/src/components/dashboard-grid.tsx +1 -1
  22. package/src/components/date-picker.tsx +5 -3
  23. package/src/components/date-selection-calendar.examples.tsx +8 -0
  24. package/src/components/date-selection-calendar.tsx +47 -0
  25. package/src/components/delivery/send-to-print-modal.tsx +351 -84
  26. package/src/components/dialog-action-button.examples.tsx +7 -0
  27. package/src/components/dialog-action-button.tsx +22 -0
  28. package/src/components/dialog.tsx +32 -12
  29. package/src/components/document-upload-panel.examples.tsx +12 -0
  30. package/src/components/document-upload-panel.tsx +48 -0
  31. package/src/components/dropdown-menu.tsx +5 -3
  32. package/src/components/empty-panel-action.examples.tsx +7 -0
  33. package/src/components/empty-panel-action.tsx +8 -0
  34. package/src/components/field-appearance.ts +14 -0
  35. package/src/components/focus-field.tsx +3 -3
  36. package/src/components/icons.tsx +30 -0
  37. package/src/components/input.examples.tsx +8 -0
  38. package/src/components/input.tsx +13 -5
  39. package/src/components/media-asset-panel.examples.tsx +11 -0
  40. package/src/components/media-asset-panel.tsx +58 -0
  41. package/src/components/media-gallery.examples.tsx +12 -0
  42. package/src/components/media-gallery.tsx +53 -0
  43. package/src/components/media-settings-dialog.examples.tsx +21 -0
  44. package/src/components/media-settings-dialog.tsx +83 -0
  45. package/src/components/preview-info-card.examples.tsx +18 -0
  46. package/src/components/preview-info-card.tsx +27 -0
  47. package/src/components/reload-icon.examples.tsx +7 -0
  48. package/src/components/reload-icon.tsx +6 -0
  49. package/src/components/request-changes-modal.examples.tsx +26 -0
  50. package/src/components/request-changes-modal.tsx +36 -19
  51. package/src/components/rich-text-editor.tsx +1 -1
  52. package/src/components/select.examples.tsx +17 -0
  53. package/src/components/select.tsx +78 -22
  54. package/src/components/settings-dialog-surface.examples.tsx +44 -0
  55. package/src/components/settings-dialog-surface.tsx +240 -0
  56. package/src/components/side-panel.examples.tsx +80 -0
  57. package/src/components/side-panel.tsx +140 -0
  58. package/src/components/subtle.tsx +64 -8
  59. package/src/components/table.examples.tsx +9 -0
  60. package/src/components/table.tsx +29 -12
  61. package/src/components/template-catalog-card.examples.tsx +25 -0
  62. package/src/components/template-catalog-card.tsx +178 -0
  63. package/src/components/text.tsx +14 -1
  64. package/src/components/textarea.examples.tsx +8 -0
  65. package/src/components/textarea.tsx +16 -6
  66. package/src/components/tile.tsx +16 -17
  67. package/src/editor/block-editor.tsx +56 -14
  68. package/src/index.ts +20 -0
  69. package/src/layout/app-shell.tsx +13 -9
  70. package/src/layout/topbar.tsx +9 -12
@@ -21,9 +21,9 @@
21
21
  // field is aria-invalid when invalid; the server error is role="alert"; confirm
22
22
  // shows a pending state and disables while in flight / invalid.
23
23
 
24
- import { useMemo, useState } from 'react'
24
+ import { useId, useMemo, useState } from 'react'
25
25
  import { Button } from '../button'
26
- import { Dialog } from '../dialog'
26
+ import { ModalDialog, ModalOverlay, ModalSurface } from '../dialog'
27
27
  import { Input } from '../input'
28
28
 
29
29
  /** The validated print spec the modal yields on confirm. */
@@ -36,6 +36,20 @@ export interface PrintSpec {
36
36
  quantity: number
37
37
  /** Delivery address — non-blank. */
38
38
  address: string
39
+ /** Company / recipient. */
40
+ company?: string
41
+ /** Street address. */
42
+ street?: string
43
+ /** City. */
44
+ city?: string
45
+ /** ZIP / postcode. */
46
+ zip?: string
47
+ /** Contact person. */
48
+ contactName?: string
49
+ /** Contact phone. */
50
+ phone?: string
51
+ /** Optional print-production note. */
52
+ note?: string
39
53
  }
40
54
 
41
55
  /** Strings the send-to-print modal renders — supplied by the app (i18n). */
@@ -50,6 +64,7 @@ export interface SendToPrintModalLabels {
50
64
  /** Material field label + placeholder. */
51
65
  materialLabel: string
52
66
  materialPlaceholder: string
67
+ materialDefault?: string
53
68
  /** Quantity field label + placeholder. */
54
69
  quantityLabel: string
55
70
  quantityPlaceholder: string
@@ -64,6 +79,34 @@ export interface SendToPrintModalLabels {
64
79
  confirm: string
65
80
  /** Confirm button — pending state. */
66
81
  submitting: string
82
+ /** Optional old-Manager section label. */
83
+ sectionLabel?: string
84
+ outputName?: string
85
+ outputDescription?: string
86
+ /** Optional no-material message. */
87
+ noMaterialConfigured?: string
88
+ companyLabel?: string
89
+ companyPlaceholder?: string
90
+ streetLabel?: string
91
+ streetPlaceholder?: string
92
+ cityLabel?: string
93
+ cityPlaceholder?: string
94
+ zipLabel?: string
95
+ zipPlaceholder?: string
96
+ contactNameLabel?: string
97
+ contactNamePlaceholder?: string
98
+ phoneLabel?: string
99
+ phonePlaceholder?: string
100
+ noteLabel?: string
101
+ notePlaceholder?: string
102
+ priceEyebrow?: string
103
+ priceIdle?: string
104
+ closeLabel?: string
105
+ }
106
+
107
+ export interface PrintMaterialOption {
108
+ id: string
109
+ label: string
67
110
  }
68
111
 
69
112
  export interface SendToPrintModalProps {
@@ -79,6 +122,16 @@ export interface SendToPrintModalProps {
79
122
  error?: string
80
123
  /** App-supplied i18n strings. */
81
124
  labels: SendToPrintModalLabels
125
+ /** Output title shown in the old Manager print-order summary card. */
126
+ outputName?: string
127
+ /** Output dimension / format line shown in the old Manager print-order summary card. */
128
+ outputDescription?: string
129
+ /** Available print materials. Empty means the old Manager disabled the order action. */
130
+ materialOptions?: PrintMaterialOption[]
131
+ /** Initial quantity. Old Manager opens print with a production-like quantity instead of blank. */
132
+ initialQuantity?: number
133
+ /** Modal family. Dashboard Workspace print orders use the old wider source panel. */
134
+ surfaceVariant?: 'standard' | 'dashboard'
82
135
  /** Optional test id forwarded to the dialog. */
83
136
  'data-testid'?: string
84
137
  }
@@ -92,6 +145,12 @@ function parseQuantity(raw: string): number | null {
92
145
  return value
93
146
  }
94
147
 
148
+ function capitalizeFirstAddressLetter(value: string): string {
149
+ const index = value.search(/\p{L}/u)
150
+ if (index < 0) return value
151
+ return `${value.slice(0, index)}${value.charAt(index).toUpperCase()}${value.slice(index + 1)}`
152
+ }
153
+
95
154
  /** Send-to-print modal with validated size / material / quantity / address. */
96
155
  export function SendToPrintModal({
97
156
  isOpen,
@@ -100,31 +159,69 @@ export function SendToPrintModal({
100
159
  isPending = false,
101
160
  error,
102
161
  labels,
162
+ outputName,
163
+ outputDescription,
164
+ materialOptions = [],
165
+ initialQuantity = 500,
166
+ surfaceVariant = 'standard',
103
167
  'data-testid': testId,
104
168
  }: SendToPrintModalProps): React.ReactNode {
105
- const [size, setSize] = useState('')
106
- const [material, setMaterial] = useState('')
107
- const [quantityRaw, setQuantityRaw] = useState('')
108
- const [address, setAddress] = useState('')
169
+ const titleId = useId()
170
+ const descriptionId = useId()
171
+ // `materialOptions` is normally fetched, so it is [] on mount. Seeding useState
172
+ // from it captures that empty first render forever; deriving the fallback each
173
+ // render selects the first option as soon as the list arrives. '' means
174
+ // "untouched", not "nothing selected".
175
+ const [materialId, setMaterialId] = useState('')
176
+ const [sizeRaw, setSizeRaw] = useState('')
177
+ const [quantityRaw, setQuantityRaw] = useState(String(initialQuantity))
178
+ const [company, setCompany] = useState('')
179
+ const [street, setStreet] = useState('')
180
+ const [city, setCity] = useState('')
181
+ const [zip, setZip] = useState('')
182
+ const [contactName, setContactName] = useState('')
183
+ const [phone, setPhone] = useState('')
184
+ const [note, setNote] = useState('')
109
185
 
110
186
  const quantity = useMemo(() => parseQuantity(quantityRaw), [quantityRaw])
187
+ const effectiveMaterialId = materialId || (materialOptions[0]?.id ?? '')
188
+ const selectedMaterial = useMemo(
189
+ () => materialOptions.find((option) => option.id === effectiveMaterialId) ?? null,
190
+ [effectiveMaterialId, materialOptions],
191
+ )
192
+ // `size` is the caller's output description when it supplies one, and a collected
193
+ // field otherwise — the documented `print_specs` rule is that it is non-blank, and
194
+ // `outputDescription` is optional, so it cannot be the only source.
195
+ const derivedSize = outputDescription?.trim() ?? ''
196
+ const size = derivedSize || sizeRaw.trim()
111
197
  // Surface the quantity error only once the user has typed something invalid
112
198
  // (not on the initial blank state — that would scream before any input).
113
199
  const quantityInvalid = quantityRaw.trim().length > 0 && quantity === null
114
200
 
115
201
  const reset = (): void => {
116
- setSize('')
117
- setMaterial('')
118
- setQuantityRaw('')
119
- setAddress('')
202
+ setMaterialId('')
203
+ setSizeRaw('')
204
+ setQuantityRaw(String(initialQuantity))
205
+ setCompany('')
206
+ setStreet('')
207
+ setCity('')
208
+ setZip('')
209
+ setContactName('')
210
+ setPhone('')
211
+ setNote('')
120
212
  }
121
213
 
122
214
  const canConfirm =
123
215
  !isPending &&
124
- size.trim().length > 0 &&
125
- material.trim().length > 0 &&
216
+ size.length > 0 &&
126
217
  quantity !== null &&
127
- address.trim().length > 0
218
+ selectedMaterial !== null &&
219
+ company.trim().length > 0 &&
220
+ street.trim().length > 0 &&
221
+ city.trim().length > 0 &&
222
+ zip.trim().length > 0 &&
223
+ contactName.trim().length > 0 &&
224
+ phone.trim().length > 0
128
225
 
129
226
  const handleOpenChange = (open: boolean): void => {
130
227
  if (!open) reset()
@@ -132,85 +229,255 @@ export function SendToPrintModal({
132
229
  }
133
230
 
134
231
  const handleConfirm = (): void => {
135
- if (!canConfirm || quantity === null) return
232
+ if (!canConfirm || quantity === null || selectedMaterial === null) return
233
+ const address = [company, street, `${zip} ${city}`, contactName, phone]
234
+ .map((part) => part.trim())
235
+ .filter(Boolean)
236
+ .join(', ')
136
237
  onConfirm({
137
- size: size.trim(),
138
- material: material.trim(),
238
+ size,
239
+ material: selectedMaterial.label.trim(),
139
240
  quantity,
140
- address: address.trim(),
241
+ address,
242
+ company: company.trim(),
243
+ street: street.trim(),
244
+ city: city.trim(),
245
+ zip: zip.trim(),
246
+ contactName: contactName.trim(),
247
+ phone: phone.trim(),
248
+ note: note.trim(),
141
249
  })
142
250
  }
143
251
 
252
+ const surfaceClassName = surfaceVariant === 'dashboard'
253
+ ? 'box-border flex h-[min(90vh,72rem)] max-h-[min(90vh,72rem)] w-[min(50vw,72rem)] max-w-[min(50vw,72rem)] flex-col overflow-hidden p-0 max-[900px]:h-[90vh] max-[900px]:w-[92vw] max-[900px]:max-w-[92vw]'
254
+ : 'box-border flex h-[min(90vh,72rem)] max-h-[min(90vh,72rem)] w-[min(90vw,51rem)] max-w-[min(90vw,51rem)] flex-col overflow-hidden p-0 max-[900px]:h-[90vh] max-[900px]:w-[92vw] max-[900px]:max-w-[92vw]'
255
+
144
256
  return (
145
- <Dialog
146
- title={labels.title}
147
- data-testid={testId ?? 'delivery-print-modal'}
257
+ <ModalOverlay
148
258
  isOpen={isOpen}
149
259
  onOpenChange={handleOpenChange}
260
+ isDismissable
150
261
  >
151
- <div className="flex flex-col gap-3">
152
- <p className="text-small text-fg-muted">{labels.body}</p>
153
-
154
- <Input
155
- label={labels.sizeLabel}
156
- placeholder={labels.sizePlaceholder}
157
- value={size}
158
- onChange={setSize}
159
- data-testid="delivery-print-size"
160
- />
161
- <Input
162
- label={labels.materialLabel}
163
- placeholder={labels.materialPlaceholder}
164
- value={material}
165
- onChange={setMaterial}
166
- data-testid="delivery-print-material"
167
- />
168
- <Input
169
- label={labels.quantityLabel}
170
- placeholder={labels.quantityPlaceholder}
171
- value={quantityRaw}
172
- onChange={setQuantityRaw}
173
- type="number"
174
- inputMode="numeric"
175
- isInvalid={quantityInvalid}
176
- errorMessage={quantityInvalid ? labels.quantityError : undefined}
177
- data-testid="delivery-print-quantity"
178
- />
179
- <Input
180
- label={labels.addressLabel}
181
- placeholder={labels.addressPlaceholder}
182
- value={address}
183
- onChange={setAddress}
184
- data-testid="delivery-print-address"
185
- />
186
-
187
- {error ? (
188
- <p data-testid="delivery-print-error" role="alert" className="text-small text-danger">
189
- {error}
190
- </p>
191
- ) : null}
192
-
193
- <div className="mt-2 flex justify-end gap-2">
194
- <Button
195
- variant="secondary"
196
- size="sm"
197
- isDisabled={isPending}
198
- onPress={() => handleOpenChange(false)}
199
- >
200
- {labels.cancel}
201
- </Button>
202
- <Button
203
- data-testid="delivery-print-confirm"
204
- variant="primary"
205
- size="sm"
206
- isDisabled={!canConfirm}
207
- isPending={isPending}
208
- onPress={handleConfirm}
209
- >
210
- {isPending ? labels.submitting : labels.confirm}
211
- </Button>
212
- </div>
213
- </div>
214
- </Dialog>
262
+ <ModalSurface className={surfaceClassName}>
263
+ <ModalDialog
264
+ aria-labelledby={titleId}
265
+ aria-describedby={descriptionId}
266
+ data-testid={testId ?? 'delivery-print-modal'}
267
+ className="flex min-h-0 flex-1 flex-col outline-none"
268
+ >
269
+ {({ close }) => (
270
+ <form
271
+ className="flex min-h-0 flex-1 flex-col gap-6 py-10"
272
+ onSubmit={(event) => {
273
+ event.preventDefault()
274
+ handleConfirm()
275
+ }}
276
+ noValidate
277
+ >
278
+ <div className="mx-auto box-border w-full max-w-[50rem] px-5">
279
+ <div className="flex items-start justify-between gap-4">
280
+ <h2 id={titleId} className="m-0 max-w-[19ch] text-heading1 font-medium leading-[1.02] tracking-tight text-fg">
281
+ {labels.title}
282
+ </h2>
283
+ <button
284
+ type="button"
285
+ onClick={close}
286
+ aria-label={labels.closeLabel ?? 'Close'}
287
+ className="-mr-1 -mt-1 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-fg-subtle outline-none transition-colors hover:bg-surface-muted hover:text-fg focus-visible:ring-2 focus-visible:ring-ring"
288
+ >
289
+ <svg
290
+ width="18"
291
+ height="18"
292
+ viewBox="0 0 24 24"
293
+ fill="none"
294
+ stroke="currentColor"
295
+ strokeWidth="2"
296
+ strokeLinecap="round"
297
+ aria-hidden="true"
298
+ >
299
+ <path d="M6 6l12 12M18 6 6 18" />
300
+ </svg>
301
+ </button>
302
+ </div>
303
+ </div>
304
+
305
+ <div className="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
306
+ <div className="mx-auto box-border flex w-full max-w-[50rem] flex-col gap-6 px-5">
307
+ <div className="flex flex-col gap-1">
308
+ <p className="m-0 text-body font-medium text-fg">
309
+ {labels.sectionLabel ?? labels.title}
310
+ </p>
311
+ <p id={descriptionId} className="m-0 max-w-[48ch] text-body font-normal text-fg">
312
+ {labels.body}
313
+ </p>
314
+ </div>
315
+
316
+ <div className="flex flex-col gap-1 rounded-md bg-surface-card p-3 text-caption leading-4 text-fg-muted">
317
+ <span>{outputName?.trim() || labels.outputName || labels.sizeLabel}</span>
318
+ {outputDescription?.trim() || labels.outputDescription || labels.sizePlaceholder ? (
319
+ <strong className="font-medium text-fg">
320
+ {outputDescription?.trim() || labels.outputDescription || labels.sizePlaceholder}
321
+ </strong>
322
+ ) : null}
323
+ </div>
324
+
325
+ <div className="grid gap-3">
326
+ {derivedSize ? null : (
327
+ <Input
328
+ label={labels.sizeLabel}
329
+ placeholder={labels.sizePlaceholder}
330
+ value={sizeRaw}
331
+ onChange={setSizeRaw}
332
+ data-testid="delivery-print-size"
333
+ />
334
+ )}
335
+
336
+ <Input
337
+ label={labels.quantityLabel}
338
+ placeholder={labels.quantityPlaceholder}
339
+ value={quantityRaw}
340
+ onChange={setQuantityRaw}
341
+ type="number"
342
+ inputMode="numeric"
343
+ isInvalid={quantityInvalid}
344
+ errorMessage={quantityInvalid ? labels.quantityError : undefined}
345
+ data-testid="delivery-print-quantity"
346
+ />
347
+
348
+ {materialOptions.length > 1 ? (
349
+ <label className="grid gap-1.5 text-small font-medium text-fg">
350
+ <span>{labels.materialLabel}</span>
351
+ <select
352
+ value={effectiveMaterialId}
353
+ onChange={(event) => setMaterialId(event.currentTarget.value)}
354
+ data-testid="delivery-print-material"
355
+ className="h-12 rounded-md border border-border bg-surface px-3 text-body font-normal text-fg outline-none transition-colors hover:border-border-muted focus:border-fg focus:ring-2 focus:ring-ring"
356
+ >
357
+ {materialOptions.map((option) => (
358
+ <option key={option.id} value={option.id}>
359
+ {option.label}
360
+ </option>
361
+ ))}
362
+ </select>
363
+ </label>
364
+ ) : selectedMaterial ? (
365
+ <div
366
+ data-testid="delivery-print-material"
367
+ className="flex flex-col gap-1 rounded-md bg-surface-card p-3 text-caption leading-4 text-fg-muted"
368
+ >
369
+ <span>{labels.materialLabel}</span>
370
+ <strong className="font-medium text-fg">{selectedMaterial.label}</strong>
371
+ </div>
372
+ ) : (
373
+ <p
374
+ role="note"
375
+ data-testid="delivery-print-material"
376
+ className="m-0 rounded-md bg-surface-card p-3 text-caption leading-4 text-fg-muted"
377
+ >
378
+ {labels.noMaterialConfigured ?? labels.materialPlaceholder}
379
+ </p>
380
+ )}
381
+
382
+ <Input
383
+ label={labels.companyLabel ?? labels.addressLabel}
384
+ placeholder={labels.companyPlaceholder ?? labels.addressPlaceholder}
385
+ value={company}
386
+ onChange={setCompany}
387
+ data-testid="delivery-print-company"
388
+ />
389
+ <Input
390
+ label={labels.streetLabel ?? labels.addressLabel}
391
+ placeholder={labels.streetPlaceholder ?? labels.addressPlaceholder}
392
+ value={street}
393
+ onChange={(value) => setStreet(capitalizeFirstAddressLetter(value))}
394
+ data-testid="delivery-print-street"
395
+ />
396
+ <div className="grid gap-4 sm:grid-cols-[1fr_10rem]">
397
+ <Input
398
+ label={labels.cityLabel ?? labels.addressLabel}
399
+ placeholder={labels.cityPlaceholder ?? labels.addressPlaceholder}
400
+ value={city}
401
+ onChange={setCity}
402
+ data-testid="delivery-print-city"
403
+ />
404
+ <Input
405
+ label={labels.zipLabel ?? labels.addressLabel}
406
+ placeholder={labels.zipPlaceholder ?? labels.addressPlaceholder}
407
+ value={zip}
408
+ onChange={setZip}
409
+ data-testid="delivery-print-zip"
410
+ />
411
+ </div>
412
+ <div className="grid gap-4 sm:grid-cols-2">
413
+ <Input
414
+ label={labels.contactNameLabel ?? labels.addressLabel}
415
+ placeholder={labels.contactNamePlaceholder ?? labels.addressPlaceholder}
416
+ value={contactName}
417
+ onChange={setContactName}
418
+ data-testid="delivery-print-contact"
419
+ />
420
+ <Input
421
+ label={labels.phoneLabel ?? labels.addressLabel}
422
+ placeholder={labels.phonePlaceholder ?? labels.addressPlaceholder}
423
+ value={phone}
424
+ onChange={setPhone}
425
+ type="tel"
426
+ data-testid="delivery-print-phone"
427
+ />
428
+ </div>
429
+ <Input
430
+ label={labels.noteLabel ?? labels.addressLabel}
431
+ placeholder={labels.notePlaceholder ?? labels.addressPlaceholder}
432
+ value={note}
433
+ onChange={setNote}
434
+ data-testid="delivery-print-note"
435
+ />
436
+ </div>
437
+
438
+ {error ? (
439
+ <p data-testid="delivery-print-error" role="alert" className="m-0 text-small text-danger">
440
+ {error}
441
+ </p>
442
+ ) : null}
443
+ </div>
444
+ </div>
445
+
446
+ <div className="sticky bottom-0 mt-0 w-full border-border border-t bg-surface pt-4">
447
+ <div className="mx-auto box-border flex w-full max-w-[50rem] items-center justify-between gap-4 px-5">
448
+ <div className="flex min-w-0 flex-col gap-1 text-small text-fg-muted">
449
+ {/* No fallback: borrowing `sizeLabel` / `materialPlaceholder` put
450
+ "Size" and "e.g. Matte 250g" in a money slot for every consumer
451
+ that had not adopted the new label set. */}
452
+ {labels.priceEyebrow ? <span>{labels.priceEyebrow}</span> : null}
453
+ {labels.priceIdle ? <span className="truncate">{labels.priceIdle}</span> : null}
454
+ </div>
455
+ <div className="flex shrink-0 justify-end gap-2">
456
+ <Button
457
+ variant="secondary"
458
+ size="sm"
459
+ isDisabled={isPending}
460
+ onPress={() => handleOpenChange(false)}
461
+ >
462
+ {labels.cancel}
463
+ </Button>
464
+ <Button
465
+ data-testid="delivery-print-confirm"
466
+ variant="primary"
467
+ size="sm"
468
+ isDisabled={!canConfirm}
469
+ isPending={isPending}
470
+ onPress={handleConfirm}
471
+ >
472
+ {isPending ? labels.submitting : labels.confirm}
473
+ </Button>
474
+ </div>
475
+ </div>
476
+ </div>
477
+ </form>
478
+ )}
479
+ </ModalDialog>
480
+ </ModalSurface>
481
+ </ModalOverlay>
215
482
  )
216
483
  }
@@ -0,0 +1,7 @@
1
+ import { DialogActionButton } from './dialog-action-button'
2
+ export const examples = {
3
+ default: () => <DialogActionButton>Review Mapping</DialogActionButton>,
4
+ variants: () => <><DialogActionButton variant="secondary">Change CSV</DialogActionButton><DialogActionButton>Bind CSV</DialogActionButton></>,
5
+ states: () => <><DialogActionButton isDisabled>Bind CSV</DialogActionButton><DialogActionButton isPending>Binding CSV…</DialogActionButton></>,
6
+ }
7
+ export const meta = { category: 'Primitives', description: 'Source UI dialog action palette, typography and pointer/keyboard states, independent of dashboard CTAs.' }
@@ -0,0 +1,22 @@
1
+ import { Button as AriaButton } from 'react-aria-components'
2
+ import { uic } from '../utils/uic'
3
+
4
+ export const DIALOG_ACTION_VARIANTS = ['primary', 'secondary'] as const
5
+ export type DialogActionVariant = (typeof DIALOG_ACTION_VARIANTS)[number]
6
+
7
+ /** Source UI dialog action, distinct from the frontend dashboard CTA Button.
8
+ * Shares Button's semantic palette (brand-primary / surface-card) so the two never
9
+ * drift apart; only the geometry — pill radius, 24/12 padding — is specific here.
10
+ * Keyboard focus retains the shared RAC treatment.
11
+ */
12
+ export const DialogActionButton = uic(AriaButton, {
13
+ displayName: 'DialogActionButton',
14
+ baseClass: 'inline-flex items-center justify-center rounded-full px-6 py-3 text-body font-medium leading-5 cursor-pointer transition-colors duration-200 motion-reduce:transition-none outline-none ' +
15
+ 'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-offset-2 ' +
16
+ 'data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed data-[pending]:cursor-progress',
17
+ variants: { variant: {
18
+ primary: 'border-0 bg-brand-primary text-fg-inverted data-[hovered]:bg-neutral-600 data-[pressed]:bg-brand-primary',
19
+ secondary: 'border border-border bg-surface-card text-fg data-[hovered]:bg-surface-muted data-[hovered]:border-border-muted data-[pressed]:bg-surface-muted',
20
+ } },
21
+ defaultVariants: { variant: 'primary' },
22
+ })
@@ -32,7 +32,7 @@ export const ModalOverlay = uic(RACModalOverlay, {
32
32
  export const ModalSurface = uic(RACModal, {
33
33
  displayName: 'DialogModal',
34
34
  baseClass:
35
- 'rounded-lg bg-surface p-5 outline-none shadow-modal-surface ' +
35
+ 'relative rounded-lg bg-surface p-5 outline-none shadow-modal-surface ' +
36
36
  'data-[entering]:animate-modal-surface-in data-[exiting]:animate-modal-surface-out',
37
37
  })
38
38
 
@@ -92,6 +92,10 @@ export type DialogProps = RACDialogProps & {
92
92
  badge?: ReactNode
93
93
  /** Width preset — `md` (default) for forms; `sm` for tight confirms; `lg`/`xl` for wide stepper content. */
94
94
  size?: DialogSize
95
+ /** Optional form-specific width cap; the viewport width preset still applies. */
96
+ maxWidth?: number
97
+ /** Responsive intrinsic detail envelope, independent of title sizing. */
98
+ widthPreset?: 'responsive-detail' | 'custom-task-detail'
95
99
  children: ReactNode | ((opts: { close: () => void }) => ReactNode)
96
100
  /**
97
101
  * Controlled open state. Provide together with `onOpenChange` to drive the
@@ -115,6 +119,8 @@ export const Dialog = ({
115
119
  badge,
116
120
  // gs DialogContent defaults to `md` (586px form width).
117
121
  size = 'md',
122
+ maxWidth,
123
+ widthPreset,
118
124
  children,
119
125
  isOpen,
120
126
  defaultOpen,
@@ -126,6 +132,11 @@ export const Dialog = ({
126
132
  // `full` is a fixed-height flex canvas: the body fills and scrolls internally so
127
133
  // the header (+ a footer the content pins) stay put. Other sizes are content-height.
128
134
  const isFlex = size === 'full'
135
+ // The Manager header treatment — block layout, floated close, label-ramp
136
+ // description — belongs to the OPT-IN width presets, not to "any dialog that
137
+ // isn't `full`". Gating it on `isFlex` made every sm/md/lg/xl dialog render a
138
+ // display-scale title and stranded `TITLE_CLASS` on the single `full` size.
139
+ const isCompact = widthPreset !== undefined
129
140
  return (
130
141
  // `isOpen`/`defaultOpen`/`onOpenChange` go to the RAC ModalOverlay: when
131
142
  // provided the dialog is controlled (renders independently of a DialogTrigger);
@@ -142,19 +153,28 @@ export const Dialog = ({
142
153
  onOpenChange={onOpenChange}
143
154
  isDismissable={isDismissable}
144
155
  >
145
- <ModalSurface className={SIZE_CLASS[size]}>
156
+ <ModalSurface
157
+ className={
158
+ widthPreset === 'responsive-detail'
159
+ ? 'w-[90vw] max-w-125 md:max-w-150 lg:max-w-160 xl:max-w-180 max-h-[85vh] overflow-y-auto'
160
+ : widthPreset === 'custom-task-detail'
161
+ ? 'w-[90vw] min-w-[400px] max-w-[500px] md:max-w-[600px] lg:max-w-[640px] xl:max-w-[720px] max-h-[85vh] overflow-y-auto max-[460px]:min-w-0'
162
+ : SIZE_CLASS[size]
163
+ }
164
+ style={maxWidth === undefined ? undefined : { maxWidth }}
165
+ >
146
166
  <ModalDialog
147
167
  {...props}
148
168
  className={isFlex ? 'flex min-h-0 flex-1 flex-col outline-none' : 'outline-none'}
149
169
  >
150
170
  {(renderProps) => (
151
171
  <>
152
- <div className="mb-4 flex shrink-0 items-start justify-between gap-4">
172
+ <div className={isCompact ? 'block' : 'mb-4 flex shrink-0 items-start justify-between gap-4'}>
153
173
  {title || description ? (
154
174
  <div className="min-w-0">
155
175
  {title ? (
156
176
  <div className="flex items-center gap-2">
157
- <Heading slot="title" className={`${TITLE_CLASS[size]} text-fg`}>
177
+ <Heading slot="title" className={isCompact ? 'm-0 mb-4 max-w-5/6 text-heading1 font-medium tracking-normal text-fg' : `${TITLE_CLASS[size]} text-fg`}>
158
178
  {title}
159
179
  </Heading>
160
180
  {badge ? (
@@ -165,8 +185,8 @@ export const Dialog = ({
165
185
  </div>
166
186
  ) : null}
167
187
  {description ? (
168
- // gs `.description` = base 16px, text-secondary (=fg).
169
- <p className="mt-1 text-body text-fg">{description}</p>
188
+ // Source ordinary descriptions use the 14px/18px label ramp.
189
+ <p className={isCompact ? 'm-0 mb-6 max-w-5/6 text-small leading-4.5 font-normal text-fg-workflow-muted' : 'mt-1 text-body text-fg'}>{description}</p>
170
190
  ) : null}
171
191
  </div>
172
192
  ) : (
@@ -179,19 +199,19 @@ export const Dialog = ({
179
199
  // gs close: 32px square (`h-8 w-8`), medium radius (`rounded-md`),
180
200
  // tertiary icon color (`text-fg-subtle`) → primary (`text-fg`) +
181
201
  // subtle hover bg on hover. Smooth color transition.
182
- className="-mr-1 -mt-1 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-fg-subtle outline-none transition-colors hover:bg-surface-muted hover:text-fg focus-visible:ring-2 focus-visible:ring-ring"
202
+ className={`${isCompact ? 'absolute top-[calc(var(--spacing)*3.375)] end-4 max-md:end-3' : '-mr-1 -mt-1'} inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-fg-subtle outline-none transition-colors hover:bg-surface-muted hover:text-fg focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none`}
183
203
  >
184
204
  <svg
185
- width="18"
186
- height="18"
187
- viewBox="0 0 24 24"
205
+ width={isCompact ? 15 : 18}
206
+ height={isCompact ? 15 : 18}
207
+ viewBox={isCompact ? '0 0 15 15' : '0 0 24 24'}
188
208
  fill="none"
189
- stroke="currentColor"
209
+ stroke={isCompact ? undefined : 'currentColor'}
190
210
  strokeWidth="2"
191
211
  strokeLinecap="round"
192
212
  aria-hidden="true"
193
213
  >
194
- <path d="M6 6l12 12M18 6 6 18" />
214
+ <path fill={isCompact ? 'currentColor' : undefined} d={isCompact ? 'M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z' : 'M6 6l12 12M18 6 6 18'} />
195
215
  </svg>
196
216
  </button>
197
217
  </div>
@@ -0,0 +1,12 @@
1
+ import { DocumentUploadPanel } from './document-upload-panel'
2
+
3
+ const copy = { title: 'CSV file', actionLabel: 'Select CSV', helperText: 'No CSV file selected yet.\nSelect the CSV file for content mapping.' }
4
+ export const examples = {
5
+ default: () => <DocumentUploadPanel {...copy} />,
6
+ dragging: () => <DocumentUploadPanel {...copy} isDragging />,
7
+ disabled: () => <DocumentUploadPanel {...copy} isDisabled />,
8
+ }
9
+ export const meta = {
10
+ category: 'Form',
11
+ description: 'Upload tile with a centred keyboard-accessible action and a native drop surface. Focus/hover use the shared React Aria button; caller owns file validation.',
12
+ }