@duro-app/ui 5.0.0 → 5.2.0

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 (139) hide show
  1. package/dist/Table-C70zdSkM.js +1419 -0
  2. package/dist/Table-C70zdSkM.js.map +1 -0
  3. package/dist/components/Button/Button.d.ts +11 -2
  4. package/dist/components/Button/Button.d.ts.map +1 -1
  5. package/dist/components/Button/Button.stories.d.ts +1 -0
  6. package/dist/components/Button/Button.stories.d.ts.map +1 -1
  7. package/dist/components/Card/Card.stories.d.ts.map +1 -1
  8. package/dist/components/Combobox/Combobox.d.ts +1 -1
  9. package/dist/components/Combobox/Combobox.d.ts.map +1 -1
  10. package/dist/components/Combobox/styles.css.d.ts +0 -64
  11. package/dist/components/Combobox/styles.css.d.ts.map +1 -1
  12. package/dist/components/Dialog/Dialog.d.ts +11 -3
  13. package/dist/components/Dialog/Dialog.d.ts.map +1 -1
  14. package/dist/components/Dialog/Dialog.stories.d.ts +4 -0
  15. package/dist/components/Dialog/Dialog.stories.d.ts.map +1 -1
  16. package/dist/components/Dialog/styles.css.d.ts +1 -0
  17. package/dist/components/Dialog/styles.css.d.ts.map +1 -1
  18. package/dist/components/DragDrop/styles.css.d.ts +10 -2
  19. package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
  20. package/dist/components/Grid/Grid.d.ts +11 -2
  21. package/dist/components/Grid/Grid.d.ts.map +1 -1
  22. package/dist/components/Grid/Grid.stories.d.ts +2 -0
  23. package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
  24. package/dist/components/Grid/styles.css.d.ts +2 -2
  25. package/dist/components/Grid/styles.css.d.ts.map +1 -1
  26. package/dist/components/Input/Input.d.ts +6 -1
  27. package/dist/components/Input/Input.d.ts.map +1 -1
  28. package/dist/components/Input/Input.meta.d.ts.map +1 -1
  29. package/dist/components/Input/Input.stories.d.ts +1 -0
  30. package/dist/components/Input/Input.stories.d.ts.map +1 -1
  31. package/dist/components/Input/styles.css.d.ts +18 -0
  32. package/dist/components/Input/styles.css.d.ts.map +1 -1
  33. package/dist/components/InputGroup/InputGroup.d.ts +6 -1
  34. package/dist/components/InputGroup/InputGroup.d.ts.map +1 -1
  35. package/dist/components/InputGroup/InputGroup.stories.d.ts +1 -0
  36. package/dist/components/InputGroup/InputGroup.stories.d.ts.map +1 -1
  37. package/dist/components/Listbox/Listbox.d.ts +75 -0
  38. package/dist/components/Listbox/Listbox.d.ts.map +1 -0
  39. package/dist/components/Listbox/Listbox.meta.d.ts +3 -0
  40. package/dist/components/Listbox/Listbox.meta.d.ts.map +1 -0
  41. package/dist/components/Listbox/Listbox.stories.d.ts +7 -0
  42. package/dist/components/Listbox/Listbox.stories.d.ts.map +1 -0
  43. package/dist/components/Listbox/styles.css.d.ts +75 -0
  44. package/dist/components/Listbox/styles.css.d.ts.map +1 -0
  45. package/dist/components/Menu/Menu.d.ts +18 -5
  46. package/dist/components/Menu/Menu.d.ts.map +1 -1
  47. package/dist/components/Menu/Menu.meta.d.ts.map +1 -1
  48. package/dist/components/Menu/Menu.stories.d.ts +7 -0
  49. package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
  50. package/dist/components/Menu/MenuContext.d.ts +3 -0
  51. package/dist/components/Menu/MenuContext.d.ts.map +1 -1
  52. package/dist/components/Menu/styles.css.d.ts +24 -15
  53. package/dist/components/Menu/styles.css.d.ts.map +1 -1
  54. package/dist/components/Menu/useMenuRoot.d.ts +14 -1
  55. package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
  56. package/dist/components/StatusIcon/StatusIcon.stories.d.ts.map +1 -1
  57. package/dist/components/Table/Table.stories.d.ts +1 -0
  58. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  59. package/dist/components/TextLink/TextLink.d.ts +24 -0
  60. package/dist/components/TextLink/TextLink.d.ts.map +1 -0
  61. package/dist/components/TextLink/TextLink.meta.d.ts +3 -0
  62. package/dist/components/TextLink/TextLink.meta.d.ts.map +1 -0
  63. package/dist/components/TextLink/TextLink.stories.d.ts +9 -0
  64. package/dist/components/TextLink/TextLink.stories.d.ts.map +1 -0
  65. package/dist/components/TextLink/styles.css.d.ts +28 -0
  66. package/dist/components/TextLink/styles.css.d.ts.map +1 -0
  67. package/dist/components/ThemeProvider/ThemeProvider.stories.d.ts +1 -0
  68. package/dist/components/ThemeProvider/ThemeProvider.stories.d.ts.map +1 -1
  69. package/dist/components/VisuallyHidden/VisuallyHidden.d.ts +14 -0
  70. package/dist/components/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
  71. package/dist/components/VisuallyHidden/VisuallyHidden.meta.d.ts +3 -0
  72. package/dist/components/VisuallyHidden/VisuallyHidden.meta.d.ts.map +1 -0
  73. package/dist/components/VisuallyHidden/VisuallyHidden.stories.d.ts +7 -0
  74. package/dist/components/VisuallyHidden/VisuallyHidden.stories.d.ts.map +1 -0
  75. package/dist/docs/FlexGrow.stories.d.ts +7 -0
  76. package/dist/docs/FlexGrow.stories.d.ts.map +1 -0
  77. package/dist/index.css +1 -1
  78. package/dist/index.d.ts +5 -0
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +2986 -2821
  81. package/dist/index.js.map +1 -1
  82. package/dist/index.native.d.ts +1 -0
  83. package/dist/index.native.d.ts.map +1 -1
  84. package/dist/shared/length.d.ts +14 -9
  85. package/dist/shared/length.d.ts.map +1 -1
  86. package/dist/shared/length.typecheck.d.ts +3 -0
  87. package/dist/shared/length.typecheck.d.ts.map +1 -1
  88. package/dist/styles/visually-hidden.css.d.ts +6 -0
  89. package/dist/styles/visually-hidden.css.d.ts.map +1 -1
  90. package/dist/table.js +1 -1
  91. package/package.json +2 -2
  92. package/src/components/Button/Button.stories.tsx +41 -0
  93. package/src/components/Button/Button.tsx +16 -1
  94. package/src/components/Card/Card.stories.tsx +2 -1
  95. package/src/components/Combobox/Combobox.tsx +25 -79
  96. package/src/components/Combobox/styles.css.ts +0 -74
  97. package/src/components/Dialog/Dialog.meta.ts +1 -1
  98. package/src/components/Dialog/Dialog.stories.tsx +109 -3
  99. package/src/components/Dialog/Dialog.tsx +63 -9
  100. package/src/components/Dialog/styles.css.ts +2 -0
  101. package/src/components/DragDrop/styles.css.ts +16 -3
  102. package/src/components/Grid/Grid.meta.ts +1 -1
  103. package/src/components/Grid/Grid.stories.tsx +53 -1
  104. package/src/components/Grid/Grid.tsx +23 -11
  105. package/src/components/Grid/styles.css.ts +3 -2
  106. package/src/components/Input/Input.meta.ts +10 -3
  107. package/src/components/Input/Input.stories.tsx +52 -2
  108. package/src/components/Input/Input.tsx +12 -3
  109. package/src/components/Input/styles.css.ts +29 -0
  110. package/src/components/InputGroup/InputGroup.stories.tsx +20 -0
  111. package/src/components/InputGroup/InputGroup.tsx +20 -2
  112. package/src/components/Listbox/Listbox.meta.ts +52 -0
  113. package/src/components/Listbox/Listbox.stories.tsx +170 -0
  114. package/src/components/Listbox/Listbox.tsx +278 -0
  115. package/src/components/Listbox/styles.css.ts +89 -0
  116. package/src/components/Menu/Menu.meta.ts +2 -1
  117. package/src/components/Menu/Menu.stories.tsx +289 -8
  118. package/src/components/Menu/Menu.tsx +100 -29
  119. package/src/components/Menu/MenuContext.ts +3 -0
  120. package/src/components/Menu/styles.css.ts +31 -15
  121. package/src/components/Menu/useMenuRoot.ts +105 -48
  122. package/src/components/StatusIcon/StatusIcon.stories.tsx +2 -2
  123. package/src/components/Table/Table.stories.tsx +39 -0
  124. package/src/components/TextLink/TextLink.meta.ts +33 -0
  125. package/src/components/TextLink/TextLink.stories.tsx +63 -0
  126. package/src/components/TextLink/TextLink.tsx +46 -0
  127. package/src/components/TextLink/styles.css.ts +58 -0
  128. package/src/components/ThemeProvider/ThemeProvider.stories.tsx +50 -0
  129. package/src/components/VisuallyHidden/VisuallyHidden.meta.ts +19 -0
  130. package/src/components/VisuallyHidden/VisuallyHidden.stories.tsx +30 -0
  131. package/src/components/VisuallyHidden/VisuallyHidden.tsx +22 -0
  132. package/src/docs/FlexGrow.stories.tsx +83 -0
  133. package/src/index.native.ts +1 -0
  134. package/src/index.ts +5 -0
  135. package/src/shared/length.ts +29 -10
  136. package/src/shared/length.typecheck.tsx +9 -0
  137. package/src/styles/visually-hidden.css.ts +8 -0
  138. package/dist/Table-BKN_d-mp.js +0 -1192
  139. package/dist/Table-BKN_d-mp.js.map +0 -1
@@ -0,0 +1,170 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, fn, waitFor, within} from 'storybook/test'
3
+ import {useCallback, useEffect, useId, useRef, useState} from 'react'
4
+ import {css, html} from 'react-strict-dom'
5
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
6
+ import {borders} from '@duro-app/tokens/tokens/borders.css'
7
+ import {sizes} from '@duro-app/tokens/tokens/sizes.css'
8
+ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
9
+ import {Listbox} from './Listbox'
10
+
11
+ const meta: Meta = {
12
+ title: 'Components/Listbox',
13
+ }
14
+
15
+ export default meta
16
+ type Story = StoryObj
17
+
18
+ const page = () => within(document.body)
19
+
20
+ const styles = css.create({
21
+ editor: {
22
+ minHeight: sizes.editorMinH,
23
+ padding: spacing.sm,
24
+ borderWidth: borders.hairline,
25
+ borderStyle: 'solid',
26
+ borderColor: colors.border,
27
+ borderRadius: radii.sm,
28
+ color: colors.text,
29
+ },
30
+ })
31
+
32
+ const PEOPLE = ['Ada', 'Grace', 'Linus', 'Margaret']
33
+ const onPick = fn()
34
+
35
+ // A contenteditable typeahead: '@' opens the list, arrows move the highlight,
36
+ // Enter picks. The editor keeps focus throughout.
37
+ function MentionEditor() {
38
+ const id = useId()
39
+ const editorRef = useRef<HTMLDivElement | null>(null)
40
+ const [open, setOpen] = useState(false)
41
+ const [highlighted, setHighlighted] = useState(0)
42
+ const optionId = useCallback((value: string) => `${id}-${value}`, [id])
43
+ const highlightedId = open ? optionId(PEOPLE[highlighted]!) : null
44
+
45
+ const pick = useCallback((value: string) => {
46
+ onPick(value)
47
+ editorRef.current?.append(`${value} `)
48
+ setOpen(false)
49
+ }, [])
50
+
51
+ // The anchor's ARIA, set on the contenteditable root like an editor would.
52
+ useEffect(() => {
53
+ const editor = editorRef.current
54
+ if (!editor) return
55
+ for (const [name, value] of Object.entries(Listbox.getAnchorProps({id, open, highlightedId}))) {
56
+ if (value === undefined) editor.removeAttribute(name)
57
+ else editor.setAttribute(name, String(value))
58
+ }
59
+ }, [id, open, highlightedId])
60
+
61
+ useEffect(() => {
62
+ const editor = editorRef.current
63
+ if (!editor) return
64
+ editor.setAttribute('contenteditable', 'true')
65
+ const onKeyDown = (e: KeyboardEvent) => {
66
+ if (e.key === '@') {
67
+ setOpen(true)
68
+ setHighlighted(0)
69
+ return
70
+ }
71
+ if (!openRef.current) return
72
+ if (e.key === 'ArrowDown') {
73
+ e.preventDefault()
74
+ setHighlighted((i) => (i + 1) % PEOPLE.length)
75
+ } else if (e.key === 'ArrowUp') {
76
+ e.preventDefault()
77
+ setHighlighted((i) => (i - 1 + PEOPLE.length) % PEOPLE.length)
78
+ } else if (e.key === 'Enter') {
79
+ e.preventDefault()
80
+ pick(PEOPLE[highlightedRef.current]!)
81
+ } else if (e.key === 'Escape') {
82
+ setOpen(false)
83
+ }
84
+ }
85
+ editor.addEventListener('keydown', onKeyDown)
86
+ return () => editor.removeEventListener('keydown', onKeyDown)
87
+ }, [pick])
88
+
89
+ const openRef = useRef(open)
90
+ openRef.current = open
91
+ const highlightedRef = useRef(highlighted)
92
+ highlightedRef.current = highlighted
93
+
94
+ return (
95
+ <>
96
+ <html.div
97
+ ref={editorRef}
98
+ role="textbox"
99
+ aria-label="Message"
100
+ aria-multiline={true}
101
+ style={styles.editor}
102
+ />
103
+ <Listbox.Root
104
+ id={id}
105
+ open={open}
106
+ anchor={() => editorRef.current?.getBoundingClientRect()}
107
+ highlightedId={highlightedId}
108
+ onHighlight={(next) => {
109
+ const index = PEOPLE.findIndex((p) => optionId(p) === next)
110
+ if (index >= 0) setHighlighted(index)
111
+ }}
112
+ onSelect={pick}
113
+ getOptionId={optionId}
114
+ aria-label="People"
115
+ >
116
+ {PEOPLE.map((person) => (
117
+ <Listbox.Option key={person} value={person}>
118
+ {person}
119
+ </Listbox.Option>
120
+ ))}
121
+ </Listbox.Root>
122
+ </>
123
+ )
124
+ }
125
+
126
+ export const EditorTypeahead: Story = {
127
+ beforeEach: () => {
128
+ onPick.mockClear()
129
+ },
130
+ render: () => <MentionEditor />,
131
+ play: async ({canvas, userEvent}) => {
132
+ const editor = canvas.getByRole('textbox', {name: 'Message'})
133
+ await userEvent.click(editor)
134
+ await userEvent.keyboard('@')
135
+ const list = await page().findByRole('listbox', {name: 'People'})
136
+ await expect(editor).toHaveFocus()
137
+ await expect(editor).toHaveAttribute('aria-expanded', 'true')
138
+ await expect(editor).toHaveAttribute('aria-controls', list.id)
139
+
140
+ await userEvent.keyboard('{ArrowDown}')
141
+ const grace = page().getByRole('option', {name: 'Grace'})
142
+ await expect(editor).toHaveAttribute('aria-activedescendant', grace.id)
143
+ await expect(editor).toHaveFocus()
144
+
145
+ // A press on an option picks it and leaves focus in the editor.
146
+ await userEvent.click(page().getByRole('option', {name: 'Linus'}))
147
+ await expect(onPick).toHaveBeenCalledWith('Linus')
148
+ await waitFor(() => expect(page().queryByRole('listbox')).not.toBeInTheDocument())
149
+ await expect(editor).toHaveFocus()
150
+ await expect(editor).toHaveAttribute('aria-expanded', 'false')
151
+ await expect(editor).not.toHaveAttribute('aria-activedescendant')
152
+ },
153
+ }
154
+
155
+ export const EmptyList: Story = {
156
+ render: () => (
157
+ <Listbox.Root
158
+ id="empty-list"
159
+ open
160
+ anchor={() => new DOMRect(16, 16, 200, 24)}
161
+ highlightedId={null}
162
+ aria-label="Results"
163
+ >
164
+ <Listbox.Empty>No match</Listbox.Empty>
165
+ </Listbox.Root>
166
+ ),
167
+ play: async () => {
168
+ await expect(await page().findByText('No match')).toBeVisible()
169
+ },
170
+ }
@@ -0,0 +1,278 @@
1
+ import {
2
+ type ReactNode,
3
+ type Ref,
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useLayoutEffect,
9
+ useRef,
10
+ useState,
11
+ } from 'react'
12
+ import {createPortal} from 'react-dom'
13
+ import {html} from 'react-strict-dom'
14
+ import {SPACING_PX} from '@duro-app/tokens/keys'
15
+ import {styles} from './styles.css'
16
+ import {usePortalMount} from '../ThemeProvider/ThemeProvider'
17
+ import {computePopoverPosition} from '../Popover/position'
18
+ import {usePopoverLayer} from '../Popover/PopoverLayerContext'
19
+
20
+ /**
21
+ * A headless-state popup list of options for an input that keeps focus: a
22
+ * Combobox's text field, or a rich-text editor's typeahead (mentions, slash
23
+ * commands). The anchor element owns focus and the keyboard; it moves the
24
+ * highlight by setting `highlightedId`, and wires itself to the list with
25
+ * `getAnchorProps()` (aria-controls / aria-expanded / aria-activedescendant).
26
+ * A press on an option never takes focus from the anchor.
27
+ */
28
+
29
+ export type ListboxMaxHeight = 'listMaxH' | 'listMaxHSm'
30
+
31
+ const maxHeightMap = {
32
+ listMaxH: styles.maxHeightListMaxH,
33
+ listMaxHSm: styles.maxHeightListMaxHSm,
34
+ } as const
35
+
36
+ /** Default option id: the listbox id plus the value, made safe for an id. */
37
+ export function defaultOptionId(listboxId: string, value: string): string {
38
+ return `${listboxId}-option-${value.replace(/[^\w-]/g, (ch) => `_${ch.charCodeAt(0).toString(16)}`)}`
39
+ }
40
+
41
+ export interface ListboxAnchorProps {
42
+ role?: 'combobox'
43
+ 'aria-controls': string | undefined
44
+ 'aria-expanded': boolean
45
+ 'aria-activedescendant': string | undefined
46
+ 'aria-autocomplete': 'list'
47
+ 'aria-haspopup': 'listbox'
48
+ }
49
+
50
+ /**
51
+ * ARIA for the element that keeps focus and drives the list. Spread it on an
52
+ * input, or set each attribute on a contenteditable root.
53
+ */
54
+ export function getAnchorProps({
55
+ id,
56
+ open,
57
+ highlightedId,
58
+ }: {
59
+ id: string
60
+ open: boolean
61
+ highlightedId: string | null | undefined
62
+ }): ListboxAnchorProps {
63
+ return {
64
+ 'aria-controls': open ? id : undefined,
65
+ 'aria-expanded': open,
66
+ 'aria-activedescendant': open && highlightedId ? highlightedId : undefined,
67
+ 'aria-autocomplete': 'list',
68
+ 'aria-haspopup': 'listbox',
69
+ }
70
+ }
71
+
72
+ interface ListboxContextValue {
73
+ id: string
74
+ highlightedId: string | null
75
+ onHighlight?: (id: string | null) => void
76
+ onSelect?: (value: string) => void
77
+ getOptionId: (value: string) => string
78
+ }
79
+
80
+ const ListboxContext = createContext<ListboxContextValue | null>(null)
81
+
82
+ function useListbox(part: string) {
83
+ const ctx = useContext(ListboxContext)
84
+ if (!ctx) throw new Error(`Listbox.${part} must be used within Listbox.Root`)
85
+ return ctx
86
+ }
87
+
88
+ // --- Root ---
89
+
90
+ interface RootProps {
91
+ /** DOM id of the list; pass the same to getAnchorProps. */
92
+ id: string
93
+ open: boolean
94
+ /** The rect the list is placed against: the input, or the editor's caret. */
95
+ anchor: () => DOMRect | null | undefined
96
+ /** Give the list the anchor's width (a Combobox under its input). */
97
+ matchAnchorWidth?: boolean
98
+ /** Id of the highlighted option, controlled by the anchor's keyboard handling. */
99
+ highlightedId: string | null
100
+ /** Pointer hover asks to move the highlight. */
101
+ onHighlight?: (id: string | null) => void
102
+ /** A press on an option. */
103
+ onSelect?: (value: string) => void
104
+ /** Option value → DOM id. Default: defaultOptionId(id, value). */
105
+ getOptionId?: (value: string) => string
106
+ /** Accessible name of the list. */
107
+ 'aria-label'?: string
108
+ /** Size-token key capping the list's height; longer lists scroll. Default: listMaxH. */
109
+ maxHeight?: ListboxMaxHeight
110
+ children: ReactNode
111
+ }
112
+
113
+ function Root({
114
+ id,
115
+ open,
116
+ anchor,
117
+ matchAnchorWidth = false,
118
+ highlightedId,
119
+ onHighlight,
120
+ onSelect,
121
+ getOptionId,
122
+ 'aria-label': ariaLabel,
123
+ maxHeight = 'listMaxH',
124
+ children,
125
+ }: RootProps) {
126
+ const mount = usePortalMount()
127
+ const listRef = useRef<HTMLDivElement>(null)
128
+ const [place, setPlace] = useState<{top: number; left: number; width: number} | null>(null)
129
+ const anchorRef = useRef(anchor)
130
+ anchorRef.current = anchor
131
+
132
+ useLayoutEffect(() => {
133
+ if (!open) {
134
+ setPlace(null)
135
+ return
136
+ }
137
+ const update = () => {
138
+ const rect = anchorRef.current()
139
+ if (!rect) return
140
+ const list = listRef.current
141
+ const next = computePopoverPosition({
142
+ anchor: {top: rect.top, left: rect.left, width: rect.width, height: rect.height},
143
+ popup: {
144
+ width: matchAnchorWidth ? rect.width : (list?.offsetWidth ?? 0),
145
+ height: list?.offsetHeight ?? 0,
146
+ },
147
+ viewport: {width: window.innerWidth, height: window.innerHeight},
148
+ side: 'bottom',
149
+ align: 'start',
150
+ offset: SPACING_PX.xs,
151
+ })
152
+ setPlace((prev) =>
153
+ prev && prev.top === next.top && prev.left === next.left && prev.width === rect.width
154
+ ? prev
155
+ : {top: next.top, left: next.left, width: rect.width},
156
+ )
157
+ }
158
+ update()
159
+ // A second pass once the list has its size, so the flip sees its height.
160
+ const frame = requestAnimationFrame(update)
161
+ window.addEventListener('resize', update)
162
+ window.addEventListener('scroll', update, true)
163
+ return () => {
164
+ cancelAnimationFrame(frame)
165
+ window.removeEventListener('resize', update)
166
+ window.removeEventListener('scroll', update, true)
167
+ }
168
+ }, [open, matchAnchorWidth])
169
+
170
+ // A press inside the list must not move focus off the anchor.
171
+ useEffect(() => {
172
+ const list = listRef.current
173
+ if (!list || !open) return
174
+ const keep = (e: MouseEvent) => e.preventDefault()
175
+ list.addEventListener('mousedown', keep)
176
+ return () => list.removeEventListener('mousedown', keep)
177
+ }, [open, place])
178
+
179
+ // Keep the highlighted option in view.
180
+ useEffect(() => {
181
+ if (!open || !highlightedId) return
182
+ const list = listRef.current
183
+ const option = list?.querySelector<HTMLElement>(`[id="${CSS.escape(highlightedId)}"]`)
184
+ option?.scrollIntoView?.({block: 'nearest'})
185
+ }, [open, highlightedId])
186
+
187
+ usePopoverLayer(listRef, {active: open && place !== null})
188
+
189
+ const optionId = useCallback(
190
+ (value: string) => (getOptionId ? getOptionId(value) : defaultOptionId(id, value)),
191
+ [getOptionId, id],
192
+ )
193
+
194
+ if (!open || !place || typeof document === 'undefined') return null
195
+
196
+ const node = (
197
+ <ListboxContext.Provider
198
+ value={{id, highlightedId, onHighlight, onSelect, getOptionId: optionId}}
199
+ >
200
+ <html.div
201
+ ref={listRef}
202
+ id={id}
203
+ role="listbox"
204
+ aria-label={ariaLabel}
205
+ style={[
206
+ styles.popup,
207
+ maxHeightMap[maxHeight],
208
+ styles.position(place.top, place.left),
209
+ matchAnchorWidth ? styles.width(place.width) : styles.ownWidth,
210
+ ]}
211
+ >
212
+ {children}
213
+ </html.div>
214
+ </ListboxContext.Provider>
215
+ )
216
+ return mount ? createPortal(node, mount) : node
217
+ }
218
+
219
+ // --- Option ---
220
+
221
+ interface OptionProps {
222
+ value: string
223
+ /** DOM id. Default: Root's getOptionId(value). */
224
+ id?: string
225
+ /** The committed choice (aria-selected). */
226
+ selected?: boolean
227
+ disabled?: boolean
228
+ children: ReactNode
229
+ ref?: Ref<HTMLDivElement>
230
+ }
231
+
232
+ function Option({
233
+ value,
234
+ id: idProp,
235
+ selected = false,
236
+ disabled = false,
237
+ children,
238
+ ref,
239
+ }: OptionProps) {
240
+ const {highlightedId, onHighlight, onSelect, getOptionId} = useListbox('Option')
241
+ const id = idProp ?? getOptionId(value)
242
+ const highlighted = highlightedId === id
243
+
244
+ return (
245
+ <html.div
246
+ ref={ref}
247
+ id={id}
248
+ role="option"
249
+ aria-selected={selected}
250
+ aria-disabled={disabled || undefined}
251
+ onClick={disabled ? undefined : () => onSelect?.(value)}
252
+ onPointerEnter={disabled ? undefined : () => onHighlight?.(id)}
253
+ style={[
254
+ styles.option,
255
+ selected && styles.optionSelected,
256
+ highlighted && styles.optionHighlighted,
257
+ disabled && styles.optionDisabled,
258
+ ]}
259
+ >
260
+ {children}
261
+ </html.div>
262
+ )
263
+ }
264
+
265
+ // --- Empty ---
266
+
267
+ /** Shown in place of options when nothing matches. */
268
+ function Empty({children}: {children: ReactNode}) {
269
+ useListbox('Empty')
270
+ return <html.div style={styles.empty}>{children}</html.div>
271
+ }
272
+
273
+ export const Listbox = {
274
+ Root,
275
+ Option,
276
+ Empty,
277
+ getAnchorProps,
278
+ }
@@ -0,0 +1,89 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
5
+ import {shadows} from '@duro-app/tokens/tokens/shadows.css'
6
+ import {duration} from '@duro-app/tokens/tokens/motion.css'
7
+ import {sizes} from '@duro-app/tokens/tokens/sizes.css'
8
+ import {borders} from '@duro-app/tokens/tokens/borders.css'
9
+
10
+ export const styles = css.create({
11
+ popup: {
12
+ // Fixed, with top/left (and width) applied inline from the anchor's rect,
13
+ // so the list escapes any ancestor with overflow: hidden or a transform.
14
+ position: 'fixed',
15
+ top: 0,
16
+ left: 0,
17
+ backgroundColor: colors.bgCard,
18
+ borderWidth: borders.hairline,
19
+ borderStyle: 'solid',
20
+ borderColor: colors.border,
21
+ borderRadius: radii.sm,
22
+ boxShadow: shadows.md,
23
+ paddingTop: spacing.xs,
24
+ paddingBottom: spacing.xs,
25
+ overflowY: 'auto',
26
+ // Above Dialog/Drawer (1000/1001): they portal into the same mount.
27
+ zIndex: 1050,
28
+ // The portal mount is pointer-events: none; the list takes them back.
29
+ pointerEvents: 'auto',
30
+ },
31
+ // A list that does not take the anchor's width (an editor typeahead).
32
+ ownWidth: {
33
+ minWidth: sizes.popupMinW,
34
+ },
35
+ maxHeightListMaxH: {
36
+ maxHeight: sizes.listMaxH,
37
+ },
38
+ maxHeightListMaxHSm: {
39
+ maxHeight: sizes.listMaxHSm,
40
+ },
41
+ position: (top: number, left: number) => ({
42
+ top,
43
+ left,
44
+ }),
45
+ width: (width: number) => ({
46
+ width,
47
+ }),
48
+ option: {
49
+ display: 'flex',
50
+ alignItems: 'center',
51
+ paddingTop: spacing.sm,
52
+ paddingBottom: spacing.sm,
53
+ paddingLeft: spacing.md,
54
+ paddingRight: spacing.md,
55
+ fontSize: typography.fontSizeSm,
56
+ fontFamily: typography.fontFamily,
57
+ color: colors.text,
58
+ cursor: 'pointer',
59
+ backgroundColor: 'transparent',
60
+ transitionProperty: 'background-color',
61
+ transitionDuration: duration.fast,
62
+ },
63
+ optionSelected: {
64
+ color: colors.accent,
65
+ fontWeight: typography.fontWeightMedium,
66
+ },
67
+ // The fill is the highlight; the transparent outline is what forced-colors
68
+ // mode paints, where fills are dropped.
69
+ optionHighlighted: {
70
+ backgroundColor: colors.bgCardHover,
71
+ outlineStyle: 'solid',
72
+ outlineWidth: borders.focusRing,
73
+ outlineColor: 'transparent',
74
+ outlineOffset: `calc(-1 * ${borders.focusRing})`,
75
+ },
76
+ optionDisabled: {
77
+ color: colors.textMuted,
78
+ cursor: 'default',
79
+ },
80
+ empty: {
81
+ paddingTop: spacing.sm,
82
+ paddingBottom: spacing.sm,
83
+ paddingLeft: spacing.md,
84
+ paddingRight: spacing.md,
85
+ fontSize: typography.fontSizeSm,
86
+ fontFamily: typography.fontFamily,
87
+ color: colors.textMuted,
88
+ },
89
+ })
@@ -2,11 +2,12 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Dropdown action menu. Triggers actions (not value selection). Compound component — Root is required (throws without it). Menu.Trigger renders the button itself: give it the label (text, an Icon), never a Button.',
5
+ 'Dropdown action menu. Triggers actions (not value selection). Compound component — Root is required (throws without it). Menu.Trigger renders the button itself: give it the label (text, an Icon), never a Button; an icon-only trigger takes aria-label, and variant="ghost" suits toolbars. The popup is portalled and placed against the trigger (never clipped by a sticky or overflow ancestor), capped at sizes.listMaxH with scrolling, and takes focus on open (WAI-ARIA menu button); Escape closes only the menu, not an enclosing Popover or Dialog.',
6
6
  whenToUse: [
7
7
  'Context menu or "more actions" dropdown',
8
8
  'Navigation links in a dropdown',
9
9
  'Overflow menu for toolbar actions',
10
+ 'A toolbar dropdown (align, insert) in an editor — Menu.Trigger variant="ghost"',
10
11
  ],
11
12
  whenNotToUse: [
12
13
  'Picking a value — use Select',