@podoba/react 0.0.7 → 0.0.9

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.
@@ -0,0 +1,216 @@
1
+ // @app/ui — send-to-print modal (issue 14).
2
+ //
3
+ // PRESENTATIONAL print-order dialog built on the @app/ui <Dialog> (React Aria
4
+ // Modal — focus trap, Esc, aria-modal). Collects the print spec (size,
5
+ // material, quantity, delivery address) and, on confirm, the owning surface in
6
+ // apps/web creates a `print` delivery job.
7
+ //
8
+ // VALIDATION mirrors the smart-document `print_specs` + `delivery_output`
9
+ // block rules (docs/GOVERNANCE/tasks-delivery.md / the issue-06 validator):
10
+ // * size — non-blank (trimmed),
11
+ // * material — non-blank (trimmed),
12
+ // * quantity — a finite integer strictly > 0,
13
+ // * address — non-blank (trimmed, the delivery_output address field).
14
+ // Confirm stays DISABLED until every field is valid; per-field errors are shown
15
+ // inline (the quantity input carries aria-invalid + a role="alert" message).
16
+ //
17
+ // APP-AGNOSTIC (hard rule #1): every string is a prop; the action is delegated
18
+ // via `onConfirm(spec)`; apps/web wires i18n + the REST mutation + invalidation.
19
+ //
20
+ // a11y: every field is label-associated (Input / RAC TextField); the quantity
21
+ // field is aria-invalid when invalid; the server error is role="alert"; confirm
22
+ // shows a pending state and disables while in flight / invalid.
23
+
24
+ import { useMemo, useState } from 'react'
25
+ import { Button } from '../button'
26
+ import { Dialog } from '../dialog'
27
+ import { Input } from '../input'
28
+
29
+ /** The validated print spec the modal yields on confirm. */
30
+ export interface PrintSpec {
31
+ /** Print size (e.g. "A2", "1000×700mm") — non-blank. */
32
+ size: string
33
+ /** Material / stock (e.g. "Matte 250g") — non-blank. */
34
+ material: string
35
+ /** Quantity — a finite integer > 0. */
36
+ quantity: number
37
+ /** Delivery address — non-blank. */
38
+ address: string
39
+ }
40
+
41
+ /** Strings the send-to-print modal renders — supplied by the app (i18n). */
42
+ export interface SendToPrintModalLabels {
43
+ /** Dialog heading. */
44
+ title: string
45
+ /** Explanatory body copy under the title. */
46
+ body: string
47
+ /** Size field label + placeholder. */
48
+ sizeLabel: string
49
+ sizePlaceholder: string
50
+ /** Material field label + placeholder. */
51
+ materialLabel: string
52
+ materialPlaceholder: string
53
+ /** Quantity field label + placeholder. */
54
+ quantityLabel: string
55
+ quantityPlaceholder: string
56
+ /** Inline error shown when the quantity is not a finite integer > 0. */
57
+ quantityError: string
58
+ /** Address field label + placeholder. */
59
+ addressLabel: string
60
+ addressPlaceholder: string
61
+ /** Cancel button. */
62
+ cancel: string
63
+ /** Confirm (create print job) button — idle state. */
64
+ confirm: string
65
+ /** Confirm button — pending state. */
66
+ submitting: string
67
+ }
68
+
69
+ export interface SendToPrintModalProps {
70
+ /** Controlled open state. */
71
+ isOpen: boolean
72
+ /** Notified on open/close (false on Esc / click-outside / cancel). */
73
+ onOpenChange: (isOpen: boolean) => void
74
+ /** Create the `print` delivery job with the validated spec. */
75
+ onConfirm: (spec: PrintSpec) => void
76
+ /** Whether the confirm mutation is in flight (disables + shows pending). */
77
+ isPending?: boolean
78
+ /** Server / mutation error message to surface inline (role="alert"). */
79
+ error?: string
80
+ /** App-supplied i18n strings. */
81
+ labels: SendToPrintModalLabels
82
+ /** Optional test id forwarded to the dialog. */
83
+ 'data-testid'?: string
84
+ }
85
+
86
+ /** Parse a raw quantity string into a finite integer > 0, or null when invalid. */
87
+ function parseQuantity(raw: string): number | null {
88
+ const trimmed = raw.trim()
89
+ if (trimmed.length === 0) return null
90
+ const value = Number(trimmed)
91
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) return null
92
+ return value
93
+ }
94
+
95
+ /** Send-to-print modal with validated size / material / quantity / address. */
96
+ export function SendToPrintModal({
97
+ isOpen,
98
+ onOpenChange,
99
+ onConfirm,
100
+ isPending = false,
101
+ error,
102
+ labels,
103
+ 'data-testid': testId,
104
+ }: SendToPrintModalProps): React.ReactNode {
105
+ const [size, setSize] = useState('')
106
+ const [material, setMaterial] = useState('')
107
+ const [quantityRaw, setQuantityRaw] = useState('')
108
+ const [address, setAddress] = useState('')
109
+
110
+ const quantity = useMemo(() => parseQuantity(quantityRaw), [quantityRaw])
111
+ // Surface the quantity error only once the user has typed something invalid
112
+ // (not on the initial blank state — that would scream before any input).
113
+ const quantityInvalid = quantityRaw.trim().length > 0 && quantity === null
114
+
115
+ const reset = (): void => {
116
+ setSize('')
117
+ setMaterial('')
118
+ setQuantityRaw('')
119
+ setAddress('')
120
+ }
121
+
122
+ const canConfirm =
123
+ !isPending &&
124
+ size.trim().length > 0 &&
125
+ material.trim().length > 0 &&
126
+ quantity !== null &&
127
+ address.trim().length > 0
128
+
129
+ const handleOpenChange = (open: boolean): void => {
130
+ if (!open) reset()
131
+ onOpenChange(open)
132
+ }
133
+
134
+ const handleConfirm = (): void => {
135
+ if (!canConfirm || quantity === null) return
136
+ onConfirm({
137
+ size: size.trim(),
138
+ material: material.trim(),
139
+ quantity,
140
+ address: address.trim(),
141
+ })
142
+ }
143
+
144
+ return (
145
+ <Dialog
146
+ title={labels.title}
147
+ data-testid={testId ?? 'delivery-print-modal'}
148
+ isOpen={isOpen}
149
+ onOpenChange={handleOpenChange}
150
+ >
151
+ <div className="flex flex-col gap-3">
152
+ <p className="text-sm 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-sm 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>
215
+ )
216
+ }
@@ -1,6 +1,7 @@
1
1
  import { type ReactNode, useState } from 'react'
2
2
  import { DropZone, FileTrigger, Text } from 'react-aria-components'
3
3
  import { Button } from './button'
4
+ import { useInFocusOverlay } from './focus-context'
4
5
 
5
6
  /**
6
7
  * FileUpload — a drop zone + "choose file" trigger over React Aria Components
@@ -21,6 +22,7 @@ export type FileUploadProps = {
21
22
 
22
23
  export const FileUpload = ({ label, description, accept, allowsMultiple, onFiles, className }: FileUploadProps) => {
23
24
  const [names, setNames] = useState<string[]>([])
25
+ const inFocus = useInFocusOverlay()
24
26
 
25
27
  const handle = (files: File[]) => {
26
28
  if (files.length === 0) return
@@ -38,7 +40,8 @@ export const FileUpload = ({ label, description, accept, allowsMultiple, onFiles
38
40
  handle(allowsMultiple ? files : files.slice(0, 1))
39
41
  }}
40
42
  className={
41
- 'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-surface p-6 text-center outline-none transition-colors ' +
43
+ 'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-surface text-center outline-none transition-colors ' +
44
+ (inFocus ? 'min-h-64 p-12 ' : 'p-6 ') +
42
45
  'data-[hovered]:border-fg-subtle ' +
43
46
  'data-[drop-target]:border-brand-green data-[drop-target]:bg-surface-card ' +
44
47
  'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring'
@@ -0,0 +1,11 @@
1
+ import { createContext, useContext } from 'react'
2
+
3
+ /**
4
+ * Signals that a field is being rendered inside an active FocusField overlay, so
5
+ * the field can present an enhanced "focus" view (e.g. DatePicker shows the
6
+ * calendar inline/open instead of behind a popover). Default `false`.
7
+ */
8
+ export const FocusOverlayContext = createContext(false)
9
+
10
+ /** True when a field is rendered inside an active FocusField overlay. */
11
+ export const useInFocusOverlay = (): boolean => useContext(FocusOverlayContext)
@@ -0,0 +1,213 @@
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useId,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from 'react'
12
+ import { createPortal } from 'react-dom'
13
+ import { clsx } from 'clsx'
14
+ import { FocusOverlayContext } from './focus-context'
15
+
16
+ /**
17
+ * FocusFields + FocusField — immersive focus-mode field editing.
18
+ *
19
+ * Ported from gs-platform's FormWithPreview overlay pattern. Wrap a form in
20
+ * `<FocusFields>`; each `<FocusField>` renders as a compact card (icon · label ·
21
+ * value preview). Clicking one enters focus mode: the rest of the form blurs and
22
+ * dims, and the field's editor (`children`) takes over the form area at a large
23
+ * size. Esc or a click on the backdrop closes it.
24
+ *
25
+ * Generic by design — `children` is any editor (an input, textarea, select…). The
26
+ * card owns the label, so pass a *label-less* control as children. Text controls
27
+ * are auto-enlarged and stripped to a bare headline look in the overlay.
28
+ */
29
+
30
+ type FocusCtx = {
31
+ activeId: string | null
32
+ activate: (id: string) => void
33
+ close: () => void
34
+ host: HTMLElement | null
35
+ }
36
+
37
+ const FocusFieldsContext = createContext<FocusCtx | null>(null)
38
+
39
+ function useFocusFields(): FocusCtx {
40
+ const ctx = useContext(FocusFieldsContext)
41
+ if (!ctx) throw new Error('<FocusField> must be rendered inside <FocusFields>')
42
+ return ctx
43
+ }
44
+
45
+ export function FocusFields({ children, className }: { children: ReactNode; className?: string }) {
46
+ const [activeId, setActiveId] = useState<string | null>(null)
47
+ const [host, setHost] = useState<HTMLDivElement | null>(null)
48
+ const close = useCallback(() => setActiveId(null), [])
49
+ const activate = useCallback((id: string) => setActiveId(id), [])
50
+
51
+ useEffect(() => {
52
+ if (!activeId) return
53
+ const onKey = (e: KeyboardEvent) => {
54
+ if (e.key === 'Escape') close()
55
+ }
56
+ document.addEventListener('keydown', onKey)
57
+ return () => document.removeEventListener('keydown', onKey)
58
+ }, [activeId, close])
59
+
60
+ const ctx = useMemo<FocusCtx>(() => ({ activeId, activate, close, host }), [activeId, activate, close, host])
61
+
62
+ return (
63
+ <FocusFieldsContext.Provider value={ctx}>
64
+ <div className={clsx('relative', className)}>
65
+ <div
66
+ className={clsx(
67
+ 'transition-[filter,opacity] duration-200',
68
+ activeId && 'pointer-events-none select-none opacity-40 blur-[3px]',
69
+ )}
70
+ aria-hidden={activeId ? true : undefined}
71
+ >
72
+ {children}
73
+ </div>
74
+ {activeId ? (
75
+ // Backdrop over the form; the portalled editor sits at the top and
76
+ // stops propagation so only clicks outside it close focus mode.
77
+ <div className="absolute inset-0 z-20 overflow-auto" onClick={close}>
78
+ <div ref={setHost} onClick={(e) => e.stopPropagation()} />
79
+ </div>
80
+ ) : null}
81
+ </div>
82
+ </FocusFieldsContext.Provider>
83
+ )
84
+ }
85
+
86
+ // In the overlay, enlarge and de-chrome text controls into a bare headline editor.
87
+ const OVERLAY_EDITOR =
88
+ '[&_input]:w-full [&_input]:border-0 [&_input]:bg-transparent [&_input]:p-0 [&_input]:text-3xl [&_input]:font-medium [&_input]:text-fg [&_input]:outline-none [&_input]:shadow-none [&_input]:ring-0 [&_input]:placeholder:text-fg-subtle ' +
89
+ '[&_textarea]:min-h-40 [&_textarea]:w-full [&_textarea]:resize-none [&_textarea]:border-0 [&_textarea]:bg-transparent [&_textarea]:p-0 [&_textarea]:text-3xl [&_textarea]:font-medium [&_textarea]:text-fg [&_textarea]:outline-none [&_textarea]:placeholder:text-fg-subtle'
90
+
91
+ function IconBadge({ icon }: { icon: ReactNode }) {
92
+ return (
93
+ <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-surface-muted text-sm font-medium text-fg">
94
+ {icon}
95
+ </div>
96
+ )
97
+ }
98
+
99
+ export type FocusFieldProps = {
100
+ /** Field label (shown on the card and above the overlay editor). */
101
+ label: ReactNode
102
+ /** Optional leading icon (e.g. a "T" for text). */
103
+ icon?: ReactNode
104
+ /** Current value shown on the collapsed card. */
105
+ preview?: ReactNode
106
+ /** Shown on the card when there's no value. */
107
+ placeholder?: ReactNode
108
+ /** The editor, shown in the overlay when focused. */
109
+ children: ReactNode
110
+ /**
111
+ * How the editor is presented in the overlay:
112
+ * - `"text"` (default): treat `children` as a bare text control and enlarge it
113
+ * to a headline; FocusField supplies the label.
114
+ * - `"control"`: render `children` (a self-labelled field component) as-is, with
115
+ * no enlargement and no FocusField label (the control provides its own).
116
+ */
117
+ editor?: 'text' | 'control'
118
+ className?: string
119
+ }
120
+
121
+ export function FocusField({
122
+ label,
123
+ icon,
124
+ preview,
125
+ placeholder,
126
+ children,
127
+ editor = 'text',
128
+ className,
129
+ }: FocusFieldProps) {
130
+ const ctx = useFocusFields()
131
+ const id = useId()
132
+ const active = ctx.activeId === id
133
+ const contentRef = useRef<HTMLDivElement>(null)
134
+
135
+ // Move focus into the editor once it's mounted (the portal host appears a
136
+ // render after activation, so wait for ctx.host before focusing).
137
+ useEffect(() => {
138
+ if (!active || !ctx.host) return
139
+ const el = contentRef.current?.querySelector<HTMLElement>('input, textarea, [contenteditable="true"]')
140
+ el?.focus()
141
+ }, [active, ctx.host])
142
+
143
+ const hasValue = preview !== undefined && preview !== null && preview !== ''
144
+
145
+ return (
146
+ <>
147
+ <div
148
+ role="button"
149
+ tabIndex={0}
150
+ aria-label={typeof label === 'string' ? label : undefined}
151
+ onClick={() => ctx.activate(id)}
152
+ onKeyDown={(e) => {
153
+ if (e.key === 'Enter' || e.key === ' ') {
154
+ e.preventDefault()
155
+ ctx.activate(id)
156
+ }
157
+ }}
158
+ className={clsx(
159
+ // Field-group divider: a bottom border on each row, none on the last.
160
+ 'flex w-full cursor-text items-start gap-3 border-b border-border p-3 text-left outline-none transition-colors last:border-b-0',
161
+ 'hover:bg-surface-card focus-visible:ring-2 focus-visible:ring-ring',
162
+ className,
163
+ )}
164
+ >
165
+ {icon ? <IconBadge icon={icon} /> : null}
166
+ <div className="min-w-0 flex-1">
167
+ <div className="text-sm text-fg-muted">{label}</div>
168
+ <div className="mt-1 truncate text-base text-fg">
169
+ {hasValue ? preview : <span className="text-fg-subtle">{placeholder}</span>}
170
+ </div>
171
+ </div>
172
+ </div>
173
+ {active && ctx.host
174
+ ? createPortal(
175
+ // No card/shadow: a full-bleed white area at the top that gradient-fades
176
+ // to transparent, so it melts into the blurred form below.
177
+ <div
178
+ ref={contentRef}
179
+ className="relative min-h-[60vh] w-full bg-gradient-to-b from-surface from-0% via-surface via-65% to-transparent px-3 pt-3"
180
+ >
181
+ <button
182
+ type="button"
183
+ onClick={ctx.close}
184
+ aria-label="Close"
185
+ className="absolute right-3 top-3 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"
186
+ >
187
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
188
+ <path d="M6 6l12 12M18 6 6 18" />
189
+ </svg>
190
+ </button>
191
+ {/* Tell field components they're in a focus overlay so they can
192
+ render an enhanced view (e.g. an inline open calendar). */}
193
+ <FocusOverlayContext.Provider value={true}>
194
+ {editor === 'control' ? (
195
+ // Self-labelled field component — render it as-is (enhanced by context).
196
+ <div className="max-w-md pr-10">{children}</div>
197
+ ) : (
198
+ <div className="flex items-start gap-3">
199
+ {icon ? <IconBadge icon={icon} /> : null}
200
+ <div className="min-w-0 flex-1 pr-10">
201
+ <div className="text-sm text-fg-muted">{label}</div>
202
+ <div className={OVERLAY_EDITOR}>{children}</div>
203
+ </div>
204
+ </div>
205
+ )}
206
+ </FocusOverlayContext.Provider>
207
+ </div>,
208
+ ctx.host,
209
+ )
210
+ : null}
211
+ </>
212
+ )
213
+ }
@@ -13,6 +13,7 @@ import {
13
13
  } from 'react-aria-components'
14
14
  import { clsx } from 'clsx'
15
15
  import { uic } from '../utils/uic'
16
+ import { useInFocusOverlay } from './focus-context'
16
17
 
17
18
  /**
18
19
  * MultiSelect — a dropdown that selects several options at once.
@@ -96,6 +97,7 @@ export const MultiSelect = ({
96
97
  const triggerRef = useRef<HTMLButtonElement>(null)
97
98
  const [open, setOpen] = useState(false)
98
99
  const { contains } = useFilter({ sensitivity: 'base' })
100
+ const inFocus = useInFocusOverlay()
99
101
  const [internal, setInternal] = useState<Set<string>>(() => new Set(defaultSelectedKeys ?? []))
100
102
  const selected = selectedKeys ?? internal
101
103
 
@@ -126,48 +128,64 @@ export const MultiSelect = ({
126
128
  </ListBox>
127
129
  )
128
130
 
131
+ const listContent = searchable ? (
132
+ <Autocomplete filter={contains}>
133
+ <SearchField aria-label="Filter options" autoFocus className="border-b border-border p-1">
134
+ <RACInput
135
+ placeholder="Search…"
136
+ className="w-full rounded-md bg-surface px-3 py-2 text-sm text-fg outline-none placeholder:text-fg-muted"
137
+ />
138
+ </SearchField>
139
+ {list}
140
+ </Autocomplete>
141
+ ) : (
142
+ list
143
+ )
144
+
145
+ const desc = description ? <span className="text-xs text-fg-muted">{description}</span> : null
146
+ const err = isInvalid && errorMessage ? <span className="text-xs text-danger">{errorMessage}</span> : null
147
+
129
148
  return (
130
149
  <div className={clsx('flex flex-col gap-2', className)}>
131
150
  <span id={labelId} className="text-heading5 font-medium text-fg">
132
151
  {label}
133
152
  </span>
134
- <RACButton
135
- ref={triggerRef}
136
- aria-labelledby={labelId}
137
- isDisabled={isDisabled}
138
- onPress={() => setOpen(true)}
139
- className={clsx(
140
- 'flex h-12 w-full items-center justify-between gap-2.5 rounded-lg border bg-surface px-4 text-sm text-fg outline-none transition-colors',
141
- 'data-[hovered]:border-fg-subtle data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring',
142
- 'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
143
- isInvalid ? 'border-danger ring-2 ring-danger' : 'border-border',
144
- )}
145
- >
146
- <span className={clsx('truncate', chosen.length === 0 && 'text-fg-muted')}>{summary}</span>
147
- <Chevron />
148
- </RACButton>
149
- {description ? <span className="text-xs text-fg-muted">{description}</span> : null}
150
- {isInvalid && errorMessage ? <span className="text-xs text-danger">{errorMessage}</span> : null}
151
- <Popover
152
- triggerRef={triggerRef}
153
- isOpen={open}
154
- onOpenChange={setOpen}
155
- className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg"
156
- >
157
- {searchable ? (
158
- <Autocomplete filter={contains}>
159
- <SearchField aria-label="Filter options" autoFocus className="border-b border-border p-1">
160
- <RACInput
161
- placeholder="Search…"
162
- className="w-full rounded-md bg-surface px-3 py-2 text-sm text-fg outline-none placeholder:text-fg-muted"
163
- />
164
- </SearchField>
165
- {list}
166
- </Autocomplete>
167
- ) : (
168
- list
169
- )}
170
- </Popover>
153
+ {inFocus ? (
154
+ // Seamless: no trigger/popover — the checklist sits inline on the panel.
155
+ <>
156
+ {listContent}
157
+ {desc}
158
+ {err}
159
+ </>
160
+ ) : (
161
+ <>
162
+ <RACButton
163
+ ref={triggerRef}
164
+ aria-labelledby={labelId}
165
+ isDisabled={isDisabled}
166
+ onPress={() => setOpen(true)}
167
+ className={clsx(
168
+ 'flex h-12 w-full items-center justify-between gap-2.5 rounded-lg border bg-surface px-4 text-sm text-fg outline-none transition-colors',
169
+ 'data-[hovered]:border-fg-subtle data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring',
170
+ 'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
171
+ isInvalid ? 'border-danger ring-2 ring-danger' : 'border-border',
172
+ )}
173
+ >
174
+ <span className={clsx('truncate', chosen.length === 0 && 'text-fg-muted')}>{summary}</span>
175
+ <Chevron />
176
+ </RACButton>
177
+ {desc}
178
+ {err}
179
+ <Popover
180
+ triggerRef={triggerRef}
181
+ isOpen={open}
182
+ onOpenChange={setOpen}
183
+ className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg"
184
+ >
185
+ {listContent}
186
+ </Popover>
187
+ </>
188
+ )}
171
189
  </div>
172
190
  )
173
191
  }