@duro-app/ui 3.4.1 → 3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "3.4.1",
3
+ "version": "3.5.0",
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.1"
67
+ "@duro-app/tokens": "^3.5.0"
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
- zIndex: 49,
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,6 +9,7 @@ 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 {Select} from '../Select/Select'
12
13
  import {spacing} from '@duro-app/tokens/tokens/spacing.css'
13
14
  import {colors} from '@duro-app/tokens/tokens/colors.css'
14
15
 
@@ -373,3 +374,82 @@ export const InsideTransformedAncestor: Story = {
373
374
  await expect(r.bottom).toBeLessThanOrEqual(doc.defaultView!.innerHeight)
374
375
  },
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
+ }
@@ -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,
@@ -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({
@@ -37,6 +37,9 @@ export type IconName =
37
37
  | 'mail'
38
38
  | 'file-text'
39
39
  | 'plug'
40
+ // Input / action glyphs
41
+ | 'search'
42
+ | 'mic'
40
43
  // Color-mode glyphs
41
44
  | 'sun'
42
45
  | 'moon'
@@ -264,6 +267,23 @@ const strokeIcons: Partial<Record<IconName, ReactNode>> = {
264
267
  <path d="M9 10.76V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v6.76a2 2 0 0 0 .55 1.38l1.68 1.78A1 1 0 0 1 16.5 16h-9a1 1 0 0 1-.73-1.68l1.68-1.78A2 2 0 0 0 9 10.76z" />
265
268
  </>
266
269
  ),
270
+ // ---- input / action ----
271
+ // Magnifying glass — search fields, find actions.
272
+ search: (
273
+ <>
274
+ <circle cx="11" cy="11" r="8" />
275
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
276
+ </>
277
+ ),
278
+ // Microphone — voice input, dictation.
279
+ mic: (
280
+ <>
281
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
282
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
283
+ <line x1="12" y1="19" x2="12" y2="23" />
284
+ <line x1="8" y1="23" x2="16" y2="23" />
285
+ </>
286
+ ),
267
287
  // ---- color mode ----
268
288
  sun: (
269
289
  <>
@@ -3,6 +3,7 @@ import {expect, fn} from 'storybook/test'
3
3
  import {css, html} from 'react-strict-dom'
4
4
  import {InputGroup} from './InputGroup'
5
5
  import {Input} from '../Input/Input'
6
+ import {Icon} from '../Icon/Icon'
6
7
  import {spacing} from '@duro-app/tokens/tokens/spacing.css'
7
8
 
8
9
  const meta: Meta = {
@@ -64,6 +65,26 @@ export const BothSides: Story = {
64
65
  },
65
66
  }
66
67
 
68
+ export const SearchField: Story = {
69
+ render: () => (
70
+ <html.div style={layoutStyles.container}>
71
+ <InputGroup.Root>
72
+ <InputGroup.Addon position="start">
73
+ <Icon name="search" size="sm" />
74
+ </InputGroup.Addon>
75
+ <Input type="search" name="q" aria-label="Search" placeholder="Search" />
76
+ <InputGroup.Addon>
77
+ <Icon name="mic" size="sm" />
78
+ </InputGroup.Addon>
79
+ </InputGroup.Root>
80
+ </html.div>
81
+ ),
82
+ play: async ({canvas}) => {
83
+ const input = canvas.getByRole('searchbox', {name: 'Search'})
84
+ await expect(input).toHaveAttribute('name', 'q')
85
+ },
86
+ }
87
+
67
88
  export const ClickableAddon: Story = {
68
89
  render: () => (
69
90
  <html.div style={layoutStyles.container}>
@@ -51,7 +51,9 @@ export const styles = css.create({
51
51
  left: 0,
52
52
  right: 0,
53
53
  bottom: 0,
54
- zIndex: 49,
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: 50,
77
+ zIndex: 1050,
76
78
  // Re-enable pointer events: the portal mount is pointer-events: none.
77
79
  pointerEvents: 'auto',
78
80
  },