@duro-app/ui 5.4.0 → 5.6.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 (147) hide show
  1. package/dist/{Table-Cm5cCnZD.js → Table-B1sWL3GA.js} +668 -650
  2. package/dist/Table-B1sWL3GA.js.map +1 -0
  3. package/dist/components/Breadcrumb/Breadcrumb.d.ts +37 -0
  4. package/dist/components/Breadcrumb/Breadcrumb.d.ts.map +1 -0
  5. package/dist/components/Breadcrumb/Breadcrumb.meta.d.ts +3 -0
  6. package/dist/components/Breadcrumb/Breadcrumb.meta.d.ts.map +1 -0
  7. package/dist/components/Breadcrumb/Breadcrumb.stories.d.ts +18 -0
  8. package/dist/components/Breadcrumb/Breadcrumb.stories.d.ts.map +1 -0
  9. package/dist/components/Breadcrumb/styles.css.d.ts +46 -0
  10. package/dist/components/Breadcrumb/styles.css.d.ts.map +1 -0
  11. package/dist/components/Button/Button.d.ts +2 -1
  12. package/dist/components/Button/Button.d.ts.map +1 -1
  13. package/dist/components/Button/styles.css.d.ts +1 -0
  14. package/dist/components/Button/styles.css.d.ts.map +1 -1
  15. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +14 -0
  16. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
  17. package/dist/components/Inline/Inline.meta.d.ts.map +1 -1
  18. package/dist/components/LinkButton/LinkButton.d.ts +8 -1
  19. package/dist/components/LinkButton/LinkButton.d.ts.map +1 -1
  20. package/dist/components/LinkButton/LinkButton.stories.d.ts +9 -0
  21. package/dist/components/LinkButton/LinkButton.stories.d.ts.map +1 -1
  22. package/dist/components/LinkButton/styles.css.d.ts +1 -0
  23. package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
  24. package/dist/components/Menu/Menu.d.ts +4 -1
  25. package/dist/components/Menu/Menu.d.ts.map +1 -1
  26. package/dist/components/Menu/styles.css.d.ts +14 -0
  27. package/dist/components/Menu/styles.css.d.ts.map +1 -1
  28. package/dist/components/PageNav/PageNav.d.ts +30 -0
  29. package/dist/components/PageNav/PageNav.d.ts.map +1 -0
  30. package/dist/components/PageNav/PageNav.meta.d.ts +3 -0
  31. package/dist/components/PageNav/PageNav.meta.d.ts.map +1 -0
  32. package/dist/components/PageNav/PageNav.stories.d.ts +16 -0
  33. package/dist/components/PageNav/PageNav.stories.d.ts.map +1 -0
  34. package/dist/components/PageNav/styles.css.d.ts +51 -0
  35. package/dist/components/PageNav/styles.css.d.ts.map +1 -0
  36. package/dist/components/Popover/Popover.d.ts +14 -2
  37. package/dist/components/Popover/Popover.d.ts.map +1 -1
  38. package/dist/components/Popover/Popover.meta.d.ts.map +1 -1
  39. package/dist/components/Popover/PopoverLayerContext.d.ts +3 -0
  40. package/dist/components/Popover/PopoverLayerContext.d.ts.map +1 -1
  41. package/dist/components/Popover/PopoverRaised.stories.d.ts +28 -0
  42. package/dist/components/Popover/PopoverRaised.stories.d.ts.map +1 -0
  43. package/dist/components/Popover/styles.css.d.ts +15 -0
  44. package/dist/components/Popover/styles.css.d.ts.map +1 -1
  45. package/dist/components/Select/Select.d.ts +4 -1
  46. package/dist/components/Select/Select.d.ts.map +1 -1
  47. package/dist/components/Select/styles.css.d.ts +9 -0
  48. package/dist/components/Select/styles.css.d.ts.map +1 -1
  49. package/dist/components/TableOfContents/TableOfContents.d.ts +53 -0
  50. package/dist/components/TableOfContents/TableOfContents.d.ts.map +1 -0
  51. package/dist/components/TableOfContents/TableOfContents.meta.d.ts +3 -0
  52. package/dist/components/TableOfContents/TableOfContents.meta.d.ts.map +1 -0
  53. package/dist/components/TableOfContents/TableOfContents.stories.d.ts +21 -0
  54. package/dist/components/TableOfContents/TableOfContents.stories.d.ts.map +1 -0
  55. package/dist/components/TableOfContents/TableOfContentsContext.d.ts +11 -0
  56. package/dist/components/TableOfContents/TableOfContentsContext.d.ts.map +1 -0
  57. package/dist/components/TableOfContents/styles.css.d.ts +114 -0
  58. package/dist/components/TableOfContents/styles.css.d.ts.map +1 -0
  59. package/dist/components/TextLink/TextLink.d.ts +8 -1
  60. package/dist/components/TextLink/TextLink.d.ts.map +1 -1
  61. package/dist/components/TextLink/TextLink.stories.d.ts +9 -0
  62. package/dist/components/TextLink/TextLink.stories.d.ts.map +1 -1
  63. package/dist/components/Toggle/Toggle.d.ts +2 -1
  64. package/dist/components/Toggle/Toggle.d.ts.map +1 -1
  65. package/dist/components/Toggle/styles.css.d.ts +1 -0
  66. package/dist/components/Toggle/styles.css.d.ts.map +1 -1
  67. package/dist/components/Tree/Tree.d.ts +13 -1
  68. package/dist/components/Tree/Tree.d.ts.map +1 -1
  69. package/dist/components/Tree/Tree.meta.d.ts.map +1 -1
  70. package/dist/components/Tree/Tree.stories.d.ts +9 -0
  71. package/dist/components/Tree/Tree.stories.d.ts.map +1 -1
  72. package/dist/components/Tree/styles.css.d.ts +5 -0
  73. package/dist/components/Tree/styles.css.d.ts.map +1 -1
  74. package/dist/docs/clickLink.d.ts +4 -0
  75. package/dist/docs/clickLink.d.ts.map +1 -0
  76. package/dist/docs/coarsePointer.d.ts +15 -0
  77. package/dist/docs/coarsePointer.d.ts.map +1 -0
  78. package/dist/docs/themedSurface.d.ts +8 -0
  79. package/dist/docs/themedSurface.d.ts.map +1 -0
  80. package/dist/index.css +1 -1
  81. package/dist/index.d.ts +6 -0
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +2718 -2212
  84. package/dist/index.js.map +1 -1
  85. package/dist/shared/ModalContext.d.ts +7 -0
  86. package/dist/shared/ModalContext.d.ts.map +1 -1
  87. package/dist/shared/exports.typecheck.d.ts +9 -0
  88. package/dist/shared/exports.typecheck.d.ts.map +1 -0
  89. package/dist/shared/navigate.d.ts +25 -0
  90. package/dist/shared/navigate.d.ts.map +1 -0
  91. package/dist/shared/types.d.ts +2 -0
  92. package/dist/shared/types.d.ts.map +1 -1
  93. package/dist/table.js +1 -1
  94. package/package.json +2 -2
  95. package/src/components/Breadcrumb/Breadcrumb.meta.ts +37 -0
  96. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +110 -0
  97. package/src/components/Breadcrumb/Breadcrumb.tsx +95 -0
  98. package/src/components/Breadcrumb/styles.css.ts +76 -0
  99. package/src/components/Button/Button.tsx +2 -1
  100. package/src/components/Button/styles.css.ts +3 -0
  101. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +172 -14
  102. package/src/components/Inline/Inline.meta.ts +2 -1
  103. package/src/components/LinkButton/LinkButton.meta.ts +1 -1
  104. package/src/components/LinkButton/LinkButton.stories.tsx +42 -1
  105. package/src/components/LinkButton/LinkButton.tsx +9 -0
  106. package/src/components/LinkButton/styles.css.ts +4 -0
  107. package/src/components/Menu/Menu.meta.ts +1 -1
  108. package/src/components/Menu/Menu.tsx +17 -2
  109. package/src/components/Menu/styles.css.ts +18 -0
  110. package/src/components/PageNav/PageNav.meta.ts +35 -0
  111. package/src/components/PageNav/PageNav.stories.tsx +146 -0
  112. package/src/components/PageNav/PageNav.tsx +93 -0
  113. package/src/components/PageNav/styles.css.ts +100 -0
  114. package/src/components/Popover/Popover.meta.ts +2 -1
  115. package/src/components/Popover/Popover.tsx +29 -4
  116. package/src/components/Popover/PopoverLayerContext.ts +3 -0
  117. package/src/components/Popover/PopoverRaised.stories.tsx +334 -0
  118. package/src/components/Popover/styles.css.ts +21 -0
  119. package/src/components/Select/Select.meta.ts +1 -1
  120. package/src/components/Select/Select.tsx +5 -1
  121. package/src/components/Select/styles.css.ts +12 -0
  122. package/src/components/TableOfContents/TableOfContents.meta.ts +41 -0
  123. package/src/components/TableOfContents/TableOfContents.stories.tsx +222 -0
  124. package/src/components/TableOfContents/TableOfContents.tsx +173 -0
  125. package/src/components/TableOfContents/TableOfContentsContext.ts +20 -0
  126. package/src/components/TableOfContents/styles.css.ts +147 -0
  127. package/src/components/TextLink/TextLink.meta.ts +1 -1
  128. package/src/components/TextLink/TextLink.stories.tsx +42 -1
  129. package/src/components/TextLink/TextLink.tsx +9 -0
  130. package/src/components/Toggle/Toggle.tsx +2 -1
  131. package/src/components/Toggle/styles.css.ts +2 -0
  132. package/src/components/Tree/Tree.meta.ts +2 -1
  133. package/src/components/Tree/Tree.stories.tsx +93 -1
  134. package/src/components/Tree/Tree.tsx +69 -27
  135. package/src/components/Tree/styles.css.ts +6 -0
  136. package/src/docs/clickLink.ts +24 -0
  137. package/src/docs/coarsePointer.ts +42 -0
  138. package/src/docs/themedSurface.tsx +23 -0
  139. package/src/index.ts +11 -0
  140. package/src/shared/ModalContext.ts +8 -1
  141. package/src/shared/exports.typecheck.ts +16 -0
  142. package/src/shared/linkParts.typecheck.tsx +47 -0
  143. package/src/shared/navigate.ts +62 -0
  144. package/src/shared/types.ts +3 -0
  145. package/dist/Table-Cm5cCnZD.js.map +0 -1
  146. package/dist/shared/length.typecheck.d.ts +0 -31
  147. package/dist/shared/length.typecheck.d.ts.map +0 -1
@@ -0,0 +1,93 @@
1
+ import {createContext, type ReactNode, useContext} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {styles} from './styles.css'
4
+ import {linkClickHandler, type OnNavigate} from '../../shared/navigate'
5
+
6
+ /* PageNav — the previous and next pages in reading order, at the foot of a
7
+ * page: a labelled `nav` holding up to two link cards. Each card shows a small
8
+ * direction label ("Previous", from your text catalog) over the page title.
9
+ * Prev holds the left slot and Next the right one even when the other is
10
+ * missing, so Next stays right-aligned on the last-but-one page too; on a
11
+ * narrow container (below the `xs` breakpoint) the two stack.
12
+ *
13
+ * Not `Pagination` (numbered result pages, in `@duro-app/ui/table`). */
14
+
15
+ // Prev and Next place themselves in Root's two-column grid.
16
+ const PageNavContext = createContext(false)
17
+
18
+ function usePageNav() {
19
+ if (!useContext(PageNavContext))
20
+ throw new Error('PageNav compound components must be used within PageNav.Root')
21
+ }
22
+
23
+ // --- Root ---
24
+
25
+ interface RootProps {
26
+ /** Names the landmark ("Pages"); required, from your text catalog. */
27
+ 'aria-label': string
28
+ /** A `PageNav.Prev` and/or a `PageNav.Next`. */
29
+ children: ReactNode
30
+ }
31
+
32
+ function Root({'aria-label': ariaLabel, children}: RootProps) {
33
+ return (
34
+ <PageNavContext.Provider value={true}>
35
+ <html.nav aria-label={ariaLabel} style={styles.root}>
36
+ <html.div style={styles.grid}>{children}</html.div>
37
+ </html.nav>
38
+ </PageNavContext.Provider>
39
+ )
40
+ }
41
+
42
+ // --- Prev / Next ---
43
+
44
+ interface LinkProps {
45
+ href: string
46
+ /** The page's title. */
47
+ title: ReactNode
48
+ /** The direction label above the title ("Previous", "Next"), from your text catalog. */
49
+ label: ReactNode
50
+ /**
51
+ * Client-side navigation: called for a plain primary click (no modifier
52
+ * key). Call `event.preventDefault()`, then your router's navigate.
53
+ */
54
+ onNavigate?: OnNavigate
55
+ }
56
+
57
+ function Prev({href, title, label, onNavigate}: LinkProps) {
58
+ usePageNav()
59
+ return (
60
+ <html.a
61
+ href={href}
62
+ onClick={linkClickHandler(href, onNavigate)}
63
+ style={[styles.card, styles.prev]}
64
+ >
65
+ <html.span style={styles.label}>
66
+ <html.span aria-hidden>←</html.span> {label}
67
+ </html.span>
68
+ <html.span style={styles.title}>{title}</html.span>
69
+ </html.a>
70
+ )
71
+ }
72
+
73
+ function Next({href, title, label, onNavigate}: LinkProps) {
74
+ usePageNav()
75
+ return (
76
+ <html.a
77
+ href={href}
78
+ onClick={linkClickHandler(href, onNavigate)}
79
+ style={[styles.card, styles.next]}
80
+ >
81
+ <html.span style={styles.label}>
82
+ {label} <html.span aria-hidden>→</html.span>
83
+ </html.span>
84
+ <html.span style={styles.title}>{title}</html.span>
85
+ </html.a>
86
+ )
87
+ }
88
+
89
+ export const PageNav = {
90
+ Root,
91
+ Prev,
92
+ Next,
93
+ }
@@ -0,0 +1,100 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {radii, spacing} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
5
+ import {borders} from '@duro-app/tokens/tokens/borders.css'
6
+ import {breakpoints} from '@duro-app/tokens/tokens/breakpoints.css'
7
+ import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
8
+
9
+ // Below this container width the two cards stack (Mobile.dc.html); above it
10
+ // they sit side by side, each in its own half (Reader.dc.html).
11
+ const STACK_BP = breakpoints.xs
12
+ const STACKED = `@container (max-width: ${STACK_BP})`
13
+
14
+ // The picked docs browser (docs/mockups/docs-nav/Reader.dc.html and
15
+ // Mobile.dc.html, `.pn .card`): two interactive cards, a small muted
16
+ // direction label over the title, Next aligned right.
17
+ export const styles = css.create({
18
+ // hosts the container query: a container cannot query itself
19
+ root: {
20
+ containerType: 'inline-size',
21
+ minWidth: 0,
22
+ },
23
+ grid: {
24
+ display: 'grid',
25
+ gridTemplateColumns: {
26
+ default: 'minmax(0, 1fr) minmax(0, 1fr)',
27
+ [STACKED]: 'minmax(0, 1fr)',
28
+ },
29
+ gap: spacing.md,
30
+ },
31
+ card: {
32
+ display: 'flex',
33
+ flexDirection: 'column',
34
+ padding: spacing.md,
35
+ fontFamily: typography.fontFamily,
36
+ color: colors.text,
37
+ textDecorationLine: 'none',
38
+ backgroundColor: {
39
+ default: colors.bgCard,
40
+ ':hover': colors.bgCardHover,
41
+ },
42
+ borderWidth: borders.hairline,
43
+ borderStyle: 'solid',
44
+ borderColor: colors.border,
45
+ borderRadius: radii.md,
46
+ cursor: 'pointer',
47
+ transitionProperty: 'background-color',
48
+ transitionDuration: duration.fast,
49
+ transitionTimingFunction: easing.standard,
50
+ outlineWidth: {
51
+ default: 0,
52
+ ':focus-visible': borders.focusRing,
53
+ },
54
+ outlineStyle: {
55
+ default: 'none',
56
+ ':focus-visible': 'solid',
57
+ },
58
+ outlineColor: {
59
+ default: 'transparent',
60
+ ':focus-visible': colors.accent,
61
+ },
62
+ outlineOffset: {
63
+ default: 0,
64
+ ':focus-visible': borders.focusOffset,
65
+ },
66
+ },
67
+ // each side keeps its slot: Next stays in the right half without a Prev
68
+ prev: {
69
+ gridColumn: {
70
+ default: '1',
71
+ [STACKED]: 'auto',
72
+ },
73
+ gridRow: {
74
+ default: '1',
75
+ [STACKED]: 'auto',
76
+ },
77
+ },
78
+ next: {
79
+ gridColumn: {
80
+ default: '2',
81
+ [STACKED]: 'auto',
82
+ },
83
+ gridRow: {
84
+ default: '1',
85
+ [STACKED]: 'auto',
86
+ },
87
+ textAlign: {
88
+ default: 'right',
89
+ [STACKED]: 'left',
90
+ },
91
+ },
92
+ label: {
93
+ fontSize: typography.fontSizeXs,
94
+ color: colors.textMuted,
95
+ },
96
+ title: {
97
+ fontSize: typography.fontSizeSm,
98
+ overflowWrap: 'anywhere',
99
+ },
100
+ })
@@ -2,10 +2,11 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Non-modal anchored overlay for small interactive content. Compound component — Root is required (throws without it). Anchored to Popover.Trigger, or to a virtual anchor (a () => DOMRect) when there is no Trigger; call reposition() through a ref when the anchor moves.',
5
+ 'Non-modal anchored overlay for small interactive content. Compound component — Root is required (throws without it). Anchored to Popover.Trigger, or to a virtual anchor (a () => DOMRect) when there is no Trigger; call reposition() through a ref when the anchor moves. Popover.Trigger takes size="small" beside small Toggles. Popover.Popup raised draws it over another floating surface (floatingRaised, popoverRaised in a modal), never over a modal.',
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
+ 'One floating surface over another (a link editor over a selection format bar) — Popover.Popup raised',
9
10
  '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',
10
11
  ],
11
12
  whenNotToUse: [
@@ -22,6 +22,7 @@ import {PopoverLayerContext, type PopoverLayerContextValue} from './PopoverLayer
22
22
  import {useGroupedControl} from '../ButtonGroup/useGroupedControl'
23
23
  import {ControlContextBoundary} from '../Toolbar/ControlContextBoundary'
24
24
  import {mergeRefs} from '../../shared/mergeRefs'
25
+ import type {ControlSize} from '../../shared/types'
25
26
  import {useInModal} from '../../shared/ModalContext'
26
27
 
27
28
  // --- Context ---
@@ -166,6 +167,8 @@ function Root({
166
167
  unregisterParent?.()
167
168
  }
168
169
  },
170
+ // Inside a raised Popup, this Popover is raised too.
171
+ raised: parentLayer?.raised,
169
172
  }),
170
173
  [parentLayer],
171
174
  )
@@ -201,11 +204,13 @@ interface TriggerProps {
201
204
  children: ReactNode
202
205
  /** Accessible name: required when the label is an icon only. */
203
206
  'aria-label'?: string
207
+ /** `small` matches a small Toggle or Button beside it. Default: `default`. */
208
+ size?: ControlSize
204
209
  /** The trigger <button>, e.g. to measure it or return focus to it. */
205
210
  ref?: Ref<HTMLButtonElement>
206
211
  }
207
212
 
208
- function Trigger({children, 'aria-label': ariaLabel, ref}: TriggerProps) {
213
+ function Trigger({children, 'aria-label': ariaLabel, size = 'default', ref}: TriggerProps) {
209
214
  const {open, setOpen, popoverId, triggerRef} = usePopover('Trigger')
210
215
  const grouped = useGroupedControl<HTMLButtonElement>()
211
216
 
@@ -220,7 +225,7 @@ function Trigger({children, 'aria-label': ariaLabel, ref}: TriggerProps) {
220
225
  aria-haspopup="dialog"
221
226
  aria-expanded={open}
222
227
  aria-controls={open ? popoverId : undefined}
223
- style={[styles.trigger, grouped.style]}
228
+ style={[styles.trigger, size === 'small' && styles.triggerSmall, grouped.style]}
224
229
  >
225
230
  {children}
226
231
  </html.button>
@@ -233,9 +238,18 @@ interface PopupProps {
233
238
  children: ReactNode
234
239
  /** Accessible name for the dialog. */
235
240
  label: string
241
+ /**
242
+ * Draw over another floating surface: `layers.floatingRaised` outside a
243
+ * modal, `layers.popoverRaised` inside a Dialog or Drawer, instead of
244
+ * `floating` / `popover`. For one floating thing that must cover a sibling
245
+ * one (a link editor over a format bar), never for covering a modal: a
246
+ * Dialog opened later still covers it. A Popover nested inside a raised one
247
+ * is raised too. Default: inherited, else `false`.
248
+ */
249
+ raised?: boolean
236
250
  }
237
251
 
238
- function Popup({children, label}: PopupProps) {
252
+ function Popup({children, label, raised}: PopupProps) {
239
253
  const {open, popoverId, side, align, anchor, triggerRef, popupRef, repositionRef} =
240
254
  usePopover('Popup')
241
255
  const [coords, setCoords] = useState({top: 0, left: 0})
@@ -243,6 +257,14 @@ function Popup({children, label}: PopupProps) {
243
257
  // tokens cascade into the popup and it stacks above dialogs.
244
258
  const mount = usePortalMount()
245
259
  const inModal = useInModal()
260
+ const layer = useContext(PopoverLayerContext)
261
+ const isRaised = raised ?? layer?.raised ?? false
262
+ // Re-provided around the children: the Root can't see this prop, and a
263
+ // Popover nested in here reads it to stay above this one.
264
+ const raisedLayer = useMemo<PopoverLayerContextValue | null>(
265
+ () => (layer ? {...layer, raised: isRaised} : null),
266
+ [layer, isRaised],
267
+ )
246
268
 
247
269
  // Measure before paint on open; re-measure on scroll/resize (capture phase
248
270
  // catches any scrolling ancestor) and when Root.reposition() is called.
@@ -291,10 +313,13 @@ function Popup({children, label}: PopupProps) {
291
313
  style={[
292
314
  styles.popup,
293
315
  inModal && styles.popupInModal,
316
+ isRaised && (inModal ? styles.popupInModalRaised : styles.popupRaised),
294
317
  styles.popupPosition(coords.top, coords.left),
295
318
  ]}
296
319
  >
297
- <ControlContextBoundary>{children}</ControlContextBoundary>
320
+ <PopoverLayerContext.Provider value={raisedLayer}>
321
+ <ControlContextBoundary>{children}</ControlContextBoundary>
322
+ </PopoverLayerContext.Provider>
298
323
  </html.div>,
299
324
  mount ?? document.body,
300
325
  )
@@ -5,6 +5,9 @@ import {type RefObject, createContext, useContext, useLayoutEffect} from 'react'
5
5
  // on them counts as inside the Popover.
6
6
  export interface PopoverLayerContextValue {
7
7
  register: (el: Element) => () => void
8
+ /** Set by a `raised` Popup for what it holds, so a Popover nested in it is
9
+ * raised too and is not drawn under its parent. */
10
+ raised?: boolean
8
11
  }
9
12
 
10
13
  export const PopoverLayerContext = createContext<PopoverLayerContextValue | null>(null)
@@ -0,0 +1,334 @@
1
+ import {useState, type ReactNode} from 'react'
2
+ import type {Meta, StoryObj} from '@storybook/react'
3
+ import {expect, userEvent, waitFor, within} from 'storybook/test'
4
+ import {html} from 'react-strict-dom'
5
+ import {LAYERS} from '@duro-app/tokens/keys'
6
+ import {Popover} from './Popover'
7
+ import {Button} from '../Button/Button'
8
+ import {Dialog} from '../Dialog/Dialog'
9
+ import {Drawer} from '../Drawer/Drawer'
10
+ import {Select} from '../Select/Select'
11
+ import {Stack} from '../Stack/Stack'
12
+ import {Text} from '../Text/Text'
13
+ import {useInModal} from '../../shared/ModalContext'
14
+
15
+ /**
16
+ * `Popover.Popup raised`: one floating surface over another (a link editor
17
+ * over a selection format bar). Outside a modal it is `floatingRaised` (60),
18
+ * inside a Dialog or Drawer `popoverRaised` (1041). The raised Popover renders
19
+ * first, so without the layer the plain one, portalled after it, would be on
20
+ * top.
21
+ */
22
+ const meta: Meta = {
23
+ title: 'Components/Popover/Raised',
24
+ }
25
+
26
+ export default meta
27
+ type Story = StoryObj
28
+
29
+ const page = () => within(document.body)
30
+
31
+ /** A small rect at the viewport's centre: every Popover here anchors to it. */
32
+ const centre = () => new DOMRect(window.innerWidth / 2 - 10, window.innerHeight / 2 - 10, 20, 20)
33
+
34
+ /** True when the element drawn at (x, y) is `el` or inside it. */
35
+ const drawnAt = (el: Element, x: number, y: number) => {
36
+ const hit = document.elementFromPoint(x, y)
37
+ return hit !== null && el.contains(hit)
38
+ }
39
+
40
+ const centreOf = (el: Element) => {
41
+ const r = el.getBoundingClientRect()
42
+ return [r.left + r.width / 2, r.top + r.height / 2] as const
43
+ }
44
+
45
+ const zIndexOf = (el: Element) => getComputedStyle(el).zIndex
46
+
47
+ /** The format bar: wider and taller than the link editor drawn over it. */
48
+ function FormatBar() {
49
+ return (
50
+ <Popover.Root defaultOpen anchor={centre} align="center">
51
+ <Popover.Popup label="Format bar">
52
+ <Stack gap="md">
53
+ <Text>Bold · Italic · Underline · Strikethrough · Code · Link</Text>
54
+ <Text>Second row of formatting</Text>
55
+ <Text>Third row of formatting</Text>
56
+ </Stack>
57
+ </Popover.Popup>
58
+ </Popover.Root>
59
+ )
60
+ }
61
+
62
+ function LinkEditor({children}: {children?: ReactNode}) {
63
+ return (
64
+ <Popover.Root defaultOpen anchor={centre} align="center">
65
+ <Popover.Popup label="Link editor" raised>
66
+ <Stack gap="sm">
67
+ <Text>https://</Text>
68
+ {children}
69
+ </Stack>
70
+ </Popover.Popup>
71
+ </Popover.Root>
72
+ )
73
+ }
74
+
75
+ /** Raised first, then the plain one: DOM order alone would put the bar on top. */
76
+ function Pair({children}: {children?: ReactNode}) {
77
+ return (
78
+ <>
79
+ <LinkEditor>{children}</LinkEditor>
80
+ <FormatBar />
81
+ </>
82
+ )
83
+ }
84
+
85
+ function OpenDialog({children}: {children: ReactNode}) {
86
+ return (
87
+ <Dialog.Root open>
88
+ <Dialog.Portal size="sm">
89
+ <Dialog.Header>
90
+ <Dialog.Title>Editor</Dialog.Title>
91
+ </Dialog.Header>
92
+ <Dialog.Body>
93
+ <Stack gap="md">
94
+ {/* Enough body for the panel to reach under the Popovers. */}
95
+ {['First', 'Second', 'Third', 'Fourth', 'Fifth', 'Sixth'].map((n) => (
96
+ <Text key={n}>{`${n} paragraph of the document being edited.`}</Text>
97
+ ))}
98
+ {children}
99
+ </Stack>
100
+ </Dialog.Body>
101
+ </Dialog.Portal>
102
+ </Dialog.Root>
103
+ )
104
+ }
105
+
106
+ async function expectRaisedOverBar(layer: number) {
107
+ const editor = await page().findByRole('dialog', {name: 'Link editor'})
108
+ const bar = await page().findByRole('dialog', {name: 'Format bar'})
109
+ await expect(zIndexOf(editor)).toBe(String(layer))
110
+ await waitFor(() => expect(drawnAt(editor, ...centreOf(editor))).toBe(true))
111
+ // The bar is still drawn where the editor does not cover it.
112
+ const b = bar.getBoundingClientRect()
113
+ const e = editor.getBoundingClientRect()
114
+ await expect(b.left).toBeLessThan(e.left)
115
+ await expect(drawnAt(bar, b.left + 2, b.top + b.height / 2)).toBe(true)
116
+ return {editor, bar}
117
+ }
118
+
119
+ /** Outside a modal: the raised editor (60) is over the bar (50). */
120
+ export const RaisedOverFloating: Story = {
121
+ render: () => <Pair />,
122
+ play: async () => {
123
+ const {bar} = await expectRaisedOverBar(LAYERS.floatingRaised)
124
+ await expect(zIndexOf(bar)).toBe(String(LAYERS.floating))
125
+ },
126
+ }
127
+
128
+ /** Inside an open Dialog: the raised editor (1041) is over the bar (1040),
129
+ * and both are over the Dialog panel. */
130
+ export const RaisedInDialog: Story = {
131
+ render: () => (
132
+ <OpenDialog>
133
+ <Pair />
134
+ </OpenDialog>
135
+ ),
136
+ play: async () => {
137
+ const {editor, bar} = await expectRaisedOverBar(LAYERS.popoverRaised)
138
+ await expect(zIndexOf(bar)).toBe(String(LAYERS.popover))
139
+ // Both overlap the panel and are drawn there: the editor at its centre,
140
+ // the bar where the editor leaves it uncovered.
141
+ const panel = page().getByRole('dialog', {name: 'Editor'})
142
+ const p = panel.getBoundingClientRect()
143
+ const b = bar.getBoundingClientRect()
144
+ const e = editor.getBoundingClientRect()
145
+ // Under the editor, inside the bar.
146
+ const x = b.left + b.width / 2
147
+ const y = (e.bottom + b.bottom) / 2
148
+ await expect(x > p.left && x < p.right && y > p.top && y < p.bottom).toBe(true)
149
+ await expect(drawnAt(bar, x, y)).toBe(true)
150
+ const [ex, ey] = centreOf(editor)
151
+ await expect(ex > p.left && ex < p.right && ey > p.top && ey < p.bottom).toBe(true)
152
+ await expect(drawnAt(editor, ex, ey)).toBe(true)
153
+ },
154
+ }
155
+
156
+ /** The Settings dialog's centre once it is open, the viewport's before. */
157
+ const settingsCentre = () => {
158
+ const dialog = page().queryByRole('dialog', {name: 'Settings'})
159
+ if (!dialog) return centre()
160
+ // Just above the centre: the editor opens below its anchor, over the centre.
161
+ const [x, y] = centreOf(dialog)
162
+ return new DOMRect(x - 10, y - 30, 20, 20)
163
+ }
164
+
165
+ function RaisedThenDialog() {
166
+ const [dialogOpen, setDialogOpen] = useState(false)
167
+ return (
168
+ <>
169
+ <Popover.Root open anchor={settingsCentre} align="center">
170
+ <Popover.Popup label="Link editor" raised>
171
+ <Text>Stays open: controlled</Text>
172
+ </Popover.Popup>
173
+ </Popover.Root>
174
+ <html.button type="button" onClick={() => setDialogOpen(true)}>
175
+ Open dialog
176
+ </html.button>
177
+ <Dialog.Root open={dialogOpen} onOpenChange={setDialogOpen}>
178
+ <Dialog.Portal size="sm">
179
+ <Dialog.Header>
180
+ <Dialog.Title>Settings</Dialog.Title>
181
+ </Dialog.Header>
182
+ <Dialog.Body>
183
+ <Text>A modal opened over a raised Popover.</Text>
184
+ </Dialog.Body>
185
+ </Dialog.Portal>
186
+ </Dialog.Root>
187
+ </>
188
+ )
189
+ }
190
+
191
+ /** A raised Popover held open, then a Dialog: the Popover is still mounted
192
+ * and the Dialog covers it at the Dialog's centre. */
193
+ export const DialogOverRaised: Story = {
194
+ render: () => <RaisedThenDialog />,
195
+ play: async ({canvas}) => {
196
+ const editor = await page().findByRole('dialog', {name: 'Link editor'})
197
+ await userEvent.click(canvas.getByRole('button', {name: 'Open dialog'}))
198
+ const dialog = await page().findByRole('dialog', {name: 'Settings'})
199
+ await expect(editor).toBeInTheDocument()
200
+ // Re-place the editor against the open Dialog, so it sits under the
201
+ // Dialog's centre: the check below is not vacuous.
202
+ window.dispatchEvent(new Event('resize'))
203
+ await waitFor(() => {
204
+ const [x, y] = centreOf(dialog)
205
+ const e = editor.getBoundingClientRect()
206
+ expect(x >= e.left && x <= e.right && y >= e.top && y <= e.bottom).toBe(true)
207
+ })
208
+ const [x, y] = centreOf(dialog)
209
+ await waitFor(() => expect(drawnAt(dialog, x, y)).toBe(true))
210
+ },
211
+ }
212
+
213
+ /** A Popover nested in a raised one inherits `raised`: it is drawn over its
214
+ * parent, not at the plain layer under it. */
215
+ function NestedInRaised() {
216
+ return (
217
+ <Popover.Root defaultOpen anchor={centre} align="center">
218
+ <Popover.Popup label="Link editor" raised>
219
+ <Stack gap="md">
220
+ <Popover.Root align="center">
221
+ <Popover.Trigger>Open child</Popover.Trigger>
222
+ <Popover.Popup label="Child">
223
+ <Text>Child</Text>
224
+ </Popover.Popup>
225
+ </Popover.Root>
226
+ <Text>Parent: a raised Popover, wider than the child</Text>
227
+ <Text>Second row</Text>
228
+ <Text>Third row</Text>
229
+ <Text>Fourth row</Text>
230
+ </Stack>
231
+ </Popover.Popup>
232
+ </Popover.Root>
233
+ )
234
+ }
235
+
236
+ async function expectChildOnTop(layer: number) {
237
+ await userEvent.click(await page().findByRole('button', {name: 'Open child'}))
238
+ const child = await page().findByRole('dialog', {name: 'Child'})
239
+ // Non-vacuous: the child overlaps its parent.
240
+ const parent = page().getByRole('dialog', {name: 'Link editor'}).getBoundingClientRect()
241
+ const [cx, cy] = centreOf(child)
242
+ await expect(cx > parent.left && cx < parent.right && cy > parent.top && cy < parent.bottom).toBe(
243
+ true,
244
+ )
245
+ await expect(zIndexOf(child)).toBe(String(layer))
246
+ await waitFor(() => expect(drawnAt(child, ...centreOf(child))).toBe(true))
247
+ }
248
+
249
+ export const NestedInRaised_NoModal: Story = {
250
+ name: 'Nested in raised, no modal',
251
+ render: () => <NestedInRaised />,
252
+ play: async () => expectChildOnTop(LAYERS.floatingRaised),
253
+ }
254
+
255
+ export const NestedInRaised_InDialog: Story = {
256
+ name: 'Nested in raised, in a Dialog',
257
+ render: () => (
258
+ <OpenDialog>
259
+ <NestedInRaised />
260
+ </OpenDialog>
261
+ ),
262
+ play: async () => expectChildOnTop(LAYERS.popoverRaised),
263
+ }
264
+
265
+ /** A Select opened from a raised Popover in a Dialog: its listbox (popup,
266
+ * 1050) is on top. */
267
+ export const SelectInRaisedInDialog: Story = {
268
+ render: () => (
269
+ <OpenDialog>
270
+ <Pair>
271
+ <Select.Root defaultValue="https">
272
+ <Select.Trigger aria-label="Scheme">
273
+ <Select.Value />
274
+ <Select.Icon />
275
+ </Select.Trigger>
276
+ <Select.Popup>
277
+ <Select.Item value="https">https</Select.Item>
278
+ <Select.Item value="mailto">mailto</Select.Item>
279
+ <Select.Item value="tel">tel</Select.Item>
280
+ </Select.Popup>
281
+ </Select.Root>
282
+ </Pair>
283
+ </OpenDialog>
284
+ ),
285
+ play: async () => {
286
+ await userEvent.click(await page().findByRole('combobox', {name: 'Scheme'}))
287
+ const listbox = await page().findByRole('listbox')
288
+ await expect(zIndexOf(listbox)).toBe(String(LAYERS.popup))
289
+ for (const name of ['https', 'mailto', 'tel']) {
290
+ const option = await page().findByRole('option', {name})
291
+ await waitFor(() => expect(drawnAt(option, ...centreOf(option))).toBe(true))
292
+ }
293
+ },
294
+ }
295
+
296
+ function InModalProbe({label}: {label: string}) {
297
+ return <Text>{`${label}: ${String(useInModal())}`}</Text>
298
+ }
299
+
300
+ /** `useInModal()`: true in a Dialog and a Drawer, false outside. Its export
301
+ * from the package root is checked by shared/exports.typecheck.ts. */
302
+ export const UseInModal: Story = {
303
+ render: () => (
304
+ <>
305
+ <InModalProbe label="page" />
306
+ <Button>Unrelated</Button>
307
+ <Dialog.Root open>
308
+ <Dialog.Portal size="sm">
309
+ <Dialog.Header>
310
+ <Dialog.Title>In a dialog</Dialog.Title>
311
+ </Dialog.Header>
312
+ <Dialog.Body>
313
+ <InModalProbe label="dialog" />
314
+ </Dialog.Body>
315
+ </Dialog.Portal>
316
+ </Dialog.Root>
317
+ <Drawer.Root open>
318
+ <Drawer.Portal>
319
+ <Drawer.Header>
320
+ <Drawer.Title>In a drawer</Drawer.Title>
321
+ </Drawer.Header>
322
+ <Drawer.Body>
323
+ <InModalProbe label="drawer" />
324
+ </Drawer.Body>
325
+ </Drawer.Portal>
326
+ </Drawer.Root>
327
+ </>
328
+ ),
329
+ play: async () => {
330
+ await expect(await page().findByText('page: false')).toBeInTheDocument()
331
+ await expect(await page().findByText('dialog: true')).toBeInTheDocument()
332
+ await expect(await page().findByText('drawer: true')).toBeInTheDocument()
333
+ },
334
+ }
@@ -4,6 +4,7 @@ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
4
  import {typography} from '@duro-app/tokens/tokens/typography.css'
5
5
  import {shadows} from '@duro-app/tokens/tokens/shadows.css'
6
6
  import {duration} from '@duro-app/tokens/tokens/motion.css'
7
+ import {sizes} from '@duro-app/tokens/tokens/sizes.css'
7
8
  import {borders} from '@duro-app/tokens/tokens/borders.css'
8
9
  import {layers} from '@duro-app/tokens/tokens/layers.css'
9
10
 
@@ -31,11 +32,31 @@ export const styles = css.create({
31
32
  transitionProperty: 'background-color, border-color',
32
33
  transitionDuration: duration.fast,
33
34
  },
35
+ // size="small": Toggle sizeSmall's values, as longhands so they win over the
36
+ // trigger's own longhands. Every small control is at least controlSm tall,
37
+ // whatever it holds, and the touch target under a coarse pointer.
38
+ triggerSmall: {
39
+ gap: spacing.xs,
40
+ paddingTop: spacing.xs,
41
+ paddingBottom: spacing.xs,
42
+ paddingLeft: spacing.sm,
43
+ paddingRight: spacing.sm,
44
+ fontSize: typography.fontSizeXs,
45
+ minHeight: {default: sizes.controlSm, '@media (pointer: coarse)': sizes.touchTarget},
46
+ },
34
47
  // Inside a Dialog or Drawer: above that modal (same portal mount), below a
35
48
  // Select or Menu opened from inside the Popover.
36
49
  popupInModal: {
37
50
  zIndex: layers.popover,
38
51
  },
52
+ // `raised`: over another floating surface, outside a modal (still under a
53
+ // Dialog or Drawer opened later) and inside one (still under its popups).
54
+ popupRaised: {
55
+ zIndex: layers.floatingRaised,
56
+ },
57
+ popupInModalRaised: {
58
+ zIndex: layers.popoverRaised,
59
+ },
39
60
  popup: {
40
61
  position: 'fixed',
41
62
  top: 0,