@duro-app/ui 4.3.0 → 4.5.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.
- package/dist/{Table-JTgcR_Fh.js → Table-CUp12BKZ.js} +78 -75
- package/dist/Table-CUp12BKZ.js.map +1 -0
- package/dist/components/ActionBar/ActionBar.d.ts +7 -0
- package/dist/components/ActionBar/ActionBar.d.ts.map +1 -1
- package/dist/components/ActionBar/ActionBar.meta.d.ts.map +1 -1
- package/dist/components/ActionBar/ActionBar.stories.d.ts +3 -0
- package/dist/components/ActionBar/ActionBar.stories.d.ts.map +1 -1
- package/dist/components/ActionBar/styles.css.d.ts +25 -2
- package/dist/components/ActionBar/styles.css.d.ts.map +1 -1
- package/dist/components/Combobox/Combobox.d.ts.map +1 -1
- package/dist/components/Combobox/Combobox.meta.d.ts.map +1 -1
- package/dist/components/DetailPanel/DetailPanel.meta.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.d.ts.map +1 -1
- package/dist/components/Drawer/DrawerScroll.stories.d.ts +13 -0
- package/dist/components/Drawer/DrawerScroll.stories.d.ts.map +1 -0
- package/dist/components/Drawer/useSwipeDismiss.d.ts +7 -2
- package/dist/components/Drawer/useSwipeDismiss.d.ts.map +1 -1
- package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
- package/dist/components/Popover/Popover.d.ts.map +1 -1
- package/dist/components/Popover/Popover.meta.d.ts.map +1 -1
- package/dist/components/Popover/PopoverLayerContext.d.ts +10 -0
- package/dist/components/Popover/PopoverLayerContext.d.ts.map +1 -0
- package/dist/components/Popover/PopoverLayers.stories.d.ts +13 -0
- package/dist/components/Popover/PopoverLayers.stories.d.ts.map +1 -0
- package/dist/components/ScrollArea/ScrollArea.d.ts +8 -2
- package/dist/components/ScrollArea/ScrollArea.d.ts.map +1 -1
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.meta.d.ts.map +1 -1
- package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
- package/dist/components/Toggle/Toggle.d.ts.map +1 -1
- package/dist/components/Toggle/rowHeight.d.ts +17 -0
- package/dist/components/Toggle/rowHeight.d.ts.map +1 -0
- package/dist/components/Toggle/styles.css.d.ts +10 -0
- package/dist/components/Toggle/styles.css.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroup.d.ts +7 -1
- package/dist/components/ToggleGroup/ToggleGroup.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroup.meta.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroup.stories.d.ts +4 -0
- package/dist/components/ToggleGroup/ToggleGroup.stories.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroupContext.d.ts +6 -0
- package/dist/components/ToggleGroup/ToggleGroupContext.d.ts.map +1 -1
- package/dist/components/ToggleGroup/styles.css.d.ts +19 -0
- package/dist/components/ToggleGroup/styles.css.d.ts.map +1 -1
- package/dist/components/ToggleGroup/useRovingFocus.d.ts +17 -0
- package/dist/components/ToggleGroup/useRovingFocus.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.js +2987 -2682
- package/dist/index.js.map +1 -1
- package/dist/table.js +1 -1
- package/package.json +2 -2
- package/src/components/ActionBar/ActionBar.meta.ts +7 -0
- package/src/components/ActionBar/ActionBar.stories.tsx +95 -3
- package/src/components/ActionBar/ActionBar.tsx +12 -1
- package/src/components/ActionBar/styles.css.ts +34 -2
- package/src/components/Combobox/Combobox.meta.ts +6 -0
- package/src/components/Combobox/Combobox.tsx +5 -0
- package/src/components/DetailPanel/DetailPanel.meta.ts +6 -0
- package/src/components/Drawer/Drawer.meta.ts +1 -1
- package/src/components/Drawer/Drawer.tsx +12 -2
- package/src/components/Drawer/DrawerScroll.stories.tsx +292 -0
- package/src/components/Drawer/useSwipeDismiss.ts +53 -8
- package/src/components/Menu/useMenuRoot.ts +5 -1
- package/src/components/Popover/Popover.meta.ts +7 -0
- package/src/components/Popover/Popover.tsx +30 -24
- package/src/components/Popover/PopoverLayerContext.ts +20 -0
- package/src/components/Popover/PopoverLayers.stories.tsx +234 -0
- package/src/components/ScrollArea/ScrollArea.meta.ts +1 -1
- package/src/components/ScrollArea/ScrollArea.tsx +12 -6
- package/src/components/Select/Select.meta.ts +6 -0
- package/src/components/Select/Select.tsx +8 -1
- package/src/components/Select/useSelectRoot.ts +6 -2
- package/src/components/Toggle/Toggle.meta.ts +1 -1
- package/src/components/Toggle/Toggle.tsx +19 -2
- package/src/components/Toggle/rowHeight.ts +12 -0
- package/src/components/Toggle/styles.css.ts +12 -0
- package/src/components/ToggleGroup/ToggleGroup.meta.ts +11 -1
- package/src/components/ToggleGroup/ToggleGroup.stories.tsx +156 -0
- package/src/components/ToggleGroup/ToggleGroup.tsx +87 -10
- package/src/components/ToggleGroup/ToggleGroupContext.ts +6 -0
- package/src/components/ToggleGroup/styles.css.ts +30 -1
- package/src/components/ToggleGroup/useRovingFocus.ts +121 -0
- package/dist/Table-JTgcR_Fh.js.map +0 -1
|
@@ -21,7 +21,12 @@ interface UseSwipeDismissOptions {
|
|
|
21
21
|
anchor: DrawerAnchor
|
|
22
22
|
enabled: boolean
|
|
23
23
|
onDismiss: () => void
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* The panel element itself, not a ref: a Drawer open from its first render
|
|
26
|
+
* draws the panel inline, then remounts it into the portal once the mount
|
|
27
|
+
* exists, and listeners must follow the element that is actually shown.
|
|
28
|
+
*/
|
|
29
|
+
panel: HTMLDivElement | null
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
interface SwipeState {
|
|
@@ -32,6 +37,9 @@ interface SwipeState {
|
|
|
32
37
|
startTime: number
|
|
33
38
|
swiping: boolean
|
|
34
39
|
panelSize: number
|
|
40
|
+
/** Inner Duro scroll viewport the gesture started in; capture is deferred until the axis is decided */
|
|
41
|
+
deferredScroll: HTMLElement | null
|
|
42
|
+
pointerId: number
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
/**
|
|
@@ -41,7 +49,7 @@ interface SwipeState {
|
|
|
41
49
|
* Opposite direction: iOS-style — panel stays anchored, shows subtle
|
|
42
50
|
* overscroll feedback (box-shadow intensifies), then snaps back.
|
|
43
51
|
*/
|
|
44
|
-
export function useSwipeDismiss({anchor, enabled, onDismiss,
|
|
52
|
+
export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDismissOptions) {
|
|
45
53
|
const stateRef = useRef<SwipeState>({
|
|
46
54
|
startX: 0,
|
|
47
55
|
startY: 0,
|
|
@@ -50,6 +58,8 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panelRef}: UseSwipe
|
|
|
50
58
|
startTime: 0,
|
|
51
59
|
swiping: false,
|
|
52
60
|
panelSize: 0,
|
|
61
|
+
deferredScroll: null,
|
|
62
|
+
pointerId: 0,
|
|
53
63
|
})
|
|
54
64
|
|
|
55
65
|
const isHorizontal = anchor === 'left' || anchor === 'right'
|
|
@@ -169,14 +179,18 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panelRef}: UseSwipe
|
|
|
169
179
|
}, [])
|
|
170
180
|
|
|
171
181
|
useEffect(() => {
|
|
172
|
-
if (!enabled) return
|
|
173
|
-
const panel = panelRef.current
|
|
174
|
-
if (!panel) return
|
|
182
|
+
if (!enabled || !panel) return
|
|
175
183
|
|
|
176
184
|
function handlePointerDown(e: PointerEvent) {
|
|
177
185
|
if (e.button !== 0) return
|
|
178
186
|
const target = e.target as HTMLElement
|
|
179
|
-
|
|
187
|
+
const scrollEl = target.closest<HTMLElement>('[data-duro-scroll]')
|
|
188
|
+
// Outside a ScrollArea a control keeps its press. Inside one the gesture
|
|
189
|
+
// is decided on the first move even from a control: a list of toggles is
|
|
190
|
+
// all buttons, and touch adjustment snaps a finger onto one. A tap
|
|
191
|
+
// (under the 8px slop) still presses it; a drag never captures before
|
|
192
|
+
// the decision, so the release lands on the panel, not the control.
|
|
193
|
+
if (!scrollEl && target.closest('button, a, input, textarea, select, [role="button"]')) {
|
|
180
194
|
return
|
|
181
195
|
}
|
|
182
196
|
|
|
@@ -190,14 +204,30 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panelRef}: UseSwipe
|
|
|
190
204
|
state.swiping = false
|
|
191
205
|
state.panelSize = isHorizontal ? rect.width : rect.height
|
|
192
206
|
|
|
207
|
+
state.pointerId = e.pointerId
|
|
208
|
+
state.deferredScroll = scrollEl
|
|
209
|
+
|
|
193
210
|
panel!.dataset.panelSize = String(state.panelSize)
|
|
194
|
-
|
|
211
|
+
|
|
212
|
+
// Inside a Duro ScrollArea the scroll may own the gesture: wait for the first move.
|
|
213
|
+
if (!state.deferredScroll) beginCapture(e.pointerId)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function beginCapture(pointerId: number) {
|
|
217
|
+
panel!.setPointerCapture(pointerId)
|
|
195
218
|
|
|
196
219
|
// Freeze CSS animation — we take over positioning.
|
|
197
220
|
// Never restore it; the panel is already at its final resting position.
|
|
198
221
|
panel!.style.animation = 'none'
|
|
199
222
|
}
|
|
200
223
|
|
|
224
|
+
/** True when the viewport can't scroll further against the dismiss direction */
|
|
225
|
+
function isAtDismissEdge(el: HTMLElement) {
|
|
226
|
+
if (!isHorizontal) return el.scrollTop <= 0
|
|
227
|
+
if (anchor === 'right') return el.scrollLeft <= 0
|
|
228
|
+
return el.scrollLeft + el.clientWidth >= el.scrollWidth - 1
|
|
229
|
+
}
|
|
230
|
+
|
|
201
231
|
function handlePointerMove(e: PointerEvent) {
|
|
202
232
|
const state = stateRef.current
|
|
203
233
|
if (state.startTime === 0) return
|
|
@@ -219,8 +249,21 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panelRef}: UseSwipe
|
|
|
219
249
|
} else {
|
|
220
250
|
// Wrong axis — abort gesture
|
|
221
251
|
state.startTime = 0
|
|
252
|
+
state.deferredScroll = null
|
|
222
253
|
return
|
|
223
254
|
}
|
|
255
|
+
|
|
256
|
+
const scrollEl = state.deferredScroll
|
|
257
|
+
if (scrollEl) {
|
|
258
|
+
state.deferredScroll = null
|
|
259
|
+
if (getMovement(state) <= 0 || !isAtDismissEdge(scrollEl)) {
|
|
260
|
+
// The scroll owns this gesture: no swiping, no capture, no preventDefault
|
|
261
|
+
state.swiping = false
|
|
262
|
+
state.startTime = 0
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
beginCapture(state.pointerId)
|
|
266
|
+
}
|
|
224
267
|
}
|
|
225
268
|
|
|
226
269
|
if (state.swiping) {
|
|
@@ -239,6 +282,7 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panelRef}: UseSwipe
|
|
|
239
282
|
const velocity = movement / elapsed
|
|
240
283
|
|
|
241
284
|
state.startTime = 0
|
|
285
|
+
state.deferredScroll = null
|
|
242
286
|
|
|
243
287
|
if (!state.swiping) {
|
|
244
288
|
return
|
|
@@ -266,6 +310,7 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panelRef}: UseSwipe
|
|
|
266
310
|
const state = stateRef.current
|
|
267
311
|
state.startTime = 0
|
|
268
312
|
state.swiping = false
|
|
313
|
+
state.deferredScroll = null
|
|
269
314
|
resetStyles(panel!)
|
|
270
315
|
}
|
|
271
316
|
|
|
@@ -290,7 +335,7 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panelRef}: UseSwipe
|
|
|
290
335
|
}
|
|
291
336
|
}, [
|
|
292
337
|
enabled,
|
|
293
|
-
|
|
338
|
+
panel,
|
|
294
339
|
anchor,
|
|
295
340
|
isHorizontal,
|
|
296
341
|
getMovement,
|
|
@@ -6,6 +6,7 @@ export const meta: ComponentMeta = {
|
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'A small form or controls tied to one element that stay open while you work in them',
|
|
8
8
|
'Anchoring to a spot that is not a button (a canvas point, a text selection) with a virtual anchor',
|
|
9
|
+
'Holding a Select or Combobox: their portalled list counts as inside the popover, so choosing an item keeps it open; Escape closes an open Select, Combobox or Menu before the popover',
|
|
9
10
|
],
|
|
10
11
|
whenNotToUse: [
|
|
11
12
|
'A list of actions — use Menu',
|
|
@@ -47,6 +48,12 @@ export const meta: ComponentMeta = {
|
|
|
47
48
|
relationship:
|
|
48
49
|
'Drawer is the modal edge panel for phone width; Popover is anchored and non-modal',
|
|
49
50
|
},
|
|
51
|
+
{
|
|
52
|
+
component: 'Select',
|
|
53
|
+
kind: 'composition',
|
|
54
|
+
relationship:
|
|
55
|
+
'A Select or Combobox inside Popover.Popup keeps the popover open while you choose; a Select backdrop press closes only its list',
|
|
56
|
+
},
|
|
50
57
|
{
|
|
51
58
|
component: 'Field',
|
|
52
59
|
kind: 'composition',
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
useId,
|
|
9
9
|
useImperativeHandle,
|
|
10
10
|
useLayoutEffect,
|
|
11
|
+
useMemo,
|
|
11
12
|
useRef,
|
|
12
13
|
useState,
|
|
13
14
|
} from 'react'
|
|
@@ -17,6 +18,7 @@ import {usePortalMount} from '../ThemeProvider/ThemeProvider'
|
|
|
17
18
|
import {styles} from './styles.css'
|
|
18
19
|
import {computePopoverPosition, type PopoverAlign, type PopoverSide} from './position'
|
|
19
20
|
import {isOutsidePress} from './outside'
|
|
21
|
+
import {PopoverLayerContext, type PopoverLayerContextValue} from './PopoverLayerContext'
|
|
20
22
|
|
|
21
23
|
// --- Context ---
|
|
22
24
|
|
|
@@ -47,27 +49,6 @@ const FOCUSABLE =
|
|
|
47
49
|
|
|
48
50
|
const OFFSET = 4 // `xs` token
|
|
49
51
|
|
|
50
|
-
/**
|
|
51
|
-
* Whether a nested popup control owns this Escape: a Select, Menu or
|
|
52
|
-
* Combobox inside the popup closes on Esc without preventing default, and
|
|
53
|
-
* that Esc must close only it. Only controls that open a popup count — an
|
|
54
|
-
* expanded trigger that declares one (`aria-haspopup`), an expanded combobox,
|
|
55
|
-
* or focus inside a listbox or menu — never a plain disclosure, and never
|
|
56
|
-
* this popover's own Trigger.
|
|
57
|
-
*
|
|
58
|
-
* holds-until: Duro's Select, Menu and Combobox call preventDefault on the
|
|
59
|
-
* Escape they consume; the defaultPrevented check alone then suffices and
|
|
60
|
-
* this selector goes.
|
|
61
|
-
*/
|
|
62
|
-
const NESTED_POPUP_OWNER =
|
|
63
|
-
'[aria-expanded="true"][aria-haspopup], [role="combobox"][aria-expanded="true"], [role="listbox"], [role="menu"]'
|
|
64
|
-
|
|
65
|
-
function nestedOwnsEscape(target: EventTarget | null, ownTrigger: Element | null): boolean {
|
|
66
|
-
if (!(target instanceof Element)) return false
|
|
67
|
-
const owner = target.closest(NESTED_POPUP_OWNER)
|
|
68
|
-
return owner !== null && owner !== ownTrigger
|
|
69
|
-
}
|
|
70
|
-
|
|
71
52
|
// --- Root ---
|
|
72
53
|
|
|
73
54
|
export interface PopoverHandle {
|
|
@@ -148,7 +129,6 @@ function Root({
|
|
|
148
129
|
|
|
149
130
|
function onKeyDown(e: KeyboardEvent) {
|
|
150
131
|
if (e.key !== 'Escape' || e.defaultPrevented) return
|
|
151
|
-
if (nestedOwnsEscape(e.target, triggerRef.current)) return
|
|
152
132
|
e.preventDefault()
|
|
153
133
|
setOpen(false)
|
|
154
134
|
}
|
|
@@ -157,7 +137,7 @@ function Root({
|
|
|
157
137
|
const ignored = (ignoreRef.current ?? []).map((entry) =>
|
|
158
138
|
typeof entry === 'function' ? entry() : entry,
|
|
159
139
|
)
|
|
160
|
-
const inside = [popupRef.current, triggerRef.current, ...ignored]
|
|
140
|
+
const inside = [popupRef.current, triggerRef.current, ...ignored, ...layersRef.current]
|
|
161
141
|
if (isOutsidePress(e.target as Node | null, inside)) setOpen(false)
|
|
162
142
|
}
|
|
163
143
|
|
|
@@ -169,6 +149,28 @@ function Root({
|
|
|
169
149
|
}
|
|
170
150
|
}, [open, setOpen])
|
|
171
151
|
|
|
152
|
+
const layersRef = useRef(new Set<Element>())
|
|
153
|
+
// A nested Popover's layers are inside its parent too.
|
|
154
|
+
const parentLayer = useContext(PopoverLayerContext)
|
|
155
|
+
const layerCtx = useMemo<PopoverLayerContextValue>(
|
|
156
|
+
() => ({
|
|
157
|
+
register: (el) => {
|
|
158
|
+
layersRef.current.add(el)
|
|
159
|
+
const unregisterParent = parentLayer?.register(el)
|
|
160
|
+
return () => {
|
|
161
|
+
layersRef.current.delete(el)
|
|
162
|
+
unregisterParent?.()
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
}),
|
|
166
|
+
[parentLayer],
|
|
167
|
+
)
|
|
168
|
+
useLayoutEffect(() => {
|
|
169
|
+
const popup = popupRef.current
|
|
170
|
+
if (!open || !popup) return
|
|
171
|
+
return parentLayer?.register(popup)
|
|
172
|
+
}, [open, parentLayer])
|
|
173
|
+
|
|
172
174
|
const ctx: PopoverContextValue = {
|
|
173
175
|
open,
|
|
174
176
|
setOpen,
|
|
@@ -181,7 +183,11 @@ function Root({
|
|
|
181
183
|
repositionRef,
|
|
182
184
|
}
|
|
183
185
|
|
|
184
|
-
return
|
|
186
|
+
return (
|
|
187
|
+
<PopoverContext.Provider value={ctx}>
|
|
188
|
+
<PopoverLayerContext.Provider value={layerCtx}>{children}</PopoverLayerContext.Provider>
|
|
189
|
+
</PopoverContext.Provider>
|
|
190
|
+
)
|
|
185
191
|
}
|
|
186
192
|
|
|
187
193
|
// --- Trigger ---
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {type RefObject, createContext, useContext, useLayoutEffect} from 'react'
|
|
2
|
+
|
|
3
|
+
// A Popover's nested popups (a Select listbox, a Combobox listbox, a child
|
|
4
|
+
// Popover) are portalled out of its DOM subtree. They register here so a press
|
|
5
|
+
// on them counts as inside the Popover.
|
|
6
|
+
export interface PopoverLayerContextValue {
|
|
7
|
+
register: (el: Element) => () => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const PopoverLayerContext = createContext<PopoverLayerContextValue | null>(null)
|
|
11
|
+
|
|
12
|
+
/** Register the ref's element as part of the enclosing Popover's layer while `active`. */
|
|
13
|
+
export function usePopoverLayer(ref: RefObject<Element | null>, {active}: {active: boolean}) {
|
|
14
|
+
const layer = useContext(PopoverLayerContext)
|
|
15
|
+
useLayoutEffect(() => {
|
|
16
|
+
const el = ref.current
|
|
17
|
+
if (!layer || !active || !el) return
|
|
18
|
+
return layer.register(el)
|
|
19
|
+
}, [layer, active, ref])
|
|
20
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
+
import {expect, waitFor, within} from 'storybook/test'
|
|
3
|
+
import {userEvent} from '@testing-library/user-event'
|
|
4
|
+
import {html} from 'react-strict-dom'
|
|
5
|
+
import {Popover} from './Popover'
|
|
6
|
+
import {Select} from '../Select/Select'
|
|
7
|
+
import {Combobox} from '../Combobox/Combobox'
|
|
8
|
+
import {Menu} from '../Menu/Menu'
|
|
9
|
+
import {Stack} from '../Stack/Stack'
|
|
10
|
+
|
|
11
|
+
const meta: Meta = {
|
|
12
|
+
title: 'Components/Popover/Layers',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default meta
|
|
16
|
+
type Story = StoryObj
|
|
17
|
+
|
|
18
|
+
// Nested popups are portalled out of the canvas, so query the document.
|
|
19
|
+
const page = () => within(document.body)
|
|
20
|
+
|
|
21
|
+
const dialog = () => page().queryByRole('dialog', {name: 'Style'})
|
|
22
|
+
|
|
23
|
+
function SelectItems() {
|
|
24
|
+
return (
|
|
25
|
+
<Select.Popup>
|
|
26
|
+
<Select.Item value="primary">
|
|
27
|
+
<Select.ItemText>Primary</Select.ItemText>
|
|
28
|
+
</Select.Item>
|
|
29
|
+
<Select.Item value="secondary">
|
|
30
|
+
<Select.ItemText>Secondary</Select.ItemText>
|
|
31
|
+
</Select.Item>
|
|
32
|
+
</Select.Popup>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function VariantSelect() {
|
|
37
|
+
return (
|
|
38
|
+
<Select.Root defaultValue="primary">
|
|
39
|
+
<Select.Trigger aria-label="Variant">
|
|
40
|
+
<Select.Value />
|
|
41
|
+
<Select.Icon />
|
|
42
|
+
</Select.Trigger>
|
|
43
|
+
<SelectItems />
|
|
44
|
+
</Select.Root>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function FruitCombobox() {
|
|
49
|
+
return (
|
|
50
|
+
<Combobox.Root>
|
|
51
|
+
<Combobox.Input placeholder="Search fruit...">
|
|
52
|
+
<Combobox.Trigger />
|
|
53
|
+
</Combobox.Input>
|
|
54
|
+
<Combobox.Popup>
|
|
55
|
+
<Combobox.Item value="apple">
|
|
56
|
+
<Combobox.ItemText>Apple</Combobox.ItemText>
|
|
57
|
+
</Combobox.Item>
|
|
58
|
+
<Combobox.Item value="banana">
|
|
59
|
+
<Combobox.ItemText>Banana</Combobox.ItemText>
|
|
60
|
+
</Combobox.Item>
|
|
61
|
+
</Combobox.Popup>
|
|
62
|
+
</Combobox.Root>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function Host({children}: {children: React.ReactNode}) {
|
|
67
|
+
return (
|
|
68
|
+
<Stack gap="md">
|
|
69
|
+
<Popover.Root>
|
|
70
|
+
<Popover.Trigger>Style</Popover.Trigger>
|
|
71
|
+
<Popover.Popup label="Style">{children}</Popover.Popup>
|
|
72
|
+
</Popover.Root>
|
|
73
|
+
<html.button type="button">Elsewhere</html.button>
|
|
74
|
+
</Stack>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const SelectOutsideField: Story = {
|
|
79
|
+
render: () => (
|
|
80
|
+
<Host>
|
|
81
|
+
<VariantSelect />
|
|
82
|
+
</Host>
|
|
83
|
+
),
|
|
84
|
+
play: async ({canvas}) => {
|
|
85
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Style'}))
|
|
86
|
+
const trigger = page().getByRole('combobox', {name: 'Variant'})
|
|
87
|
+
await userEvent.click(trigger)
|
|
88
|
+
await userEvent.click(page().getByRole('option', {name: 'Secondary'}))
|
|
89
|
+
await expect(trigger).toHaveTextContent('Secondary')
|
|
90
|
+
await expect(dialog()).toBeInTheDocument()
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const ComboboxOutsideField: Story = {
|
|
95
|
+
render: () => (
|
|
96
|
+
<Host>
|
|
97
|
+
<FruitCombobox />
|
|
98
|
+
</Host>
|
|
99
|
+
),
|
|
100
|
+
play: async ({canvas}) => {
|
|
101
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Style'}))
|
|
102
|
+
const input = page().getByRole('combobox')
|
|
103
|
+
await userEvent.click(input)
|
|
104
|
+
await userEvent.click(await page().findByRole('option', {name: 'Banana'}))
|
|
105
|
+
await waitFor(() => expect(input).toHaveValue('Banana'))
|
|
106
|
+
await expect(dialog()).toBeInTheDocument()
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const SelectBackdropPress: Story = {
|
|
111
|
+
render: () => (
|
|
112
|
+
<Host>
|
|
113
|
+
<VariantSelect />
|
|
114
|
+
</Host>
|
|
115
|
+
),
|
|
116
|
+
play: async ({canvas}) => {
|
|
117
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Style'}))
|
|
118
|
+
const trigger = page().getByRole('combobox', {name: 'Variant'})
|
|
119
|
+
await userEvent.click(trigger)
|
|
120
|
+
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
121
|
+
|
|
122
|
+
// The backdrop covers the viewport: a press at the corner lands on it.
|
|
123
|
+
const backdrop = document.elementFromPoint(2, 2) as HTMLElement
|
|
124
|
+
await userEvent.click(backdrop)
|
|
125
|
+
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
|
126
|
+
await expect(dialog()).toBeInTheDocument()
|
|
127
|
+
|
|
128
|
+
// With the Select closed, an outside press closes the Popover.
|
|
129
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Elsewhere'}))
|
|
130
|
+
await expect(dialog()).not.toBeInTheDocument()
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const SelectOutsidePopover: Story = {
|
|
135
|
+
render: () => (
|
|
136
|
+
<Stack gap="md">
|
|
137
|
+
<Popover.Root>
|
|
138
|
+
<Popover.Trigger>Style</Popover.Trigger>
|
|
139
|
+
<Popover.Popup label="Style">
|
|
140
|
+
<Popover.Close>Done</Popover.Close>
|
|
141
|
+
</Popover.Popup>
|
|
142
|
+
</Popover.Root>
|
|
143
|
+
<VariantSelect />
|
|
144
|
+
</Stack>
|
|
145
|
+
),
|
|
146
|
+
play: async ({canvas}) => {
|
|
147
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Style'}))
|
|
148
|
+
await expect(dialog()).toBeInTheDocument()
|
|
149
|
+
// The Select is not inside the Popover: choosing from it is an outside press.
|
|
150
|
+
await userEvent.click(canvas.getByRole('combobox', {name: 'Variant'}))
|
|
151
|
+
await userEvent.click(page().getByRole('option', {name: 'Secondary'}))
|
|
152
|
+
await expect(dialog()).not.toBeInTheDocument()
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const EscapeInNestedSelect: Story = {
|
|
157
|
+
render: () => (
|
|
158
|
+
<Host>
|
|
159
|
+
<VariantSelect />
|
|
160
|
+
</Host>
|
|
161
|
+
),
|
|
162
|
+
play: async ({canvas}) => {
|
|
163
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Style'}))
|
|
164
|
+
const trigger = page().getByRole('combobox', {name: 'Variant'})
|
|
165
|
+
await userEvent.click(trigger)
|
|
166
|
+
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
167
|
+
await userEvent.keyboard('{Escape}')
|
|
168
|
+
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
|
169
|
+
await expect(dialog()).toBeInTheDocument()
|
|
170
|
+
await userEvent.keyboard('{Escape}')
|
|
171
|
+
await expect(dialog()).not.toBeInTheDocument()
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const EscapeInNestedMenu: Story = {
|
|
176
|
+
render: () => (
|
|
177
|
+
<Host>
|
|
178
|
+
<Menu.Root>
|
|
179
|
+
<Menu.Trigger>Actions</Menu.Trigger>
|
|
180
|
+
<Menu.Popup>
|
|
181
|
+
<Menu.Item>Copy</Menu.Item>
|
|
182
|
+
<Menu.Item>Paste</Menu.Item>
|
|
183
|
+
</Menu.Popup>
|
|
184
|
+
</Menu.Root>
|
|
185
|
+
</Host>
|
|
186
|
+
),
|
|
187
|
+
play: async ({canvas}) => {
|
|
188
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Style'}))
|
|
189
|
+
await userEvent.click(page().getByRole('button', {name: 'Actions'}))
|
|
190
|
+
await expect(await page().findByRole('menu')).toBeVisible()
|
|
191
|
+
await userEvent.keyboard('{Escape}')
|
|
192
|
+
await waitFor(() => expect(page().queryByRole('menu')).not.toBeInTheDocument())
|
|
193
|
+
await expect(dialog()).toBeInTheDocument()
|
|
194
|
+
await userEvent.keyboard('{Escape}')
|
|
195
|
+
await expect(dialog()).not.toBeInTheDocument()
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export const EscapeInNestedCombobox: Story = {
|
|
200
|
+
render: () => (
|
|
201
|
+
<Host>
|
|
202
|
+
<FruitCombobox />
|
|
203
|
+
</Host>
|
|
204
|
+
),
|
|
205
|
+
play: async ({canvas}) => {
|
|
206
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Style'}))
|
|
207
|
+
const input = page().getByRole('combobox')
|
|
208
|
+
await userEvent.click(input)
|
|
209
|
+
await expect(await page().findByRole('listbox')).toBeVisible()
|
|
210
|
+
await userEvent.keyboard('{Escape}')
|
|
211
|
+
await waitFor(() => expect(page().queryByRole('listbox')).not.toBeInTheDocument())
|
|
212
|
+
await expect(dialog()).toBeInTheDocument()
|
|
213
|
+
await userEvent.keyboard('{Escape}')
|
|
214
|
+
await expect(dialog()).not.toBeInTheDocument()
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export const TabFromNestedSelect: Story = {
|
|
219
|
+
render: () => (
|
|
220
|
+
<Host>
|
|
221
|
+
<VariantSelect />
|
|
222
|
+
<html.button type="button">After</html.button>
|
|
223
|
+
</Host>
|
|
224
|
+
),
|
|
225
|
+
play: async ({canvas}) => {
|
|
226
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Style'}))
|
|
227
|
+
const trigger = page().getByRole('combobox', {name: 'Variant'})
|
|
228
|
+
await userEvent.click(trigger)
|
|
229
|
+
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
230
|
+
await userEvent.tab()
|
|
231
|
+
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
|
232
|
+
await waitFor(() => expect(trigger).not.toHaveFocus())
|
|
233
|
+
},
|
|
234
|
+
}
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Custom scrollbar region with draggable thumb. Supports vertical and horizontal scrolling. Compound component — Root is required.',
|
|
5
|
+
'Custom scrollbar region with draggable thumb. Supports vertical and horizontal scrolling. Compound component — Root is required. Inside a Drawer, a drag in the viewport scrolls it; the drawer only takes a drag toward its edge from the scroll start.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Custom-styled scrollbars for content overflow areas',
|
|
8
8
|
'Chat logs, code blocks, or long lists with constrained height',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {type ReactNode, useRef, useCallback} from 'react'
|
|
1
|
+
import {type ComponentProps, type ReactNode, useRef, useCallback} from 'react'
|
|
2
2
|
import {html} from 'react-strict-dom'
|
|
3
3
|
import {styles} from './styles.css'
|
|
4
4
|
import {ScrollAreaContext, useScrollArea} from './ScrollAreaContext'
|
|
@@ -25,19 +25,25 @@ function Root({children}: RootProps) {
|
|
|
25
25
|
interface ViewportProps {
|
|
26
26
|
children: ReactNode
|
|
27
27
|
maxHeight?: number | string
|
|
28
|
+
/** Extra styles for the viewport (e.g. a pointer-aware max height). */
|
|
29
|
+
style?: ComponentProps<typeof html.div>['style']
|
|
30
|
+
/** A roving-focus group owns the keyboard: drop the viewport's own tab stop
|
|
31
|
+
* and region role. */
|
|
32
|
+
rovingOwner?: boolean
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
function Viewport({children, maxHeight}: ViewportProps) {
|
|
35
|
+
function Viewport({children, maxHeight, style, rovingOwner = false}: ViewportProps) {
|
|
31
36
|
const {viewportRef, viewportId} = useScrollArea()
|
|
32
37
|
|
|
33
38
|
return (
|
|
34
39
|
<html.div
|
|
35
40
|
ref={viewportRef}
|
|
36
41
|
id={viewportId}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
data-duro-scroll=""
|
|
43
|
+
tabIndex={rovingOwner ? undefined : 0}
|
|
44
|
+
role={rovingOwner ? undefined : 'region'}
|
|
45
|
+
aria-label={rovingOwner ? undefined : 'Scrollable content'}
|
|
46
|
+
style={[styles.viewport, maxHeight != null && styles.viewportMaxHeight(maxHeight), style]}
|
|
41
47
|
>
|
|
42
48
|
{children}
|
|
43
49
|
</html.div>
|
|
@@ -17,6 +17,12 @@ export const meta: ComponentMeta = {
|
|
|
17
17
|
optional: ['Value', 'Icon', 'ItemText'],
|
|
18
18
|
},
|
|
19
19
|
relatedTo: [
|
|
20
|
+
{
|
|
21
|
+
component: 'Popover',
|
|
22
|
+
kind: 'composition',
|
|
23
|
+
relationship:
|
|
24
|
+
'Inside Popover.Popup a Select keeps the popover open while you choose; its backdrop press and Escape close only the list',
|
|
25
|
+
},
|
|
20
26
|
{
|
|
21
27
|
component: 'Menu',
|
|
22
28
|
kind: 'contrast',
|
|
@@ -6,6 +6,7 @@ import {SelectContext, useSelect} from './SelectContext'
|
|
|
6
6
|
import {useSelectRoot} from './useSelectRoot'
|
|
7
7
|
import {useFieldContext} from '../Field/FieldContext'
|
|
8
8
|
import {usePortalMount} from '../ThemeProvider/ThemeProvider'
|
|
9
|
+
import {usePopoverLayer} from '../Popover/PopoverLayerContext'
|
|
9
10
|
|
|
10
11
|
// --- Root ---
|
|
11
12
|
interface RootProps {
|
|
@@ -141,10 +142,16 @@ function Popup({children}: {children: ReactNode}) {
|
|
|
141
142
|
}
|
|
142
143
|
}, [open, triggerRef])
|
|
143
144
|
|
|
145
|
+
const listboxRef = useRef<HTMLDivElement>(null)
|
|
146
|
+
const backdropRef = useRef<HTMLDivElement>(null)
|
|
147
|
+
usePopoverLayer(listboxRef, {active: open})
|
|
148
|
+
usePopoverLayer(backdropRef, {active: open})
|
|
149
|
+
|
|
144
150
|
const node = (
|
|
145
151
|
<>
|
|
146
|
-
{open && <html.div style={styles.backdrop} onClick={close} />}
|
|
152
|
+
{open && <html.div ref={backdropRef} style={styles.backdrop} onClick={close} />}
|
|
147
153
|
<html.div
|
|
154
|
+
ref={listboxRef}
|
|
148
155
|
id={listboxId}
|
|
149
156
|
role="listbox"
|
|
150
157
|
aria-hidden={!open}
|
|
@@ -88,7 +88,12 @@ export function useSelectRoot({
|
|
|
88
88
|
function handleKeyDown(e: KeyboardEvent) {
|
|
89
89
|
const order = orderRef.current
|
|
90
90
|
const items = itemsRef.current
|
|
91
|
-
if (
|
|
91
|
+
if (e.key === 'Escape') {
|
|
92
|
+
e.preventDefault()
|
|
93
|
+
close()
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (order.length === 0 && e.key !== 'Tab') return
|
|
92
97
|
|
|
93
98
|
switch (e.key) {
|
|
94
99
|
case 'ArrowDown': {
|
|
@@ -132,7 +137,6 @@ export function useSelectRoot({
|
|
|
132
137
|
})
|
|
133
138
|
break
|
|
134
139
|
}
|
|
135
|
-
case 'Escape':
|
|
136
140
|
case 'Tab': {
|
|
137
141
|
close()
|
|
138
142
|
break
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Toggle button with pressed/unpressed state. Works standalone or within ToggleGroup for multi/single selection.',
|
|
5
|
+
'Toggle button with pressed/unpressed state. Works standalone or within ToggleGroup for multi/single selection. Under a coarse pointer (touch) every Toggle is at least 44×44px; in a wrapping ToggleGroup each keeps its own border and a fixed row height.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Toolbar buttons that toggle on/off (bold, italic, grid view)',
|
|
8
8
|
'Within ToggleGroup for selecting from visible options',
|