@duro-app/ui 0.35.3 → 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.
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.stories.d.ts +1 -0
- package/dist/components/Select/Select.stories.d.ts.map +1 -1
- package/dist/components/Select/styles.css.d.ts +10 -4
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +1239 -1203
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/Select/Select.stories.tsx +59 -7
- package/src/components/Select/Select.tsx +43 -5
- package/src/components/Select/styles.css.ts +16 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "0.35.
|
|
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.
|
|
57
|
+
"@duro-app/tokens": "^0.35.4"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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,9 +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'
|
|
6
7
|
import {useFieldContext} from '../Field/FieldContext'
|
|
8
|
+
import {usePortalMount} from '../ThemeProvider/ThemeProvider'
|
|
7
9
|
|
|
8
10
|
// --- Root ---
|
|
9
11
|
interface RootProps {
|
|
@@ -108,22 +110,58 @@ function Icon({children}: {children?: ReactNode}) {
|
|
|
108
110
|
}
|
|
109
111
|
|
|
110
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.
|
|
111
118
|
function Popup({children}: {children: ReactNode}) {
|
|
112
|
-
const {open, close, listboxId} = useSelect()
|
|
113
|
-
|
|
114
|
-
|
|
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 = (
|
|
115
145
|
<>
|
|
116
146
|
{open && <html.div style={styles.backdrop} onClick={close} />}
|
|
117
147
|
<html.div
|
|
118
148
|
id={listboxId}
|
|
119
149
|
role="listbox"
|
|
120
150
|
aria-hidden={!open}
|
|
121
|
-
style={[
|
|
151
|
+
style={[
|
|
152
|
+
styles.popup,
|
|
153
|
+
(!open || !coords) && styles.hidden,
|
|
154
|
+
coords && styles.popupPosition(coords.top, coords.left, coords.width),
|
|
155
|
+
]}
|
|
122
156
|
>
|
|
123
157
|
{children}
|
|
124
158
|
</html.div>
|
|
125
159
|
</>
|
|
126
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
|
|
127
165
|
}
|
|
128
166
|
|
|
129
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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',
|