@podoba/react 0.0.7 → 0.0.8

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,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
  }
@@ -0,0 +1,134 @@
1
+ // @app/ui — request-changes modal (issue 13).
2
+ //
3
+ // PRESENTATIONAL "request changes" dialog built on the @app/ui <Dialog>
4
+ // (React Aria Modal — focus trap, Esc, aria-modal). The note is REQUIRED:
5
+ // confirm stays DISABLED until the textarea holds a non-empty (trimmed) value,
6
+ // mirroring the server contract (`request_changes` 422s on an empty note).
7
+ //
8
+ // APP-AGNOSTIC (hard rule #1): every string is a prop and the action is
9
+ // delegated via `onConfirm(note)`. The owning surface in apps/web wires the
10
+ // i18n strings + the REST mutation + invalidation, and surfaces the auto-created
11
+ // follow-up `changes` task returned by the command.
12
+ //
13
+ // a11y: the textarea is label-associated (htmlFor/id), marked `required` +
14
+ // `aria-invalid` while empty; the server error is role="alert"; confirm shows a
15
+ // pending state and is disabled while in flight or while the note is empty.
16
+
17
+ import { useId, useState } from 'react'
18
+ import { Button } from './button'
19
+ import { Dialog } from './dialog'
20
+
21
+ /** Strings the request-changes modal renders — supplied by the app (i18n). */
22
+ export interface RequestChangesModalLabels {
23
+ /** Dialog heading. */
24
+ title: string
25
+ /** Explanatory body copy under the title. */
26
+ body: string
27
+ /** Label for the required note textarea. */
28
+ noteLabel: string
29
+ /** Placeholder for the note textarea. */
30
+ notePlaceholder: string
31
+ /** Cancel button. */
32
+ cancel: string
33
+ /** Confirm (request changes) button — idle state. */
34
+ confirm: string
35
+ /** Confirm button — pending state. */
36
+ submitting: string
37
+ }
38
+
39
+ export interface RequestChangesModalProps {
40
+ /** Controlled open state. */
41
+ isOpen: boolean
42
+ /** Notified on open/close (false on Esc / click-outside / cancel). */
43
+ onOpenChange: (isOpen: boolean) => void
44
+ /** Run the request_changes command with the (required, trimmed) note. */
45
+ onConfirm: (note: string) => void
46
+ /** Whether the confirm mutation is in flight (disables + shows pending). */
47
+ isPending?: boolean
48
+ /** Server / mutation error message to surface inline (role="alert"). */
49
+ error?: string
50
+ /** App-supplied i18n strings. */
51
+ labels: RequestChangesModalLabels
52
+ /** Optional test id forwarded to the dialog. */
53
+ 'data-testid'?: string
54
+ }
55
+
56
+ /** Request-changes modal with a REQUIRED note (confirm disabled until non-empty). */
57
+ export function RequestChangesModal({
58
+ isOpen,
59
+ onOpenChange,
60
+ onConfirm,
61
+ isPending = false,
62
+ error,
63
+ labels,
64
+ 'data-testid': testId,
65
+ }: RequestChangesModalProps): React.ReactNode {
66
+ const noteId = useId()
67
+ // CONTROLLED textarea: the confirm button's disabled state depends on whether
68
+ // the note is non-empty, so the value drives dependent UI (unlike approve).
69
+ const [note, setNote] = useState('')
70
+ const isEmpty = note.trim().length === 0
71
+ const canConfirm = !isEmpty && !isPending
72
+
73
+ const handleConfirm = (): void => {
74
+ const trimmed = note.trim()
75
+ if (trimmed.length === 0) return
76
+ onConfirm(trimmed)
77
+ }
78
+
79
+ return (
80
+ <Dialog
81
+ title={labels.title}
82
+ data-testid={testId ?? 'request-changes-modal'}
83
+ isOpen={isOpen}
84
+ onOpenChange={(open) => {
85
+ if (!open) setNote('')
86
+ onOpenChange(open)
87
+ }}
88
+ >
89
+ <div className="flex flex-col gap-3">
90
+ <p className="text-sm text-fg-muted">{labels.body}</p>
91
+ <label htmlFor={noteId} className="text-sm font-medium text-fg">
92
+ {labels.noteLabel}
93
+ </label>
94
+ <textarea
95
+ id={noteId}
96
+ data-testid="request-changes-note"
97
+ value={note}
98
+ onChange={(event) => setNote(event.target.value)}
99
+ maxLength={10_000}
100
+ rows={4}
101
+ required
102
+ aria-invalid={isEmpty ? true : undefined}
103
+ placeholder={labels.notePlaceholder}
104
+ className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus-visible:ring-2 focus-visible:ring-ring"
105
+ />
106
+ {error ? (
107
+ <p data-testid="request-changes-error" role="alert" className="text-sm text-danger">
108
+ {error}
109
+ </p>
110
+ ) : null}
111
+ <div className="mt-2 flex justify-end gap-2">
112
+ <Button
113
+ variant="secondary"
114
+ size="sm"
115
+ isDisabled={isPending}
116
+ onPress={() => onOpenChange(false)}
117
+ >
118
+ {labels.cancel}
119
+ </Button>
120
+ <Button
121
+ data-testid="request-changes-confirm"
122
+ variant="primary"
123
+ size="sm"
124
+ isDisabled={!canConfirm}
125
+ isPending={isPending}
126
+ onPress={handleConfirm}
127
+ >
128
+ {isPending ? labels.submitting : labels.confirm}
129
+ </Button>
130
+ </div>
131
+ </div>
132
+ </Dialog>
133
+ )
134
+ }
@@ -1,5 +1,6 @@
1
1
  import { type ClipboardEvent as ReactClipboardEvent, type ReactNode, useEffect, useRef } from 'react'
2
2
  import { clsx } from 'clsx'
3
+ import { useInFocusOverlay } from './focus-context'
3
4
 
4
5
  /**
5
6
  * RichTextEditor — a dependency-free contentEditable WYSIWYG that emits an HTML
@@ -85,6 +86,9 @@ export function RichTextEditor({
85
86
  }: RichTextEditorProps) {
86
87
  const ref = useRef<HTMLDivElement>(null)
87
88
  const last = useRef<string>('')
89
+ // In a focus overlay, give the body more room to write in.
90
+ const inFocus = useInFocusOverlay()
91
+ const bodyMinHeight = inFocus ? Math.max(minHeight, 360) : minHeight
88
92
 
89
93
  // Sync only EXTERNAL value changes into the DOM — never on our own keystrokes,
90
94
  // or the caret would jump to the start on every character.
@@ -142,7 +146,7 @@ export function RichTextEditor({
142
146
  <div
143
147
  ref={ref}
144
148
  className="prose prose-sm max-w-none px-4 py-3.5 text-sm leading-relaxed text-fg outline-none empty:before:text-fg-subtle empty:before:content-[attr(data-placeholder)]"
145
- style={{ minHeight }}
149
+ style={{ minHeight: bodyMinHeight }}
146
150
  contentEditable
147
151
  suppressContentEditableWarning
148
152
  data-placeholder={placeholder}
@@ -13,6 +13,7 @@ import {
13
13
  Text,
14
14
  } from 'react-aria-components'
15
15
  import { uic } from '../utils/uic'
16
+ import { useInFocusOverlay } from './focus-context'
16
17
 
17
18
  /**
18
19
  * Select — accessible dropdown built on React Aria Components `Select`.
@@ -74,41 +75,55 @@ export const Select = <T extends object>({
74
75
  placeholder,
75
76
  children,
76
77
  ...props
77
- }: SelectProps<T>) => (
78
- <RACSelect {...props} placeholder={placeholder} className="group flex flex-col gap-2">
79
- <Label className="text-heading5 font-medium text-fg">{label}</Label>
80
- <SelectTrigger>
81
- <SelectValue className="data-[placeholder]:text-fg-muted" />
82
- {/* gs chevron: 9.5px caret, dark (neutral-400 → fg), non-interactive. */}
83
- <svg
84
- width="9.5"
85
- height="9.5"
86
- viewBox="0 0 12 12"
87
- fill="none"
88
- aria-hidden="true"
89
- className="pointer-events-none shrink-0 text-fg"
90
- >
91
- <path
92
- d="M3 4.5L6 7.5L9 4.5"
93
- stroke="currentColor"
94
- strokeWidth="1.5"
95
- strokeLinecap="round"
96
- strokeLinejoin="round"
97
- />
98
- </svg>
99
- </SelectTrigger>
100
- {description ? (
101
- <Text slot="description" className="text-xs text-fg-muted">
102
- {description}
103
- </Text>
104
- ) : null}
105
- <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
106
- {/* Cream fill, 8px radius, shadow-lg, NO border. 4px inset so each option's
107
- highlight sits as a padded pill; small gap for an even list rhythm. */}
108
- <Popover className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg">
109
- <ListBox className="flex max-h-64 flex-col gap-0.5 overflow-auto overscroll-contain p-1 outline-none">
110
- {children}
111
- </ListBox>
112
- </Popover>
113
- </RACSelect>
114
- )
78
+ }: SelectProps<T>) => {
79
+ // In a focus overlay, show the options inline (seamless) instead of a popover.
80
+ const inFocus = useInFocusOverlay()
81
+ const listbox = (
82
+ <ListBox className="flex max-h-72 flex-col gap-0.5 overflow-auto overscroll-contain p-1 outline-none">
83
+ {children}
84
+ </ListBox>
85
+ )
86
+ const desc = description ? (
87
+ <Text slot="description" className="text-xs text-fg-muted">
88
+ {description}
89
+ </Text>
90
+ ) : null
91
+ const err = <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
92
+
93
+ return (
94
+ <RACSelect {...props} placeholder={placeholder} className="group flex flex-col gap-2">
95
+ <Label className="text-heading5 font-medium text-fg">{label}</Label>
96
+ {inFocus ? (
97
+ <>
98
+ {listbox}
99
+ {desc}
100
+ {err}
101
+ </>
102
+ ) : (
103
+ <>
104
+ <SelectTrigger>
105
+ <SelectValue className="data-[placeholder]:text-fg-muted" />
106
+ {/* gs chevron: 9.5px caret, dark (neutral-400 → fg), non-interactive. */}
107
+ <svg
108
+ width="9.5"
109
+ height="9.5"
110
+ viewBox="0 0 12 12"
111
+ fill="none"
112
+ aria-hidden="true"
113
+ className="pointer-events-none shrink-0 text-fg"
114
+ >
115
+ <path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
116
+ </svg>
117
+ </SelectTrigger>
118
+ {desc}
119
+ {err}
120
+ {/* Cream fill, 8px radius, shadow-lg, NO border. 4px inset so each option's
121
+ highlight sits as a padded pill; small gap for an even list rhythm. */}
122
+ <Popover className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg">
123
+ {listbox}
124
+ </Popover>
125
+ </>
126
+ )}
127
+ </RACSelect>
128
+ )
129
+ }