@duro-app/ui 0.35.2 → 0.35.4

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 (36) hide show
  1. package/dist/components/ColorInput/ColorInput.d.ts +26 -0
  2. package/dist/components/ColorInput/ColorInput.d.ts.map +1 -0
  3. package/dist/components/ColorInput/ColorInput.meta.d.ts +3 -0
  4. package/dist/components/ColorInput/ColorInput.meta.d.ts.map +1 -0
  5. package/dist/components/ColorInput/styles.css.d.ts +26 -0
  6. package/dist/components/ColorInput/styles.css.d.ts.map +1 -0
  7. package/dist/components/ConfirmDialog/ConfirmDialog.d.ts +39 -0
  8. package/dist/components/ConfirmDialog/ConfirmDialog.d.ts.map +1 -0
  9. package/dist/components/ConfirmDialog/ConfirmDialog.meta.d.ts +3 -0
  10. package/dist/components/ConfirmDialog/ConfirmDialog.meta.d.ts.map +1 -0
  11. package/dist/components/Input/Input.d.ts +20 -1
  12. package/dist/components/Input/Input.d.ts.map +1 -1
  13. package/dist/components/Input/styles.css.d.ts +3 -0
  14. package/dist/components/Input/styles.css.d.ts.map +1 -1
  15. package/dist/components/Select/Select.d.ts.map +1 -1
  16. package/dist/components/Select/Select.stories.d.ts +1 -0
  17. package/dist/components/Select/Select.stories.d.ts.map +1 -1
  18. package/dist/components/Select/styles.css.d.ts +10 -4
  19. package/dist/components/Select/styles.css.d.ts.map +1 -1
  20. package/dist/index.css +1 -1
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2910 -2749
  24. package/dist/index.js.map +1 -1
  25. package/package.json +2 -2
  26. package/src/components/ColorInput/ColorInput.meta.ts +22 -0
  27. package/src/components/ColorInput/ColorInput.tsx +73 -0
  28. package/src/components/ColorInput/styles.css.ts +40 -0
  29. package/src/components/ConfirmDialog/ConfirmDialog.meta.ts +37 -0
  30. package/src/components/ConfirmDialog/ConfirmDialog.tsx +99 -0
  31. package/src/components/Input/Input.tsx +51 -5
  32. package/src/components/Input/styles.css.ts +5 -0
  33. package/src/components/Select/Select.stories.tsx +59 -7
  34. package/src/components/Select/Select.tsx +54 -5
  35. package/src/components/Select/styles.css.ts +16 -4
  36. package/src/index.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "0.35.2",
3
+ "version": "0.35.4",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -54,7 +54,7 @@
54
54
  }
55
55
  },
56
56
  "dependencies": {
57
- "@duro-app/tokens": "^0.35.2"
57
+ "@duro-app/tokens": "^0.35.4"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@babel/preset-typescript": "^7.28.0",
@@ -0,0 +1,22 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'A styled native color swatch (<input type="color">) for picking a hex color. Field-context aware like Input.',
6
+ whenToUse: [
7
+ 'Picking an accent/brand/category color in a form (e.g. a zone or tag color)',
8
+ 'Inside a Field.Root, where it adopts the label, invalid state, and disabled',
9
+ ],
10
+ whenNotToUse: [
11
+ 'Free-text hex entry with validation — use Input with a pattern instead',
12
+ 'A full palette/gradient picker — out of scope; this is the native swatch',
13
+ ],
14
+ relatedTo: [
15
+ {component: 'Input', relationship: 'Sibling form control; same Field-context contract'},
16
+ {component: 'Field', relationship: 'Wrap in Field.Root for label + error wiring'},
17
+ ],
18
+ example: `<Field.Root>
19
+ <Field.Label>Accent</Field.Label>
20
+ <ColorInput value={accent} onChange={(e) => setAccent(e.target.value)} />
21
+ </Field.Root>`,
22
+ }
@@ -0,0 +1,73 @@
1
+ import {html} from 'react-strict-dom'
2
+ import {useFieldContext} from '../Field/FieldContext'
3
+ import {styles} from './styles.css'
4
+
5
+ interface ColorInputProps {
6
+ /** Hex color, controlled (e.g. "#cfc8ba"). */
7
+ value?: string
8
+ defaultValue?: string
9
+ name?: string
10
+ /** Explicit id — overrides the Field-context controlId when set. */
11
+ id?: string
12
+ disabled?: boolean
13
+ required?: boolean
14
+ /** Accessible name when there is no associated <Field.Label>. */
15
+ 'aria-label'?: string
16
+ onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
17
+ onBlur?: () => void
18
+ ref?: React.Ref<HTMLInputElement>
19
+ }
20
+
21
+ /**
22
+ * A styled native `<input type="color">` swatch. Mirrors <Input>'s native
23
+ * surface (value/defaultValue/name/onChange/ref/disabled/required) and adopts
24
+ * the parent <Field.Root> context (controlId for label association, value/
25
+ * onChange when bound to a Form, invalid border, disabled) — so it drops into a
26
+ * <Field> exactly like <Input>. The text <Input> can't render type=color, hence
27
+ * a dedicated leaf.
28
+ */
29
+ export function ColorInput({
30
+ value,
31
+ defaultValue,
32
+ name,
33
+ id,
34
+ disabled,
35
+ required,
36
+ 'aria-label': ariaLabel,
37
+ onChange,
38
+ onBlur,
39
+ ref,
40
+ }: ColorInputProps) {
41
+ const ctx = useFieldContext()
42
+ const fieldCtx = ctx?.field
43
+
44
+ const effectiveName = name ?? fieldCtx?.name
45
+ const effectiveValue = value ?? (fieldCtx ? String(fieldCtx.value ?? '') : undefined)
46
+ const effectiveOnChange = onChange ?? fieldCtx?.onChange
47
+ const effectiveOnBlur = onBlur ?? fieldCtx?.onBlur
48
+ const effectiveRef = ref ?? fieldCtx?.ref
49
+ const effectiveDisabled = disabled ?? ctx?.disabled
50
+
51
+ return (
52
+ <html.input
53
+ type="color"
54
+ id={id ?? ctx?.controlId}
55
+ name={effectiveName}
56
+ value={effectiveValue}
57
+ defaultValue={defaultValue}
58
+ disabled={effectiveDisabled}
59
+ required={required}
60
+ aria-label={ariaLabel}
61
+ aria-invalid={ctx?.invalid || undefined}
62
+ aria-describedby={
63
+ ctx
64
+ ? `${ctx.descriptionId} ${ctx.invalid ? ctx.errorId : ''}`.trim() || undefined
65
+ : undefined
66
+ }
67
+ onChange={effectiveOnChange as React.ComponentProps<typeof html.input>['onChange']}
68
+ onBlur={effectiveOnBlur as React.ComponentProps<typeof html.input>['onBlur']}
69
+ ref={effectiveRef as React.Ref<HTMLInputElement>}
70
+ style={[styles.base, ctx?.invalid && styles.invalid, effectiveDisabled && styles.disabled]}
71
+ />
72
+ )
73
+ }
@@ -0,0 +1,40 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {radii} from '@duro-app/tokens/tokens/spacing.css'
4
+
5
+ export const styles = css.create({
6
+ base: {
7
+ width: 44,
8
+ height: 34,
9
+ padding: 2,
10
+ backgroundColor: colors.bg,
11
+ borderWidth: 1,
12
+ borderStyle: 'solid',
13
+ borderColor: {
14
+ default: colors.border,
15
+ ':hover': colors.textMuted,
16
+ },
17
+ borderRadius: radii.sm,
18
+ cursor: 'pointer',
19
+ outlineWidth: {
20
+ default: 0,
21
+ ':focus-visible': 2,
22
+ },
23
+ outlineStyle: {
24
+ default: 'none',
25
+ ':focus-visible': 'solid',
26
+ },
27
+ outlineColor: {
28
+ default: 'transparent',
29
+ ':focus-visible': colors.accent,
30
+ },
31
+ outlineOffset: 1,
32
+ },
33
+ invalid: {
34
+ borderColor: colors.error,
35
+ },
36
+ disabled: {
37
+ cursor: 'not-allowed',
38
+ opacity: 0.5,
39
+ },
40
+ })
@@ -0,0 +1,37 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Destructive-confirmation dialog with an optional type-a-phrase gate. DS owns layout + gating; the app owns the submit (confirmSlot) so it composes with form libraries.',
6
+ whenToUse: [
7
+ 'Confirming an irreversible action (delete org, delete account, drop a record with cascades)',
8
+ 'High-impact actions that should require typing a phrase (the slug/name) before enabling confirm',
9
+ ],
10
+ whenNotToUse: [
11
+ 'Routine confirmations — use a Dialog with a plain confirm button',
12
+ 'Non-destructive flows — a Dialog or inline action is enough',
13
+ ],
14
+ anatomy: {
15
+ required: ['title', 'children (warning body)'],
16
+ optional: ['confirmPhrase', 'promptLabel', 'confirmSlot', 'onConfirm', 'confirmLabel'],
17
+ },
18
+ relatedTo: [
19
+ {component: 'Dialog', relationship: 'Built on Dialog; this adds the phrase gate + footer'},
20
+ {component: 'Button', relationship: 'The confirm/cancel actions'},
21
+ ],
22
+ example: `<ConfirmDialog
23
+ open={open}
24
+ onOpenChange={setOpen}
25
+ title="Delete organization?"
26
+ confirmPhrase={org.name}
27
+ promptLabel={<>Type <b>{org.name}</b> to confirm</>}
28
+ confirmSlot={({satisfied}) => (
29
+ <fetcher.Form method="post">
30
+ <input type="hidden" name="intent" value="delete-org" />
31
+ <Button type="submit" variant="danger" disabled={!satisfied}>Delete</Button>
32
+ </fetcher.Form>
33
+ )}
34
+ >
35
+ This deletes the org and all its landscapes. This cannot be undone.
36
+ </ConfirmDialog>`,
37
+ }
@@ -0,0 +1,99 @@
1
+ import {type ReactNode, useState} from 'react'
2
+ import {Dialog} from '../Dialog/Dialog'
3
+ import {Button} from '../Button/Button'
4
+ import {Input} from '../Input/Input'
5
+ import {Field} from '../Field/Field'
6
+
7
+ type DialogSize = 'sm' | 'md' | 'lg'
8
+
9
+ interface ConfirmDialogProps {
10
+ open: boolean
11
+ onOpenChange: (open: boolean) => void
12
+ title: ReactNode
13
+ /** Warning / cascade body. */
14
+ children?: ReactNode
15
+ /** When set, the confirm action stays DISABLED until the user types this
16
+ * exact phrase (org name, slug…). The typed value is a client-side gate
17
+ * only — it is never submitted. */
18
+ confirmPhrase?: string
19
+ /** Prompt shown above the phrase input. */
20
+ promptLabel?: ReactNode
21
+ cancelLabel?: ReactNode
22
+ /**
23
+ * App-owned confirm control. Receives whether the phrase gate is satisfied,
24
+ * so the app can render its OWN <Button type="submit"> INSIDE its form
25
+ * (e.g. a react-router fetcher.Form with hidden intent fields) — the DS does
26
+ * NOT own the submit, avoiding a fight with the app's form flow. Takes
27
+ * precedence over onConfirm/confirmLabel.
28
+ */
29
+ confirmSlot?: (gate: {satisfied: boolean}) => ReactNode
30
+ /** Simple case (no app form): the DS renders a danger Button calling this. */
31
+ onConfirm?: () => void
32
+ confirmLabel?: ReactNode
33
+ size?: DialogSize
34
+ }
35
+
36
+ /**
37
+ * Generic destructive-confirmation dialog: layout + an optional type-a-phrase
38
+ * gate. The DS owns the shell and the gate; the APP owns the actual submit
39
+ * (via `confirmSlot`) so it composes with form libraries instead of forcing a
40
+ * callback. Domain warning text goes in `children`.
41
+ */
42
+ export function ConfirmDialog({
43
+ open,
44
+ onOpenChange,
45
+ title,
46
+ children,
47
+ confirmPhrase,
48
+ promptLabel,
49
+ cancelLabel = 'Cancel',
50
+ confirmSlot,
51
+ onConfirm,
52
+ confirmLabel = 'Confirm',
53
+ size = 'sm',
54
+ }: ConfirmDialogProps) {
55
+ const [typed, setTyped] = useState('')
56
+ const satisfied = !confirmPhrase || typed.trim() === confirmPhrase
57
+
58
+ const handleOpenChange = (next: boolean) => {
59
+ if (!next) setTyped('') // reset the gate when the dialog closes
60
+ onOpenChange(next)
61
+ }
62
+
63
+ return (
64
+ <Dialog.Root open={open} onOpenChange={handleOpenChange}>
65
+ <Dialog.Portal size={size}>
66
+ <Dialog.Header>
67
+ <Dialog.Title>{title}</Dialog.Title>
68
+ </Dialog.Header>
69
+ <Dialog.Body>
70
+ {children}
71
+ {confirmPhrase ? (
72
+ <Field.Root>
73
+ {promptLabel ? <Field.Label>{promptLabel}</Field.Label> : null}
74
+ <Input
75
+ font="mono"
76
+ value={typed}
77
+ onChange={(e) => setTyped(e.target.value)}
78
+ autoFocus
79
+ aria-label="confirmation phrase"
80
+ />
81
+ </Field.Root>
82
+ ) : null}
83
+ </Dialog.Body>
84
+ <Dialog.Footer>
85
+ <Button variant="secondary" onClick={() => handleOpenChange(false)}>
86
+ {cancelLabel}
87
+ </Button>
88
+ {confirmSlot ? (
89
+ confirmSlot({satisfied})
90
+ ) : onConfirm ? (
91
+ <Button variant="danger" disabled={!satisfied} onClick={onConfirm}>
92
+ {confirmLabel}
93
+ </Button>
94
+ ) : null}
95
+ </Dialog.Footer>
96
+ </Dialog.Portal>
97
+ </Dialog.Root>
98
+ )
99
+ }
@@ -10,11 +10,26 @@ export type InputVariant = 'default' | 'error'
10
10
 
11
11
  interface InputProps {
12
12
  variant?: InputVariant
13
+ /** Orthogonal to variant — `mono` keeps the error border but uses the
14
+ * monospace family (slugs, identifiers, hashes). */
15
+ font?: 'mono'
13
16
  type?: InputType
17
+ /** Explicit id — overrides the Field-context controlId when set (e.g. a
18
+ * control used outside a Field that still needs a stable id). */
19
+ id?: string
14
20
  name?: string
15
21
  placeholder?: string
16
22
  required?: boolean
23
+ readOnly?: boolean
17
24
  minLength?: number
25
+ maxLength?: number
26
+ /** Numeric/range hints — forwarded to the native input (date/number/etc). */
27
+ min?: string | number
28
+ max?: string | number
29
+ step?: string | number
30
+ inputMode?: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'
31
+ /** id of a <datalist> for native typeahead. */
32
+ list?: string
18
33
  pattern?: string
19
34
  autoComplete?:
20
35
  | 'on'
@@ -30,6 +45,10 @@ interface InputProps {
30
45
  defaultValue?: string
31
46
  disabled?: boolean
32
47
  autoFocus?: boolean
48
+ /** Accessible name when there's no associated <Field.Label>. */
49
+ 'aria-label'?: string
50
+ /** Extra describedby ids (merged with the Field context's, if any). */
51
+ 'aria-describedby'?: string
33
52
  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
34
53
  onBlur?: () => void
35
54
  ref?: React.Ref<HTMLInputElement>
@@ -37,17 +56,28 @@ interface InputProps {
37
56
 
38
57
  export function Input({
39
58
  variant = 'default',
59
+ font,
40
60
  type = 'text',
61
+ id,
41
62
  name,
42
63
  placeholder,
43
64
  required,
65
+ readOnly,
44
66
  minLength,
67
+ maxLength,
68
+ min,
69
+ max,
70
+ step,
71
+ inputMode,
72
+ list,
45
73
  pattern,
46
74
  autoComplete,
47
75
  value,
48
76
  defaultValue,
49
77
  disabled,
50
78
  autoFocus,
79
+ 'aria-label': ariaLabel,
80
+ 'aria-describedby': ariaDescribedby,
51
81
  onChange,
52
82
  onBlur,
53
83
  ref,
@@ -63,16 +93,29 @@ export function Input({
63
93
  const effectiveRef = ref ?? fieldCtx?.ref
64
94
  const effectiveDisabled = disabled ?? ctx?.disabled
65
95
  const effectiveVariant = ctx?.invalid ? 'error' : variant
96
+ // Field context owns describedby (description + error); fall back to the
97
+ // explicit prop only when standalone.
98
+ const describedBy = ctx
99
+ ? `${ctx.descriptionId} ${ctx.invalid ? ctx.errorId : ''}`.trim()
100
+ : ariaDescribedby
66
101
 
67
102
  // react-strict-dom omits some web-only props from its types, but the
68
103
  // underlying DOM element supports them. Type-assert to pass them through.
69
104
  const extraProps: Record<string, unknown> = {}
70
105
  if (pattern !== undefined) extraProps.pattern = pattern
71
106
  if (autoFocus) extraProps.autoFocus = true
107
+ if (readOnly) extraProps.readOnly = true
108
+ if (maxLength !== undefined) extraProps.maxLength = maxLength
109
+ if (min !== undefined) extraProps.min = min
110
+ if (max !== undefined) extraProps.max = max
111
+ if (step !== undefined) extraProps.step = step
112
+ if (inputMode !== undefined) extraProps.inputMode = inputMode
113
+ if (list !== undefined) extraProps.list = list
114
+ if (ariaLabel !== undefined) extraProps['aria-label'] = ariaLabel
72
115
 
73
116
  return (
74
117
  <html.input
75
- id={ctx?.controlId}
118
+ id={id ?? ctx?.controlId}
76
119
  type={type}
77
120
  name={effectiveName}
78
121
  placeholder={placeholder}
@@ -82,14 +125,17 @@ export function Input({
82
125
  value={effectiveValue}
83
126
  defaultValue={defaultValue}
84
127
  disabled={effectiveDisabled}
85
- aria-describedby={
86
- ctx ? `${ctx.descriptionId} ${ctx.invalid ? ctx.errorId : ''}`.trim() : undefined
87
- }
128
+ aria-describedby={describedBy || undefined}
88
129
  aria-invalid={ctx?.invalid || variant === 'error' || undefined}
89
130
  onChange={effectiveOnChange as StrictInputProps['onChange']}
90
131
  onBlur={effectiveOnBlur as StrictInputProps['onBlur']}
91
132
  ref={effectiveRef as React.Ref<HTMLInputElement>}
92
- style={[styles.base, styles[effectiveVariant], groupCtx?.inGroup && styles.inGroup]}
133
+ style={[
134
+ styles.base,
135
+ styles[effectiveVariant],
136
+ font === 'mono' && styles.mono,
137
+ groupCtx?.inGroup && styles.inGroup,
138
+ ]}
93
139
  {...extraProps}
94
140
  />
95
141
  )
@@ -56,4 +56,9 @@ export const styles = css.create({
56
56
  borderRadius: 0,
57
57
  outlineWidth: 0,
58
58
  },
59
+ // orthogonal to variant (default/error) — composes on top so a mono field
60
+ // still shows its error border. For slugs, identifiers, hashes.
61
+ mono: {
62
+ fontFamily: typography.fontFamilyMono,
63
+ },
59
64
  })
@@ -1,5 +1,6 @@
1
1
  import type {Meta, StoryObj} from '@storybook/react'
2
- import {expect, fn} from 'storybook/test'
2
+ import {expect, fn, screen} from 'storybook/test'
3
+ import {Dialog} from '../Dialog/Dialog'
3
4
  import {Select} from './Select'
4
5
 
5
6
  const meta: Meta = {
@@ -62,13 +63,16 @@ export const OpenAndSelect: Story = {
62
63
 
63
64
  await userEvent.click(trigger)
64
65
  await expect(trigger).toHaveAttribute('aria-expanded', 'true')
65
- const options = canvas.getAllByRole('option')
66
+ // The popup portals to the ThemeProvider mount (outside the story canvas),
67
+ // so query the whole document via `screen`.
68
+ const options = screen.getAllByRole('option')
66
69
  await expect(options.length).toBe(3)
67
70
  await expect(options[0]).toHaveAttribute('aria-selected', 'true')
68
71
 
69
72
  await userEvent.click(options[1])
70
73
  await expect(trigger).toHaveTextContent(/Francais/)
71
- await expect(canvas.queryByRole('listbox')).not.toBeInTheDocument()
74
+ // Closed listbox is aria-hidden, so it's no longer exposed by role.
75
+ await expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
72
76
  },
73
77
  }
74
78
 
@@ -101,9 +105,9 @@ export const WithPlaceholder: Story = {
101
105
  // Shows placeholder when nothing selected
102
106
  await expect(trigger).toHaveTextContent(/Choose a language\.\.\./)
103
107
 
104
- // Open and select
108
+ // Open and select (options portal to the ThemeProvider mount → use screen)
105
109
  await userEvent.click(trigger)
106
- const options = canvas.getAllByRole('option')
110
+ const options = screen.getAllByRole('option')
107
111
  await expect(options.length).toBe(4)
108
112
 
109
113
  // None selected initially
@@ -111,7 +115,7 @@ export const WithPlaceholder: Story = {
111
115
  await expect(opt).toHaveAttribute('aria-selected', 'false')
112
116
  }
113
117
 
114
- await userEvent.click(canvas.getByText('Vue'))
118
+ await userEvent.click(screen.getByText('Vue'))
115
119
  await expect(trigger).toHaveTextContent(/Vue/)
116
120
  },
117
121
  }
@@ -139,6 +143,54 @@ export const KeyboardNavigation: Story = {
139
143
  play: async ({canvas, userEvent}) => {
140
144
  const trigger = canvas.getByRole('combobox')
141
145
  await userEvent.click(trigger)
142
- await expect(canvas.getByRole('listbox')).toBeInTheDocument()
146
+ await expect(screen.getByRole('listbox')).toBeInTheDocument()
147
+ },
148
+ }
149
+
150
+ // Regression: a Select inside a Dialog. The Dialog clips its content
151
+ // (overflow: hidden + body overflowY: auto), so an in-tree absolutely
152
+ // positioned popup would be cropped. The popup must portal to the
153
+ // ThemeProvider mount (z-index above the Dialog) and render in full.
154
+ export const InsideDialog: Story = {
155
+ render: () => (
156
+ <Dialog.Root open onOpenChange={() => {}}>
157
+ <Dialog.Portal>
158
+ <Dialog.Header>
159
+ <Dialog.Title>Add a zone</Dialog.Title>
160
+ </Dialog.Header>
161
+ <Dialog.Body>
162
+ <Select.Root defaultValue="">
163
+ <Select.Trigger aria-label="Macro-zone">
164
+ <Select.Value placeholder="— No macro-zone —" />
165
+ <Select.Icon />
166
+ </Select.Trigger>
167
+ <Select.Popup>
168
+ <Select.Item value="">
169
+ <Select.ItemText>— No macro-zone —</Select.ItemText>
170
+ </Select.Item>
171
+ <Select.Item value="north">
172
+ <Select.ItemText>North</Select.ItemText>
173
+ </Select.Item>
174
+ <Select.Item value="south">
175
+ <Select.ItemText>South</Select.ItemText>
176
+ </Select.Item>
177
+ </Select.Popup>
178
+ </Select.Root>
179
+ </Dialog.Body>
180
+ </Dialog.Portal>
181
+ </Dialog.Root>
182
+ ),
183
+ play: async ({userEvent}) => {
184
+ // Both the trigger and the (portaled) popup live outside the story canvas:
185
+ // the Dialog itself portals. Query the whole document.
186
+ const trigger = screen.getByRole('combobox', {name: 'Macro-zone'})
187
+ await userEvent.click(trigger)
188
+ const options = screen.getAllByRole('option')
189
+ await expect(options.length).toBe(3)
190
+ // The popup escapes the Dialog: it renders into the portal mount, which is
191
+ // a sibling of (and stacks above) the Dialog — not clipped by it.
192
+ const listbox = screen.getByRole('listbox')
193
+ await expect(listbox).toBeInTheDocument()
194
+ await expect(listbox.closest('[role="dialog"]')).toBeNull()
143
195
  },
144
196
  }
@@ -1,8 +1,11 @@
1
- import {type ReactNode, useRef, useId, useEffect} from 'react'
1
+ import {type ReactNode, useRef, useId, useEffect, useLayoutEffect, useState} from 'react'
2
+ import {createPortal} from 'react-dom'
2
3
  import {html} from 'react-strict-dom'
3
4
  import {styles} from './styles.css'
4
5
  import {SelectContext, useSelect} from './SelectContext'
5
6
  import {useSelectRoot} from './useSelectRoot'
7
+ import {useFieldContext} from '../Field/FieldContext'
8
+ import {usePortalMount} from '../ThemeProvider/ThemeProvider'
6
9
 
7
10
  // --- Root ---
8
11
  interface RootProps {
@@ -37,6 +40,13 @@ function Trigger({
37
40
  }) {
38
41
  const {open, toggle, listboxId, highlightedId, triggerRef} = useSelect()
39
42
  const localRef = useRef<HTMLButtonElement>(null)
43
+ // Inside a <Field.Root>, adopt its controlId so <Field.Label for={controlId}>
44
+ // labels the trigger (a labelable <button>) — no manual aria-label needed; and
45
+ // surface the field's description/error + invalid state, mirroring <Input>.
46
+ const fieldCtx = useFieldContext()
47
+ const describedBy = fieldCtx
48
+ ? `${fieldCtx.descriptionId} ${fieldCtx.invalid ? fieldCtx.errorId : ''}`.trim()
49
+ : undefined
40
50
 
41
51
  // Sync local ref to context triggerRef
42
52
  useEffect(() => {
@@ -46,6 +56,7 @@ function Trigger({
46
56
  return (
47
57
  <html.button
48
58
  ref={localRef}
59
+ id={fieldCtx?.controlId}
49
60
  type="button"
50
61
  role={'combobox' as 'listbox'}
51
62
  onClick={toggle}
@@ -54,6 +65,8 @@ function Trigger({
54
65
  aria-haspopup="listbox"
55
66
  aria-controls={open ? listboxId : undefined}
56
67
  aria-activedescendant={highlightedId ?? undefined}
68
+ aria-describedby={describedBy || undefined}
69
+ aria-invalid={fieldCtx?.invalid || undefined}
57
70
  style={styles.trigger}
58
71
  >
59
72
  {children}
@@ -97,22 +110,58 @@ function Icon({children}: {children?: ReactNode}) {
97
110
  }
98
111
 
99
112
  // --- Popup ---
113
+ // Rendered into the ThemeProvider portal mount and positioned with viewport
114
+ // coordinates (measured off the trigger) so it escapes ancestor
115
+ // `overflow: hidden` / `transform` containing blocks (notably Dialog/Drawer).
116
+ // The listbox stays mounted while closed (display: none) so Items can register
117
+ // their labels from DOM text before the popup is ever opened.
100
118
  function Popup({children}: {children: ReactNode}) {
101
- const {open, close, listboxId} = useSelect()
102
-
103
- return (
119
+ const {open, close, listboxId, triggerRef} = useSelect()
120
+ const mount = usePortalMount()
121
+ const [coords, setCoords] = useState<{top: number; left: number; width: number} | null>(null)
122
+
123
+ // Measure the trigger when the popup opens, and re-measure on scroll/resize so
124
+ // the dropdown follows it. Capture-phase scroll catches scroll on any ancestor
125
+ // (e.g. a Dialog body).
126
+ useLayoutEffect(() => {
127
+ if (!open) return
128
+ const update = () => {
129
+ const el = triggerRef.current
130
+ if (!el) return
131
+ const rect = el.getBoundingClientRect()
132
+ // `xs` token = 4px gap below the trigger.
133
+ setCoords({top: rect.bottom + 4, left: rect.left, width: rect.width})
134
+ }
135
+ update()
136
+ window.addEventListener('resize', update)
137
+ window.addEventListener('scroll', update, true)
138
+ return () => {
139
+ window.removeEventListener('resize', update)
140
+ window.removeEventListener('scroll', update, true)
141
+ }
142
+ }, [open, triggerRef])
143
+
144
+ const node = (
104
145
  <>
105
146
  {open && <html.div style={styles.backdrop} onClick={close} />}
106
147
  <html.div
107
148
  id={listboxId}
108
149
  role="listbox"
109
150
  aria-hidden={!open}
110
- style={[styles.popup, !open && styles.hidden]}
151
+ style={[
152
+ styles.popup,
153
+ (!open || !coords) && styles.hidden,
154
+ coords && styles.popupPosition(coords.top, coords.left, coords.width),
155
+ ]}
111
156
  >
112
157
  {children}
113
158
  </html.div>
114
159
  </>
115
160
  )
161
+
162
+ // SSR / pre-mount fallback: render inline (hidden under overflow parents but
163
+ // won't crash). Once the mount exists, portal into it.
164
+ return mount ? createPortal(node, mount) : node
116
165
  }
117
166
 
118
167
  // --- Item ---
@@ -51,12 +51,15 @@ export const styles = css.create({
51
51
  right: 0,
52
52
  bottom: 0,
53
53
  zIndex: 49,
54
+ // The portal mount is pointer-events: none, so re-enable here to catch
55
+ // outside clicks that close the popup.
56
+ pointerEvents: 'auto',
54
57
  },
55
58
  popup: {
56
- position: 'absolute',
57
- top: '100%',
58
- left: 0,
59
- marginTop: spacing.xs,
59
+ // Position is `fixed` and top/left/minWidth are applied inline by Popup
60
+ // (computed from the trigger's bounding rect) so the dropdown escapes any
61
+ // ancestor with `overflow: hidden` / `transform` (notably inside a Dialog).
62
+ position: 'fixed',
60
63
  backgroundColor: colors.bgCard,
61
64
  borderWidth: 1,
62
65
  borderStyle: 'solid',
@@ -66,8 +69,17 @@ export const styles = css.create({
66
69
  paddingTop: spacing.xs,
67
70
  paddingBottom: spacing.xs,
68
71
  minWidth: 120,
72
+ maxHeight: 280,
73
+ overflowY: 'auto',
69
74
  zIndex: 50,
75
+ // Re-enable pointer events: the portal mount is pointer-events: none.
76
+ pointerEvents: 'auto',
70
77
  },
78
+ popupPosition: (top: number, left: number, minWidth: number) => ({
79
+ top,
80
+ left,
81
+ minWidth,
82
+ }),
71
83
  item: {
72
84
  display: 'flex',
73
85
  alignItems: 'center',
package/src/index.ts CHANGED
@@ -13,6 +13,8 @@ export {Callout, type CalloutVariant} from './components/Callout/Callout'
13
13
  export {Card, type CardVariant, type CardSize} from './components/Card/Card'
14
14
  export {Checkbox} from './components/Checkbox/Checkbox'
15
15
  export {CheckboxGroup} from './components/CheckboxGroup/CheckboxGroup'
16
+ export {ColorInput} from './components/ColorInput/ColorInput'
17
+ export {ConfirmDialog} from './components/ConfirmDialog/ConfirmDialog'
16
18
  export {Dialog, type DialogSize} from './components/Dialog/Dialog'
17
19
  export {DetailPanel, type DetailPanelSize} from './components/DetailPanel/DetailPanel'
18
20
  export {Drawer, type DrawerAnchor, type DrawerSize} from './components/Drawer/Drawer'