@duro-app/ui 3.4.0 → 3.4.2
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/Combobox/styles.css.d.ts +2 -1
- package/dist/components/Combobox/styles.css.d.ts.map +1 -1
- package/dist/components/Dialog/Dialog.d.ts.map +1 -1
- package/dist/components/Dialog/Dialog.stories.d.ts +3 -0
- package/dist/components/Dialog/Dialog.stories.d.ts.map +1 -1
- package/dist/components/Dialog/styles.css.d.ts +1 -0
- package/dist/components/Dialog/styles.css.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.d.ts.map +1 -1
- package/dist/components/Drawer/styles.css.d.ts +1 -0
- package/dist/components/Drawer/styles.css.d.ts.map +1 -1
- package/dist/components/Select/styles.css.d.ts +2 -2
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +1072 -1063
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/Combobox/styles.css.ts +4 -1
- package/src/components/Dialog/Dialog.stories.tsx +133 -1
- package/src/components/Dialog/Dialog.tsx +11 -1
- package/src/components/Dialog/styles.css.ts +3 -0
- package/src/components/Drawer/Drawer.tsx +11 -1
- package/src/components/Drawer/styles.css.ts +3 -0
- package/src/components/Select/styles.css.ts +4 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@tanstack/react-virtual": "^3.14.6",
|
|
67
|
-
"@duro-app/tokens": "^3.4.
|
|
67
|
+
"@duro-app/tokens": "^3.4.2"
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
70
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -61,7 +61,9 @@ export const styles = css.create({
|
|
|
61
61
|
left: 0,
|
|
62
62
|
right: 0,
|
|
63
63
|
bottom: 0,
|
|
64
|
-
|
|
64
|
+
// Above Dialog/Drawer (1000/1001): they portal into the same mount, so a
|
|
65
|
+
// popup opened from inside a dialog must out-rank the dialog itself.
|
|
66
|
+
zIndex: 1049,
|
|
65
67
|
},
|
|
66
68
|
popup: {
|
|
67
69
|
// Position is `fixed` and the top/left/width are applied inline by Popup
|
|
@@ -78,6 +80,7 @@ export const styles = css.create({
|
|
|
78
80
|
paddingBottom: spacing.xs,
|
|
79
81
|
maxHeight: 200,
|
|
80
82
|
overflowY: 'auto',
|
|
83
|
+
zIndex: 1050,
|
|
81
84
|
// Re-enable pointer events: the portal mount is pointer-events: none so
|
|
82
85
|
// clicks fall through except on the popup itself.
|
|
83
86
|
pointerEvents: 'auto',
|
|
@@ -9,7 +9,9 @@ import {Inline} from '../Inline/Inline'
|
|
|
9
9
|
import {Stack} from '../Stack/Stack'
|
|
10
10
|
import {Input} from '../Input/Input'
|
|
11
11
|
import {Field} from '../Field/Field'
|
|
12
|
-
import {
|
|
12
|
+
import {Select} from '../Select/Select'
|
|
13
|
+
import {spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
14
|
+
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
13
15
|
|
|
14
16
|
const meta: Meta = {
|
|
15
17
|
title: 'Components/Dialog',
|
|
@@ -321,3 +323,133 @@ function BackdropDismissDemo() {
|
|
|
321
323
|
export const BackdropDismiss: Story = {
|
|
322
324
|
render: () => <BackdropDismissDemo />,
|
|
323
325
|
}
|
|
326
|
+
|
|
327
|
+
// --- Inside a transformed ancestor ---
|
|
328
|
+
// A fixed overlay inside an ancestor with transform / filter / backdrop-filter
|
|
329
|
+
// is positioned against that ancestor, not the viewport. A consumer's sticky
|
|
330
|
+
// blurred top bar is exactly that: the dialog must still centre on the screen,
|
|
331
|
+
// which it does because Portal renders into the ThemeProvider mount.
|
|
332
|
+
|
|
333
|
+
const barStyles = css.create({
|
|
334
|
+
bar: {
|
|
335
|
+
transform: 'translateZ(0)',
|
|
336
|
+
padding: spacing.sm,
|
|
337
|
+
borderWidth: 1,
|
|
338
|
+
borderStyle: 'solid',
|
|
339
|
+
borderColor: colors.border,
|
|
340
|
+
},
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
export const InsideTransformedAncestor: Story = {
|
|
344
|
+
render: () => (
|
|
345
|
+
<html.div style={barStyles.bar}>
|
|
346
|
+
<Dialog.Root>
|
|
347
|
+
<Dialog.Trigger>
|
|
348
|
+
<Button>Open from the bar</Button>
|
|
349
|
+
</Dialog.Trigger>
|
|
350
|
+
<Dialog.Portal size="sm">
|
|
351
|
+
<Dialog.Header>
|
|
352
|
+
<Dialog.Title>Centred on the screen</Dialog.Title>
|
|
353
|
+
<Dialog.Close />
|
|
354
|
+
</Dialog.Header>
|
|
355
|
+
<Dialog.Body>
|
|
356
|
+
<Text>Not on the bar it was opened from.</Text>
|
|
357
|
+
</Dialog.Body>
|
|
358
|
+
</Dialog.Portal>
|
|
359
|
+
</Dialog.Root>
|
|
360
|
+
</html.div>
|
|
361
|
+
),
|
|
362
|
+
play: async ({canvasElement}) => {
|
|
363
|
+
const canvas = within(canvasElement)
|
|
364
|
+
await userEvent.click(canvas.getByText('Open from the bar'))
|
|
365
|
+
const doc = canvasElement.ownerDocument
|
|
366
|
+
const dialog = doc.querySelector('[role="dialog"]') as HTMLElement
|
|
367
|
+
await expect(dialog).not.toBeNull()
|
|
368
|
+
const bar = canvas.getByText('Open from the bar').closest('div') as HTMLElement
|
|
369
|
+
// rendered outside the bar…
|
|
370
|
+
await expect(bar.contains(dialog)).toBe(false)
|
|
371
|
+
// …and fully on screen, not centred on a bar at the top edge
|
|
372
|
+
const r = dialog.getBoundingClientRect()
|
|
373
|
+
await expect(r.top).toBeGreaterThanOrEqual(0)
|
|
374
|
+
await expect(r.bottom).toBeLessThanOrEqual(doc.defaultView!.innerHeight)
|
|
375
|
+
},
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// --- A Select inside a Dialog ---
|
|
379
|
+
// Dialog and Select both render into the ThemeProvider portal mount, so the
|
|
380
|
+
// Select's listbox must stack ABOVE the dialog or its options cannot be
|
|
381
|
+
// picked (they were covered by the dialog when Dialog started portaling).
|
|
382
|
+
|
|
383
|
+
function SelectInDialogDemo() {
|
|
384
|
+
const [value, setValue] = useState('en')
|
|
385
|
+
return (
|
|
386
|
+
<Dialog.Root>
|
|
387
|
+
<Dialog.Trigger>
|
|
388
|
+
<Button>Open settings</Button>
|
|
389
|
+
</Dialog.Trigger>
|
|
390
|
+
<Dialog.Portal size="sm">
|
|
391
|
+
<Dialog.Header>
|
|
392
|
+
<Dialog.Title>Settings</Dialog.Title>
|
|
393
|
+
<Dialog.Close />
|
|
394
|
+
</Dialog.Header>
|
|
395
|
+
<Dialog.Body>
|
|
396
|
+
<Select.Root value={value} onValueChange={(v) => v && setValue(v)}>
|
|
397
|
+
<Select.Trigger aria-label="Language">
|
|
398
|
+
<Select.Value />
|
|
399
|
+
<Select.Icon />
|
|
400
|
+
</Select.Trigger>
|
|
401
|
+
<Select.Popup>
|
|
402
|
+
<Select.Item value="en">
|
|
403
|
+
<Select.ItemText>English</Select.ItemText>
|
|
404
|
+
</Select.Item>
|
|
405
|
+
<Select.Item value="fr">
|
|
406
|
+
<Select.ItemText>Français</Select.ItemText>
|
|
407
|
+
</Select.Item>
|
|
408
|
+
</Select.Popup>
|
|
409
|
+
</Select.Root>
|
|
410
|
+
<Text>Chosen: {value}</Text>
|
|
411
|
+
</Dialog.Body>
|
|
412
|
+
</Dialog.Portal>
|
|
413
|
+
</Dialog.Root>
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export const SelectInsideDialog: Story = {
|
|
418
|
+
render: () => <SelectInDialogDemo />,
|
|
419
|
+
play: async ({canvasElement}) => {
|
|
420
|
+
const canvas = within(canvasElement)
|
|
421
|
+
const doc = within(canvasElement.ownerDocument.body)
|
|
422
|
+
await userEvent.click(canvas.getByText('Open settings'))
|
|
423
|
+
await userEvent.click(await doc.findByRole('combobox', {name: 'Language'}))
|
|
424
|
+
// Hit-test every option: an option that sits over the dialog panel (not
|
|
425
|
+
// only over the click-through backdrop) must still be the topmost element.
|
|
426
|
+
for (const name of ['English', 'Français']) {
|
|
427
|
+
const opt = await doc.findByRole('option', {name})
|
|
428
|
+
const r = opt.getBoundingClientRect()
|
|
429
|
+
const top = canvasElement.ownerDocument.elementFromPoint(
|
|
430
|
+
r.left + r.width / 2,
|
|
431
|
+
r.top + r.height / 2,
|
|
432
|
+
)
|
|
433
|
+
await expect(opt.contains(top)).toBe(true)
|
|
434
|
+
}
|
|
435
|
+
const option = await doc.findByRole('option', {name: 'Français'})
|
|
436
|
+
await userEvent.click(option)
|
|
437
|
+
await expect(await doc.findByText('Chosen: fr')).toBeInTheDocument()
|
|
438
|
+
},
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// --- Backdrop click dismisses (the portal mount is pointer-events: none) ---
|
|
442
|
+
|
|
443
|
+
export const BackdropClickDismisses: Story = {
|
|
444
|
+
render: () => <BackdropDismissDemo />,
|
|
445
|
+
play: async ({canvasElement}) => {
|
|
446
|
+
const canvas = within(canvasElement)
|
|
447
|
+
const docEl = canvasElement.ownerDocument
|
|
448
|
+
await userEvent.click(canvas.getByText('Open dialog'))
|
|
449
|
+
await expect(docEl.querySelector('[role="dialog"]')).not.toBeNull()
|
|
450
|
+
// a point on the backdrop, well outside the centred dialog
|
|
451
|
+
const target = docEl.elementFromPoint(5, 5) as HTMLElement
|
|
452
|
+
await userEvent.click(target)
|
|
453
|
+
await expect(await canvas.findByText('Last action: Dismissed')).toBeInTheDocument()
|
|
454
|
+
},
|
|
455
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {createPortal} from 'react-dom'
|
|
1
2
|
import {
|
|
2
3
|
type ReactNode,
|
|
3
4
|
type RefObject,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
import {html} from 'react-strict-dom'
|
|
13
14
|
import {DURATION_MS, type DurationToken} from '@duro-app/tokens/keys'
|
|
14
15
|
import {styles} from './styles.css'
|
|
16
|
+
import {usePortalMount} from '../ThemeProvider/ThemeProvider'
|
|
15
17
|
|
|
16
18
|
// --- Types ---
|
|
17
19
|
|
|
@@ -194,6 +196,7 @@ function Portal({children, size = 'md'}: PortalProps) {
|
|
|
194
196
|
descriptionId,
|
|
195
197
|
popupRef,
|
|
196
198
|
} = useDialog()
|
|
199
|
+
const mount = usePortalMount()
|
|
197
200
|
|
|
198
201
|
const handleBackdropClick = useCallback(() => {
|
|
199
202
|
if (dismissable) {
|
|
@@ -203,7 +206,7 @@ function Portal({children, size = 'md'}: PortalProps) {
|
|
|
203
206
|
|
|
204
207
|
if (!open) return null
|
|
205
208
|
|
|
206
|
-
|
|
209
|
+
const node = (
|
|
207
210
|
<>
|
|
208
211
|
{/* Backdrop — click to dismiss */}
|
|
209
212
|
<html.div
|
|
@@ -238,6 +241,13 @@ function Portal({children, size = 'md'}: PortalProps) {
|
|
|
238
241
|
</html.div>
|
|
239
242
|
</>
|
|
240
243
|
)
|
|
244
|
+
|
|
245
|
+
// Out of the tree into the ThemeProvider mount (like Select and Combobox):
|
|
246
|
+
// a fixed overlay inside an ancestor with transform / filter /
|
|
247
|
+
// backdrop-filter is positioned against THAT ancestor, not the viewport —
|
|
248
|
+
// a dialog opened from a sticky blurred top bar centred on the bar. Without
|
|
249
|
+
// a mount (SSR, no ThemeProvider) it renders in place.
|
|
250
|
+
return mount ? createPortal(node, mount) : node
|
|
241
251
|
}
|
|
242
252
|
|
|
243
253
|
// --- Header ---
|
|
@@ -12,6 +12,9 @@ export const styles = css.create({
|
|
|
12
12
|
inset: 0,
|
|
13
13
|
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
|
14
14
|
zIndex: 1000,
|
|
15
|
+
// Portaled into the ThemeProvider mount, which is pointer-events: none:
|
|
16
|
+
// the backdrop must take clicks back (outside click dismisses).
|
|
17
|
+
pointerEvents: 'auto',
|
|
15
18
|
},
|
|
16
19
|
backdropOpen: {
|
|
17
20
|
opacity: 1,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {createPortal} from 'react-dom'
|
|
1
2
|
import {
|
|
2
3
|
type ReactNode,
|
|
3
4
|
type RefObject,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
import {html} from 'react-strict-dom'
|
|
13
14
|
import {DURATION_MS, type DurationToken} from '@duro-app/tokens/keys'
|
|
14
15
|
import {styles} from './styles.css'
|
|
16
|
+
import {usePortalMount} from '../ThemeProvider/ThemeProvider'
|
|
15
17
|
import {useSwipeDismiss} from './useSwipeDismiss'
|
|
16
18
|
|
|
17
19
|
// --- Types ---
|
|
@@ -230,6 +232,7 @@ function Portal({children, size = 'md'}: PortalProps) {
|
|
|
230
232
|
descriptionId,
|
|
231
233
|
panelRef,
|
|
232
234
|
} = useDrawer()
|
|
235
|
+
const mount = usePortalMount()
|
|
233
236
|
|
|
234
237
|
const handleBackdropClick = useCallback(() => {
|
|
235
238
|
if (dismissable) {
|
|
@@ -249,7 +252,7 @@ function Portal({children, size = 'md'}: PortalProps) {
|
|
|
249
252
|
|
|
250
253
|
const isHorizontal = anchor === 'left' || anchor === 'right'
|
|
251
254
|
|
|
252
|
-
|
|
255
|
+
const node = (
|
|
253
256
|
<>
|
|
254
257
|
{/* Backdrop — click to dismiss */}
|
|
255
258
|
<html.div
|
|
@@ -287,6 +290,13 @@ function Portal({children, size = 'md'}: PortalProps) {
|
|
|
287
290
|
</html.div>
|
|
288
291
|
</>
|
|
289
292
|
)
|
|
293
|
+
|
|
294
|
+
// Out of the tree into the ThemeProvider mount (like Select and Combobox):
|
|
295
|
+
// a fixed overlay inside an ancestor with transform / filter /
|
|
296
|
+
// backdrop-filter is positioned against THAT ancestor, not the viewport —
|
|
297
|
+
// a dialog opened from a sticky blurred top bar centred on the bar. Without
|
|
298
|
+
// a mount (SSR, no ThemeProvider) it renders in place.
|
|
299
|
+
return mount ? createPortal(node, mount) : node
|
|
290
300
|
}
|
|
291
301
|
|
|
292
302
|
// --- Header ---
|
|
@@ -12,6 +12,9 @@ export const styles = css.create({
|
|
|
12
12
|
inset: 0,
|
|
13
13
|
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
|
14
14
|
zIndex: 1000,
|
|
15
|
+
// Portaled into the ThemeProvider mount, which is pointer-events: none:
|
|
16
|
+
// the backdrop must take clicks back (outside click dismisses).
|
|
17
|
+
pointerEvents: 'auto',
|
|
15
18
|
},
|
|
16
19
|
backdropOpen: {
|
|
17
20
|
animationName: css.keyframes({
|
|
@@ -51,7 +51,9 @@ export const styles = css.create({
|
|
|
51
51
|
left: 0,
|
|
52
52
|
right: 0,
|
|
53
53
|
bottom: 0,
|
|
54
|
-
|
|
54
|
+
// Above Dialog/Drawer (1000/1001): they portal into the same mount, so a
|
|
55
|
+
// popup opened from inside a dialog must out-rank the dialog itself.
|
|
56
|
+
zIndex: 1049,
|
|
55
57
|
// The portal mount is pointer-events: none, so re-enable here to catch
|
|
56
58
|
// outside clicks that close the popup.
|
|
57
59
|
pointerEvents: 'auto',
|
|
@@ -72,7 +74,7 @@ export const styles = css.create({
|
|
|
72
74
|
minWidth: 120,
|
|
73
75
|
maxHeight: 280,
|
|
74
76
|
overflowY: 'auto',
|
|
75
|
-
zIndex:
|
|
77
|
+
zIndex: 1050,
|
|
76
78
|
// Re-enable pointer events: the portal mount is pointer-events: none.
|
|
77
79
|
pointerEvents: 'auto',
|
|
78
80
|
},
|