@podoba/react 0.0.34 → 0.0.36

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 (67) 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 +33 -0
  5. package/src/components/asset-selection-surface.tsx +184 -0
  6. package/src/components/brand-page-header.tsx +60 -29
  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 +50 -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/dashboard-grid.tsx +1 -1
  21. package/src/components/date-picker.tsx +5 -3
  22. package/src/components/date-selection-calendar.examples.tsx +8 -0
  23. package/src/components/date-selection-calendar.tsx +47 -0
  24. package/src/components/delivery/send-to-print-modal.tsx +351 -84
  25. package/src/components/dialog-action-button.examples.tsx +7 -0
  26. package/src/components/dialog-action-button.tsx +22 -0
  27. package/src/components/dialog.tsx +32 -12
  28. package/src/components/document-upload-panel.examples.tsx +12 -0
  29. package/src/components/document-upload-panel.tsx +48 -0
  30. package/src/components/dropdown-menu.tsx +5 -3
  31. package/src/components/empty-panel-action.examples.tsx +7 -0
  32. package/src/components/empty-panel-action.tsx +8 -0
  33. package/src/components/field-appearance.ts +14 -0
  34. package/src/components/focus-field.tsx +3 -3
  35. package/src/components/icons.tsx +30 -0
  36. package/src/components/input.examples.tsx +8 -0
  37. package/src/components/input.tsx +13 -5
  38. package/src/components/media-asset-panel.examples.tsx +11 -0
  39. package/src/components/media-asset-panel.tsx +58 -0
  40. package/src/components/media-gallery.examples.tsx +12 -0
  41. package/src/components/media-gallery.tsx +53 -0
  42. package/src/components/media-settings-dialog.examples.tsx +21 -0
  43. package/src/components/media-settings-dialog.tsx +83 -0
  44. package/src/components/preview-info-card.examples.tsx +18 -0
  45. package/src/components/preview-info-card.tsx +27 -0
  46. package/src/components/reload-icon.examples.tsx +7 -0
  47. package/src/components/reload-icon.tsx +6 -0
  48. package/src/components/request-changes-modal.examples.tsx +26 -0
  49. package/src/components/request-changes-modal.tsx +36 -19
  50. package/src/components/rich-text-editor.tsx +1 -1
  51. package/src/components/select.examples.tsx +17 -0
  52. package/src/components/select.tsx +78 -22
  53. package/src/components/settings-dialog-surface.examples.tsx +44 -0
  54. package/src/components/settings-dialog-surface.tsx +240 -0
  55. package/src/components/subtle.tsx +64 -8
  56. package/src/components/table.examples.tsx +9 -0
  57. package/src/components/table.tsx +29 -12
  58. package/src/components/template-catalog-card.examples.tsx +25 -0
  59. package/src/components/template-catalog-card.tsx +179 -0
  60. package/src/components/text.tsx +14 -1
  61. package/src/components/textarea.examples.tsx +8 -0
  62. package/src/components/textarea.tsx +16 -6
  63. package/src/components/tile.tsx +16 -17
  64. package/src/editor/block-editor.tsx +56 -14
  65. package/src/index.ts +19 -0
  66. package/src/layout/app-shell.tsx +13 -9
  67. package/src/layout/topbar.tsx +9 -12
@@ -0,0 +1,14 @@
1
+ import { CsvActionButton, CsvMappingSelect, CsvPreviewTable } from './csv-binding-presentation'
2
+ import { SelectItem } from './select'
3
+
4
+ const headers = ['Headline', 'City']
5
+ const rows = [{ Headline: 'Annual report', City: 'Prague' }]
6
+ const choice = <CsvMappingSelect label="Headline" placeholder="Not bound" defaultSelectedKey="headline">
7
+ <SelectItem id="headline">Headline (headline)</SelectItem>
8
+ </CsvMappingSelect>
9
+ export const examples = {
10
+ default: () => <CsvPreviewTable headers={headers} rows={rows} label="CSV preview" emptyMessage="No preview rows available." />,
11
+ mapping: () => <CsvPreviewTable headers={headers} rows={rows} mapping={[choice, 'Not bound']} label="CSV mapping" emptyMessage="No columns available for mapping." />,
12
+ states: () => <><CsvActionButton>Review Mapping</CsvActionButton><CsvActionButton isDisabled>Bind CSV</CsvActionButton></>,
13
+ }
14
+ export const meta = { category: 'Form', description: 'Source CSV worksheet, hidden-label mapping select and footer action typography; preview rows do not sort or hover.' }
@@ -0,0 +1,40 @@
1
+ import { useEffect, useState, type ReactNode } from 'react'
2
+ import { uic } from '../utils/uic'
3
+ import { Table } from './table'
4
+ import { Select, type SelectProps } from './select'
5
+ import { DialogActionButton } from './dialog-action-button'
6
+
7
+ const PreviewScroll = uic('div', { displayName: 'CsvPreviewScroll', baseClass: 'w-full min-w-0 overflow-x-auto' })
8
+
9
+ /** CSV preview has one header per original column; a mapping row precedes data. */
10
+ export function CsvPreviewTable({ headers, rows, mapping, label, emptyMessage, ...rest }: {
11
+ headers: string[]
12
+ rows: Readonly<Record<string, string>>[]
13
+ mapping?: ReactNode[]
14
+ label: string
15
+ emptyMessage: string
16
+ 'data-testid'?: string
17
+ }) {
18
+ const [compact, setCompact] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 900px)').matches)
19
+ useEffect(() => {
20
+ const media = window.matchMedia('(max-width: 900px)')
21
+ const update = () => setCompact(media.matches)
22
+ update()
23
+ media.addEventListener('change', update)
24
+ return () => media.removeEventListener('change', update)
25
+ }, [])
26
+ const data: ReactNode[][] = rows.map(row => headers.map(header => row[header] ?? ''))
27
+ if (mapping) data.unshift(mapping)
28
+ return <PreviewScroll {...rest}>
29
+ <Table<ReactNode[]> appearance="worksheet" className={compact ? 'min-w-168' : 'min-w-256'}
30
+ aria-label={label} emptyMessage={emptyMessage} data={data}
31
+ columns={headers.map((header, index) => ({ key: String(index), header, render: row => row[index] }))} />
32
+ </PreviewScroll>
33
+ }
34
+
35
+ export function CsvMappingSelect<T extends object>(props: SelectProps<T>) {
36
+ return <Select {...props} appearance="filled" isLabelHidden rootClassName="w-full min-w-48" />
37
+ }
38
+
39
+ /** Original UI Button md (16/20 medium, 12x24 padding), without global button changes. */
40
+ export const CsvActionButton = DialogActionButton
@@ -34,7 +34,7 @@ export type DashboardGridProps = {
34
34
 
35
35
  export function DashboardGrid({ children, className }: DashboardGridProps) {
36
36
  return (
37
- <div className={['grid grid-cols-12 gap-4', className].filter(Boolean).join(' ')}>{children}</div>
37
+ <div className={['grid grid-cols-12 gap-3', className].filter(Boolean).join(' ')}>{children}</div>
38
38
  )
39
39
  }
40
40
 
@@ -42,9 +42,11 @@ const Chevron = ({ dir }: { dir: 'left' | 'right' }) => (
42
42
 
43
43
  const calendarCellClass =
44
44
  'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-small text-fg outline-none transition-colors ' +
45
- 'data-[outside-month]:text-fg-subtle data-[hovered]:bg-surface-muted ' +
45
+ // #25: out-of-month and unavailable days stay de-emphasised, but they are dates a
46
+ // user reads and (outside-month) can click — `fg-muted`, not the 2.10:1 decorative grey.
47
+ 'data-[outside-month]:text-fg-muted data-[hovered]:bg-surface-muted ' +
46
48
  'data-[selected]:bg-fg data-[selected]:text-fg-inverted data-[selected]:font-medium ' +
47
- 'data-[unavailable]:text-fg-subtle data-[unavailable]:line-through ' +
49
+ 'data-[unavailable]:text-fg-muted data-[unavailable]:line-through ' +
48
50
  'data-[disabled]:opacity-40 data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring'
49
51
 
50
52
  function CalendarBody() {
@@ -62,7 +64,7 @@ function CalendarBody() {
62
64
  <CalendarGrid className="w-full border-collapse">
63
65
  <CalendarGridHeader>
64
66
  {(day) => (
65
- <CalendarHeaderCell className="pb-1 text-micro font-medium uppercase tracking-wide text-fg-subtle">
67
+ <CalendarHeaderCell className="pb-1 text-micro font-medium uppercase tracking-wide text-fg-muted">
66
68
  {day}
67
69
  </CalendarHeaderCell>
68
70
  )}
@@ -0,0 +1,8 @@
1
+ import { DateSelectionCalendar } from './date-selection-calendar'
2
+ const props = { monthLabel: 'September 2026', weekdays: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], previousLabel: 'Previous month', nextLabel: 'Next month', onPrevious: () => {}, onNext: () => {}, onSelect: () => {}, weeks: [[null, ...Array.from({ length: 6 }, (_, i) => ({ id: String(i + 1), label: String(i + 1), accessibleLabel: 'September ' + (i + 1) + ', 2026', selected: i === 2, today: i === 4 }))]] }
3
+ export const examples = {
4
+ default: () => <DateSelectionCalendar {...props} />,
5
+ emptySelection: () => <DateSelectionCalendar {...props} weeks={props.weeks.map(week => week.map(day => day ? { ...day, selected: false } : null))} />,
6
+ states: () => <DateSelectionCalendar {...props} isDisabled />,
7
+ }
8
+ export const meta = { category: 'Form', description: 'Month navigation and date cells with selected, today, disabled and keyboard focus states.' }
@@ -0,0 +1,47 @@
1
+ import { Button as AriaButton } from 'react-aria-components'
2
+ import { uic } from '../utils/uic'
3
+
4
+ export const CalendarDialogContent = uic('div', { displayName: 'CalendarDialogContent', baseClass: 'flex flex-col gap-8' })
5
+ export const CalendarDateHint = uic('p', { displayName: 'CalendarDateHint', baseClass: 'm-0 min-h-9 text-small font-normal text-fg-workflow-muted', style: { maxWidth: '48ch' } })
6
+ const Frame = uic('div', { displayName: 'DateSelectionCalendar', baseClass: 'mt-6 overflow-hidden border border-border bg-surface' })
7
+ const Header = uic('div', { displayName: 'DateSelectionCalendarHeader', baseClass: 'flex min-h-15 items-center border-b border-border px-5' })
8
+ const Navigation = uic(AriaButton, { displayName: 'DateSelectionCalendarNavigation', baseClass: 'mx-1.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-sm bg-transparent text-2xl font-normal leading-none text-fg outline-none data-[hovered]:bg-surface-card data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring' })
9
+ const Month = uic('div', { displayName: 'DateSelectionCalendarMonth', baseClass: 'flex-1 text-center text-small font-normal text-fg' })
10
+ const Week = uic('div', { displayName: 'DateSelectionCalendarWeek', baseClass: 'grid grid-cols-7 border-b border-border last:border-b-0' })
11
+ const Weekday = uic('div', { displayName: 'DateSelectionCalendarWeekday', baseClass: 'border-e border-border px-3 pb-2 pt-4 text-center font-mono text-small font-normal tracking-normal text-fg-workflow-muted last:border-e-0' })
12
+ const Day = uic(AriaButton, { displayName: 'DateSelectionCalendarDay', baseClass: 'inline-flex min-h-16 w-full items-center justify-center rounded-none border-e border-border bg-transparent text-small font-normal text-fg outline-none last:border-e-0 data-[hovered]:bg-surface-card data-[focus-visible]:ring-2 data-[focus-visible]:ring-inset data-[focus-visible]:ring-ring data-[disabled]:cursor-not-allowed' })
13
+ const Placeholder = uic('div', { displayName: 'DateSelectionCalendarPlaceholder', baseClass: 'min-h-16 border-e border-border last:border-e-0' })
14
+
15
+ export interface DateSelectionCell {
16
+ id: string
17
+ label: string
18
+ accessibleLabel: string
19
+ selected: boolean
20
+ today: boolean
21
+ }
22
+ export function DateSelectionCalendar({ monthLabel, weekdays, weeks, previousLabel, nextLabel, onPrevious, onNext, onSelect, isDisabled = false, autoFocus = false }: {
23
+ monthLabel: string
24
+ weekdays: string[]
25
+ weeks: Array<Array<DateSelectionCell | null>>
26
+ previousLabel: string
27
+ nextLabel: string
28
+ onPrevious: () => void
29
+ onNext: () => void
30
+ onSelect: (id: string) => void
31
+ isDisabled?: boolean
32
+ autoFocus?: boolean
33
+ }) {
34
+ return <Frame>
35
+ <Header>
36
+ <Navigation aria-label={previousLabel} onPress={onPrevious} isDisabled={isDisabled} autoFocus={autoFocus}>‹</Navigation>
37
+ <Month aria-live="polite">{monthLabel}</Month>
38
+ <Navigation aria-label={nextLabel} onPress={onNext} isDisabled={isDisabled}>›</Navigation>
39
+ </Header>
40
+ <Week>{weekdays.map((label, index) => <Weekday key={index}>{label}</Weekday>)}</Week>
41
+ {weeks.map((week, index) => <Week key={index}>{week.map((day, column) => day
42
+ ? <Day key={day.id} aria-label={day.accessibleLabel} aria-pressed={day.selected} aria-current={day.today ? 'date' : undefined} isDisabled={isDisabled} onPress={() => onSelect(day.id)}
43
+ style={day.selected ? { boxShadow: 'inset 0 calc(var(--spacing) * -0.5) 0 var(--color-brand-green)' } : undefined}
44
+ className={day.selected ? 'bg-surface-card' : day.today ? 'bg-brand-green/14 ring-1 ring-inset ring-brand-green data-[hovered]:bg-brand-green/18' : undefined}>{day.label}</Day>
45
+ : <Placeholder key={`blank-${column}`} />)}</Week>)}
46
+ </Frame>
47
+ }
@@ -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
  }