@duro-app/ui 5.1.0 → 5.3.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 (233) hide show
  1. package/dist/Table-Cm5cCnZD.js +1524 -0
  2. package/dist/Table-Cm5cCnZD.js.map +1 -0
  3. package/dist/components/ActionBar/styles.css.d.ts +1 -1
  4. package/dist/components/ActionBar/styles.css.d.ts.map +1 -1
  5. package/dist/components/Badge/Badge.d.ts +1 -1
  6. package/dist/components/Badge/Badge.d.ts.map +1 -1
  7. package/dist/components/Badge/Badge.stories.d.ts +3 -0
  8. package/dist/components/Badge/Badge.stories.d.ts.map +1 -1
  9. package/dist/components/Badge/styles.css.d.ts +4 -0
  10. package/dist/components/Badge/styles.css.d.ts.map +1 -1
  11. package/dist/components/Button/Button.d.ts +11 -2
  12. package/dist/components/Button/Button.d.ts.map +1 -1
  13. package/dist/components/Button/Button.stories.d.ts +1 -0
  14. package/dist/components/Button/Button.stories.d.ts.map +1 -1
  15. package/dist/components/ButtonGroup/ButtonGroup.d.ts +9 -2
  16. package/dist/components/ButtonGroup/ButtonGroup.d.ts.map +1 -1
  17. package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts.map +1 -1
  18. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +19 -0
  19. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
  20. package/dist/components/ButtonGroup/ButtonGroupContext.d.ts +13 -0
  21. package/dist/components/ButtonGroup/ButtonGroupContext.d.ts.map +1 -0
  22. package/dist/components/ButtonGroup/attached.css.d.ts +34 -0
  23. package/dist/components/ButtonGroup/attached.css.d.ts.map +1 -0
  24. package/dist/components/ButtonGroup/styles.css.d.ts +7 -0
  25. package/dist/components/ButtonGroup/styles.css.d.ts.map +1 -1
  26. package/dist/components/ButtonGroup/useGroupedControl.d.ts +25 -0
  27. package/dist/components/ButtonGroup/useGroupedControl.d.ts.map +1 -0
  28. package/dist/components/Combobox/Combobox.d.ts +1 -1
  29. package/dist/components/Combobox/Combobox.d.ts.map +1 -1
  30. package/dist/components/Combobox/styles.css.d.ts +0 -64
  31. package/dist/components/Combobox/styles.css.d.ts.map +1 -1
  32. package/dist/components/Dialog/Dialog.d.ts +11 -3
  33. package/dist/components/Dialog/Dialog.d.ts.map +1 -1
  34. package/dist/components/Dialog/Dialog.stories.d.ts +4 -0
  35. package/dist/components/Dialog/Dialog.stories.d.ts.map +1 -1
  36. package/dist/components/Dialog/styles.css.d.ts +3 -2
  37. package/dist/components/Dialog/styles.css.d.ts.map +1 -1
  38. package/dist/components/DragDrop/DragDrop.d.ts.map +1 -1
  39. package/dist/components/DragDrop/styles.css.d.ts +11 -3
  40. package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
  41. package/dist/components/Drawer/Drawer.d.ts.map +1 -1
  42. package/dist/components/Drawer/styles.css.d.ts +2 -2
  43. package/dist/components/Drawer/styles.css.d.ts.map +1 -1
  44. package/dist/components/Grid/Grid.d.ts +11 -2
  45. package/dist/components/Grid/Grid.d.ts.map +1 -1
  46. package/dist/components/Grid/Grid.stories.d.ts +2 -0
  47. package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
  48. package/dist/components/Input/Input.d.ts +6 -1
  49. package/dist/components/Input/Input.d.ts.map +1 -1
  50. package/dist/components/Input/Input.meta.d.ts.map +1 -1
  51. package/dist/components/Input/Input.stories.d.ts +1 -0
  52. package/dist/components/Input/Input.stories.d.ts.map +1 -1
  53. package/dist/components/Input/styles.css.d.ts +18 -0
  54. package/dist/components/Input/styles.css.d.ts.map +1 -1
  55. package/dist/components/InputGroup/InputGroup.d.ts +6 -1
  56. package/dist/components/InputGroup/InputGroup.d.ts.map +1 -1
  57. package/dist/components/InputGroup/InputGroup.stories.d.ts +1 -0
  58. package/dist/components/InputGroup/InputGroup.stories.d.ts.map +1 -1
  59. package/dist/components/Listbox/Listbox.d.ts +75 -0
  60. package/dist/components/Listbox/Listbox.d.ts.map +1 -0
  61. package/dist/components/Listbox/Listbox.meta.d.ts +3 -0
  62. package/dist/components/Listbox/Listbox.meta.d.ts.map +1 -0
  63. package/dist/components/Listbox/Listbox.stories.d.ts +7 -0
  64. package/dist/components/Listbox/Listbox.stories.d.ts.map +1 -0
  65. package/dist/components/Listbox/styles.css.d.ts +75 -0
  66. package/dist/components/Listbox/styles.css.d.ts.map +1 -0
  67. package/dist/components/Menu/Menu.d.ts +18 -5
  68. package/dist/components/Menu/Menu.d.ts.map +1 -1
  69. package/dist/components/Menu/Menu.meta.d.ts.map +1 -1
  70. package/dist/components/Menu/Menu.stories.d.ts +7 -0
  71. package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
  72. package/dist/components/Menu/MenuContext.d.ts +3 -0
  73. package/dist/components/Menu/MenuContext.d.ts.map +1 -1
  74. package/dist/components/Menu/styles.css.d.ts +24 -15
  75. package/dist/components/Menu/styles.css.d.ts.map +1 -1
  76. package/dist/components/Menu/useMenuRoot.d.ts +14 -1
  77. package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
  78. package/dist/components/Popover/Popover.d.ts +7 -2
  79. package/dist/components/Popover/Popover.d.ts.map +1 -1
  80. package/dist/components/Popover/styles.css.d.ts +4 -1
  81. package/dist/components/Popover/styles.css.d.ts.map +1 -1
  82. package/dist/components/ScrollArea/styles.css.d.ts +1 -1
  83. package/dist/components/ScrollArea/styles.css.d.ts.map +1 -1
  84. package/dist/components/Select/Select.d.ts.map +1 -1
  85. package/dist/components/Select/Select.stories.d.ts +2 -0
  86. package/dist/components/Select/Select.stories.d.ts.map +1 -1
  87. package/dist/components/Select/styles.css.d.ts +2 -2
  88. package/dist/components/Select/styles.css.d.ts.map +1 -1
  89. package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
  90. package/dist/components/TextLink/TextLink.d.ts +24 -0
  91. package/dist/components/TextLink/TextLink.d.ts.map +1 -0
  92. package/dist/components/TextLink/TextLink.meta.d.ts +3 -0
  93. package/dist/components/TextLink/TextLink.meta.d.ts.map +1 -0
  94. package/dist/components/TextLink/TextLink.stories.d.ts +9 -0
  95. package/dist/components/TextLink/TextLink.stories.d.ts.map +1 -0
  96. package/dist/components/TextLink/styles.css.d.ts +28 -0
  97. package/dist/components/TextLink/styles.css.d.ts.map +1 -0
  98. package/dist/components/ThemeProvider/Layers.stories.d.ts +24 -0
  99. package/dist/components/ThemeProvider/Layers.stories.d.ts.map +1 -0
  100. package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
  101. package/dist/components/ThemeProvider/ThemeProvider.stories.d.ts +1 -0
  102. package/dist/components/ThemeProvider/ThemeProvider.stories.d.ts.map +1 -1
  103. package/dist/components/Toast/styles.css.d.ts +1 -0
  104. package/dist/components/Toast/styles.css.d.ts.map +1 -1
  105. package/dist/components/Toggle/Toggle.d.ts.map +1 -1
  106. package/dist/components/Toggle/styles.css.d.ts +3 -0
  107. package/dist/components/Toggle/styles.css.d.ts.map +1 -1
  108. package/dist/components/ToggleGroup/ToggleGroup.d.ts.map +1 -1
  109. package/dist/components/ToggleGroup/styles.css.d.ts +5 -0
  110. package/dist/components/ToggleGroup/styles.css.d.ts.map +1 -1
  111. package/dist/components/Toolbar/ControlContextBoundary.d.ts +10 -0
  112. package/dist/components/Toolbar/ControlContextBoundary.d.ts.map +1 -0
  113. package/dist/components/Toolbar/Toolbar.d.ts +22 -0
  114. package/dist/components/Toolbar/Toolbar.d.ts.map +1 -0
  115. package/dist/components/Toolbar/Toolbar.meta.d.ts +3 -0
  116. package/dist/components/Toolbar/Toolbar.meta.d.ts.map +1 -0
  117. package/dist/components/Toolbar/Toolbar.stories.d.ts +28 -0
  118. package/dist/components/Toolbar/Toolbar.stories.d.ts.map +1 -0
  119. package/dist/components/Toolbar/ToolbarContext.d.ts +10 -0
  120. package/dist/components/Toolbar/ToolbarContext.d.ts.map +1 -0
  121. package/dist/components/Toolbar/styles.css.d.ts +16 -0
  122. package/dist/components/Toolbar/styles.css.d.ts.map +1 -0
  123. package/dist/components/Tooltip/styles.css.d.ts +1 -1
  124. package/dist/components/Tooltip/styles.css.d.ts.map +1 -1
  125. package/dist/components/VirtualTable/styles.css.d.ts +1 -1
  126. package/dist/components/VirtualTable/styles.css.d.ts.map +1 -1
  127. package/dist/components/VisuallyHidden/VisuallyHidden.d.ts +14 -0
  128. package/dist/components/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
  129. package/dist/components/VisuallyHidden/VisuallyHidden.meta.d.ts +3 -0
  130. package/dist/components/VisuallyHidden/VisuallyHidden.meta.d.ts.map +1 -0
  131. package/dist/components/VisuallyHidden/VisuallyHidden.stories.d.ts +7 -0
  132. package/dist/components/VisuallyHidden/VisuallyHidden.stories.d.ts.map +1 -0
  133. package/dist/docs/FlexGrow.stories.d.ts +7 -0
  134. package/dist/docs/FlexGrow.stories.d.ts.map +1 -0
  135. package/dist/index.css +1 -1
  136. package/dist/index.d.ts +7 -1
  137. package/dist/index.d.ts.map +1 -1
  138. package/dist/index.js +3768 -3356
  139. package/dist/index.js.map +1 -1
  140. package/dist/index.native.d.ts +1 -0
  141. package/dist/index.native.d.ts.map +1 -1
  142. package/dist/shared/ModalContext.d.ts +14 -0
  143. package/dist/shared/ModalContext.d.ts.map +1 -0
  144. package/dist/shared/mergeRefs.d.ts +4 -0
  145. package/dist/shared/mergeRefs.d.ts.map +1 -0
  146. package/dist/shared/useOrderedRegistry.d.ts +26 -0
  147. package/dist/shared/useOrderedRegistry.d.ts.map +1 -0
  148. package/dist/styles/visually-hidden.css.d.ts +6 -0
  149. package/dist/styles/visually-hidden.css.d.ts.map +1 -1
  150. package/dist/table.js +45 -45
  151. package/dist/table.js.map +1 -1
  152. package/package.json +2 -2
  153. package/src/components/ActionBar/styles.css.ts +2 -1
  154. package/src/components/Badge/Badge.stories.tsx +62 -1
  155. package/src/components/Badge/Badge.tsx +1 -1
  156. package/src/components/Badge/styles.css.ts +5 -0
  157. package/src/components/Button/Button.stories.tsx +41 -0
  158. package/src/components/Button/Button.tsx +22 -1
  159. package/src/components/ButtonGroup/ButtonGroup.meta.ts +14 -1
  160. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +271 -1
  161. package/src/components/ButtonGroup/ButtonGroup.tsx +46 -14
  162. package/src/components/ButtonGroup/ButtonGroupContext.ts +19 -0
  163. package/src/components/ButtonGroup/attached.css.ts +44 -0
  164. package/src/components/ButtonGroup/styles.css.ts +8 -0
  165. package/src/components/ButtonGroup/useGroupedControl.ts +75 -0
  166. package/src/components/Combobox/Combobox.tsx +25 -79
  167. package/src/components/Combobox/styles.css.ts +0 -74
  168. package/src/components/Dialog/Dialog.meta.ts +1 -1
  169. package/src/components/Dialog/Dialog.stories.tsx +109 -3
  170. package/src/components/Dialog/Dialog.tsx +68 -10
  171. package/src/components/Dialog/styles.css.ts +5 -2
  172. package/src/components/DragDrop/DragDrop.tsx +15 -8
  173. package/src/components/DragDrop/styles.css.ts +20 -4
  174. package/src/components/Drawer/Drawer.tsx +5 -1
  175. package/src/components/Drawer/styles.css.ts +3 -2
  176. package/src/components/Grid/Grid.meta.ts +1 -1
  177. package/src/components/Grid/Grid.stories.tsx +53 -1
  178. package/src/components/Grid/Grid.tsx +23 -11
  179. package/src/components/Input/Input.meta.ts +10 -3
  180. package/src/components/Input/Input.stories.tsx +52 -2
  181. package/src/components/Input/Input.tsx +12 -3
  182. package/src/components/Input/styles.css.ts +29 -0
  183. package/src/components/InputGroup/InputGroup.stories.tsx +20 -0
  184. package/src/components/InputGroup/InputGroup.tsx +20 -2
  185. package/src/components/Listbox/Listbox.meta.ts +52 -0
  186. package/src/components/Listbox/Listbox.stories.tsx +170 -0
  187. package/src/components/Listbox/Listbox.tsx +278 -0
  188. package/src/components/Listbox/styles.css.ts +90 -0
  189. package/src/components/Menu/Menu.meta.ts +2 -1
  190. package/src/components/Menu/Menu.stories.tsx +289 -8
  191. package/src/components/Menu/Menu.tsx +107 -29
  192. package/src/components/Menu/MenuContext.ts +3 -0
  193. package/src/components/Menu/styles.css.ts +32 -15
  194. package/src/components/Menu/useMenuRoot.ts +105 -48
  195. package/src/components/Popover/Popover.tsx +26 -5
  196. package/src/components/Popover/styles.css.ts +9 -1
  197. package/src/components/ScrollArea/styles.css.ts +2 -1
  198. package/src/components/Select/Select.stories.tsx +20 -1
  199. package/src/components/Select/Select.tsx +9 -3
  200. package/src/components/Select/styles.css.ts +3 -2
  201. package/src/components/Select/useSelectRoot.ts +17 -0
  202. package/src/components/TextLink/TextLink.meta.ts +33 -0
  203. package/src/components/TextLink/TextLink.stories.tsx +63 -0
  204. package/src/components/TextLink/TextLink.tsx +46 -0
  205. package/src/components/TextLink/styles.css.ts +58 -0
  206. package/src/components/ThemeProvider/Layers.stories.tsx +379 -0
  207. package/src/components/ThemeProvider/ThemeProvider.stories.tsx +50 -0
  208. package/src/components/ThemeProvider/ThemeProvider.tsx +4 -2
  209. package/src/components/Toast/styles.css.ts +3 -0
  210. package/src/components/Toggle/Toggle.tsx +20 -4
  211. package/src/components/Toggle/styles.css.ts +6 -0
  212. package/src/components/ToggleGroup/ToggleGroup.tsx +16 -4
  213. package/src/components/ToggleGroup/styles.css.ts +7 -0
  214. package/src/components/Toolbar/ControlContextBoundary.tsx +16 -0
  215. package/src/components/Toolbar/Toolbar.meta.ts +48 -0
  216. package/src/components/Toolbar/Toolbar.stories.tsx +468 -0
  217. package/src/components/Toolbar/Toolbar.tsx +113 -0
  218. package/src/components/Toolbar/ToolbarContext.ts +15 -0
  219. package/src/components/Toolbar/styles.css.ts +17 -0
  220. package/src/components/Tooltip/styles.css.ts +2 -1
  221. package/src/components/VirtualTable/styles.css.ts +2 -1
  222. package/src/components/VisuallyHidden/VisuallyHidden.meta.ts +19 -0
  223. package/src/components/VisuallyHidden/VisuallyHidden.stories.tsx +30 -0
  224. package/src/components/VisuallyHidden/VisuallyHidden.tsx +22 -0
  225. package/src/docs/FlexGrow.stories.tsx +83 -0
  226. package/src/index.native.ts +1 -0
  227. package/src/index.ts +7 -1
  228. package/src/shared/ModalContext.ts +18 -0
  229. package/src/shared/mergeRefs.ts +11 -0
  230. package/src/shared/useOrderedRegistry.ts +57 -0
  231. package/src/styles/visually-hidden.css.ts +8 -0
  232. package/dist/Table-BkrgH0jb.js +0 -1199
  233. package/dist/Table-BkrgH0jb.js.map +0 -1
@@ -0,0 +1,379 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, waitFor, within} from 'storybook/test'
3
+ import {Dialog} from '../Dialog/Dialog'
4
+ import {Drawer} from '../Drawer/Drawer'
5
+ import {DragDrop} from '../DragDrop/DragDrop'
6
+ import {Popover} from '../Popover/Popover'
7
+ import {Select} from '../Select/Select'
8
+ import {ToastProvider, useToast} from '../Toast/ToastProvider'
9
+ import {Button} from '../Button/Button'
10
+ import {Stack} from '../Stack/Stack'
11
+ import {Text} from '../Text/Text'
12
+
13
+ /**
14
+ * What stacks above what. Dialog, Drawer, the popups and the toast region all
15
+ * portal into the ThemeProvider mount, so their `layers` values order them in
16
+ * one stacking context: a popup or a toast opened over a modal must be the
17
+ * topmost element where it is drawn.
18
+ */
19
+ const meta: Meta = {
20
+ title: 'Theme/Layers',
21
+ }
22
+
23
+ export default meta
24
+ type Story = StoryObj
25
+
26
+ const page = () => within(document.body)
27
+
28
+ /** True when the element drawn at `el`'s centre is `el` or inside it. */
29
+ function onTop(el: Element): boolean {
30
+ const r = el.getBoundingClientRect()
31
+ const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
32
+ return hit !== null && el.contains(hit)
33
+ }
34
+
35
+ function LinkPopover() {
36
+ return (
37
+ <Popover.Root>
38
+ <Popover.Trigger>Insert link</Popover.Trigger>
39
+ <Popover.Popup label="Link">
40
+ <Stack gap="sm">
41
+ <Text>Paste a URL.</Text>
42
+ <Popover.Close>Apply</Popover.Close>
43
+ </Stack>
44
+ </Popover.Popup>
45
+ </Popover.Root>
46
+ )
47
+ }
48
+
49
+ export const PopoverInDialog: Story = {
50
+ render: () => (
51
+ <Dialog.Root>
52
+ <Dialog.Trigger>
53
+ <Button>Open editor</Button>
54
+ </Dialog.Trigger>
55
+ <Dialog.Portal size="sm">
56
+ <Dialog.Header>
57
+ <Dialog.Title>Editor</Dialog.Title>
58
+ </Dialog.Header>
59
+ <Dialog.Body>
60
+ <LinkPopover />
61
+ </Dialog.Body>
62
+ </Dialog.Portal>
63
+ </Dialog.Root>
64
+ ),
65
+ play: async ({canvas, userEvent}) => {
66
+ await userEvent.click(canvas.getByRole('button', {name: 'Open editor'}))
67
+ await userEvent.click(await page().findByRole('button', {name: 'Insert link'}))
68
+ const popup = await page().findByRole('dialog', {name: 'Link'})
69
+ await waitFor(() => expect(onTop(popup)).toBe(true))
70
+ },
71
+ }
72
+
73
+ export const PopoverInDrawer: Story = {
74
+ render: () => (
75
+ <Drawer.Root>
76
+ <Drawer.Trigger>
77
+ <Button>Open details</Button>
78
+ </Drawer.Trigger>
79
+ <Drawer.Portal>
80
+ <Drawer.Header>
81
+ <Drawer.Title>Details</Drawer.Title>
82
+ </Drawer.Header>
83
+ <Drawer.Body>
84
+ <LinkPopover />
85
+ </Drawer.Body>
86
+ </Drawer.Portal>
87
+ </Drawer.Root>
88
+ ),
89
+ play: async ({canvas, userEvent}) => {
90
+ await userEvent.click(canvas.getByRole('button', {name: 'Open details'}))
91
+ await userEvent.click(await page().findByRole('button', {name: 'Insert link'}))
92
+ const popup = await page().findByRole('dialog', {name: 'Link'})
93
+ await waitFor(() => expect(onTop(popup)).toBe(true))
94
+ },
95
+ }
96
+
97
+ function SaveButton() {
98
+ const {toast} = useToast()
99
+ return (
100
+ <Button onClick={() => toast({variant: 'success', message: 'Draft saved', duration: 0})}>
101
+ Save draft
102
+ </Button>
103
+ )
104
+ }
105
+
106
+ export const ToastOverDialog: Story = {
107
+ render: () => (
108
+ <ToastProvider>
109
+ <Dialog.Root>
110
+ <Dialog.Trigger>
111
+ <Button>Open draft</Button>
112
+ </Dialog.Trigger>
113
+ <Dialog.Portal size="sm">
114
+ <Dialog.Header>
115
+ <Dialog.Title>Draft</Dialog.Title>
116
+ </Dialog.Header>
117
+ <Dialog.Body>
118
+ <SaveButton />
119
+ </Dialog.Body>
120
+ </Dialog.Portal>
121
+ </Dialog.Root>
122
+ </ToastProvider>
123
+ ),
124
+ play: async ({canvas, userEvent}) => {
125
+ await userEvent.click(canvas.getByRole('button', {name: 'Open draft'}))
126
+ await userEvent.click(await page().findByRole('button', {name: 'Save draft'}))
127
+ const message = await page().findByText('Draft saved')
128
+ const toast = message.closest('[role="status"]') as Element
129
+ await waitFor(() => expect(onTop(toast)).toBe(true))
130
+ },
131
+ }
132
+
133
+ const ITEMS = [
134
+ {id: 'a', name: 'Alpha'},
135
+ {id: 'b', name: 'Beta'},
136
+ ]
137
+
138
+ export const DragDropInDialog: Story = {
139
+ render: () => (
140
+ <Dialog.Root>
141
+ <Dialog.Trigger>
142
+ <Button>Open board</Button>
143
+ </Dialog.Trigger>
144
+ <Dialog.Portal size="sm">
145
+ <Dialog.Header>
146
+ <Dialog.Title>Board</Dialog.Title>
147
+ </Dialog.Header>
148
+ <Dialog.Body>
149
+ <DragDrop.Root<{id: string; name: string}> onDrop={() => {}}>
150
+ <Stack gap="md">
151
+ <DragDrop.Zone id="from" label="From">
152
+ <Stack gap="sm">
153
+ {ITEMS.map((item) => (
154
+ <DragDrop.Item
155
+ key={item.id}
156
+ id={item.id}
157
+ zone="from"
158
+ label={item.name}
159
+ data={item}
160
+ >
161
+ <Button variant="secondary">{item.name}</Button>
162
+ </DragDrop.Item>
163
+ ))}
164
+ </Stack>
165
+ </DragDrop.Zone>
166
+ <DragDrop.Zone id="to" label="To">
167
+ <Text>Drop here.</Text>
168
+ </DragDrop.Zone>
169
+ </Stack>
170
+ </DragDrop.Root>
171
+ </Dialog.Body>
172
+ </Dialog.Portal>
173
+ </Dialog.Root>
174
+ ),
175
+ play: async ({canvas, userEvent}) => {
176
+ await userEvent.click(canvas.getByRole('button', {name: 'Open board'}))
177
+ const alpha = await page().findByRole('button', {name: 'Alpha'})
178
+ const target = page().getByText('Drop here.')
179
+ const from = alpha.parentElement as Element
180
+ const a = from.getBoundingClientRect()
181
+ const b = target.getBoundingClientRect()
182
+ const init = (x: number, y: number) => ({
183
+ bubbles: true,
184
+ cancelable: true,
185
+ pointerId: 7,
186
+ pointerType: 'mouse',
187
+ isPrimary: true,
188
+ button: 0,
189
+ buttons: 1,
190
+ clientX: x,
191
+ clientY: y,
192
+ })
193
+ const start = {x: a.left + a.width / 2, y: a.top + a.height / 2}
194
+ const end = {x: b.left + b.width / 2, y: b.top + b.height / 2}
195
+ from.dispatchEvent(new PointerEvent('pointerdown', init(start.x, start.y)))
196
+ for (let i = 1; i <= 6; i++) {
197
+ const x = start.x + ((end.x - start.x) * i) / 6
198
+ const y = start.y + ((end.y - start.y) * i) / 6
199
+ document.dispatchEvent(new PointerEvent('pointermove', init(x, y)))
200
+ await new Promise((r) => requestAnimationFrame(() => r(null)))
201
+ }
202
+ // Mid-drag: the ghost is the aria-hidden copy. It is click-through, so it
203
+ // takes hits for the duration of the check only.
204
+ const ghost = await waitFor(() => {
205
+ const found = [...document.querySelectorAll('[aria-hidden="true"]')].find(
206
+ (el) => el.textContent === 'Alpha' && getComputedStyle(el).position === 'fixed',
207
+ ) as HTMLElement | undefined
208
+ if (!found) throw new Error('no ghost yet')
209
+ return found
210
+ })
211
+ ghost.style.pointerEvents = 'auto'
212
+ const visible = onTop(ghost)
213
+ ghost.style.pointerEvents = ''
214
+ document.dispatchEvent(new PointerEvent('pointerup', {...init(end.x, end.y), buttons: 0}))
215
+ await expect(visible).toBe(true)
216
+ },
217
+ }
218
+
219
+ /** A Select opened inside a Popover: its listbox (mounted before the Popover
220
+ * opens) and click-catcher stack above the Popover. */
221
+ export const SelectInPopover: Story = {
222
+ render: () => (
223
+ <Popover.Root>
224
+ <Popover.Trigger>Style</Popover.Trigger>
225
+ <Popover.Popup label="Style">
226
+ <Select.Root defaultValue="primary">
227
+ <Select.Trigger aria-label="Variant">
228
+ <Select.Value />
229
+ <Select.Icon />
230
+ </Select.Trigger>
231
+ <Select.Popup>
232
+ <Select.Item value="primary">Primary</Select.Item>
233
+ <Select.Item value="secondary">Secondary</Select.Item>
234
+ <Select.Item value="ghost">Ghost</Select.Item>
235
+ </Select.Popup>
236
+ </Select.Root>
237
+ </Popover.Popup>
238
+ </Popover.Root>
239
+ ),
240
+ play: async ({canvas, userEvent}) => {
241
+ await userEvent.click(canvas.getByRole('button', {name: 'Style'}))
242
+ await userEvent.click(await page().findByRole('combobox', {name: 'Variant'}))
243
+ for (const name of ['Primary', 'Secondary', 'Ghost']) {
244
+ const option = await page().findByRole('option', {name})
245
+ await waitFor(() => expect(onTop(option)).toBe(true))
246
+ }
247
+ },
248
+ }
249
+
250
+ /** A Dialog opened from a button inside an open Popover: the Dialog is on
251
+ * top, and the Popover does not stay drawn over it. */
252
+ export const DialogFromPopover: Story = {
253
+ render: () => (
254
+ <Popover.Root>
255
+ <Popover.Trigger>Share</Popover.Trigger>
256
+ <Popover.Popup label="Share">
257
+ <Dialog.Root>
258
+ <Dialog.Trigger>
259
+ <Button>Invite people</Button>
260
+ </Dialog.Trigger>
261
+ <Dialog.Portal size="sm">
262
+ <Dialog.Header>
263
+ <Dialog.Title>Invite</Dialog.Title>
264
+ </Dialog.Header>
265
+ <Dialog.Body>
266
+ <Text>Send an invitation.</Text>
267
+ </Dialog.Body>
268
+ </Dialog.Portal>
269
+ </Dialog.Root>
270
+ </Popover.Popup>
271
+ </Popover.Root>
272
+ ),
273
+ play: async ({canvas, userEvent}) => {
274
+ await userEvent.click(canvas.getByRole('button', {name: 'Share'}))
275
+ await userEvent.click(await page().findByRole('button', {name: 'Invite people'}))
276
+ const dialog = await page().findByRole('dialog', {name: 'Invite'})
277
+ await waitFor(() => expect(onTop(dialog)).toBe(true))
278
+ // The Popover is either gone or under the modal's backdrop.
279
+ const popover = page().queryByRole('dialog', {name: 'Share'})
280
+ if (popover) await expect(onTop(popover)).toBe(false)
281
+ },
282
+ }
283
+
284
+ /** DragDrop inside a Dialog whose body is scrolled: the ghost stays under the
285
+ * pointer and on top. */
286
+ export const DragDropInScrolledDialog: Story = {
287
+ render: () => (
288
+ <Dialog.Root>
289
+ <Dialog.Trigger>
290
+ <Button>Open long board</Button>
291
+ </Dialog.Trigger>
292
+ <Dialog.Portal size="sm">
293
+ <Dialog.Header>
294
+ <Dialog.Title>Long board</Dialog.Title>
295
+ </Dialog.Header>
296
+ <Dialog.Body>
297
+ <Stack gap="md">
298
+ {Array.from({length: 30}, (_, i) => (
299
+ <Text key={i}>{`Line ${i + 1}`}</Text>
300
+ ))}
301
+ <DragDrop.Root<{id: string; name: string}> onDrop={() => {}}>
302
+ <Stack gap="md">
303
+ <DragDrop.Zone id="from" label="From">
304
+ <Stack gap="sm">
305
+ {ITEMS.map((item) => (
306
+ <DragDrop.Item
307
+ key={item.id}
308
+ id={item.id}
309
+ zone="from"
310
+ label={item.name}
311
+ data={item}
312
+ >
313
+ <Button variant="secondary">{item.name}</Button>
314
+ </DragDrop.Item>
315
+ ))}
316
+ </Stack>
317
+ </DragDrop.Zone>
318
+ <DragDrop.Zone id="to" label="To">
319
+ <Text>Drop here.</Text>
320
+ </DragDrop.Zone>
321
+ </Stack>
322
+ </DragDrop.Root>
323
+ </Stack>
324
+ </Dialog.Body>
325
+ </Dialog.Portal>
326
+ </Dialog.Root>
327
+ ),
328
+ play: async ({canvas, userEvent}) => {
329
+ await userEvent.click(canvas.getByRole('button', {name: 'Open long board'}))
330
+ const alpha = await page().findByRole('button', {name: 'Alpha'})
331
+ alpha.scrollIntoView({block: 'center'})
332
+ let scrolled = 0
333
+ for (let el = alpha.parentElement; el; el = el.parentElement) scrolled += el.scrollTop
334
+ await expect(scrolled).toBeGreaterThan(0)
335
+ const from = alpha.parentElement as Element
336
+ const target = page().getByText('Drop here.')
337
+ const a = from.getBoundingClientRect()
338
+ const b = target.getBoundingClientRect()
339
+ const init = (x: number, y: number) => ({
340
+ bubbles: true,
341
+ cancelable: true,
342
+ pointerId: 7,
343
+ pointerType: 'mouse',
344
+ isPrimary: true,
345
+ button: 0,
346
+ buttons: 1,
347
+ clientX: x,
348
+ clientY: y,
349
+ })
350
+ const start = {x: a.left + a.width / 2, y: a.top + a.height / 2}
351
+ const end = {x: b.left + b.width / 2, y: b.top + b.height / 2}
352
+ from.dispatchEvent(new PointerEvent('pointerdown', init(start.x, start.y)))
353
+ for (let i = 1; i <= 6; i++) {
354
+ const x = start.x + ((end.x - start.x) * i) / 6
355
+ const y = start.y + ((end.y - start.y) * i) / 6
356
+ document.dispatchEvent(new PointerEvent('pointermove', init(x, y)))
357
+ await new Promise((r) => requestAnimationFrame(() => r(null)))
358
+ }
359
+ const ghost = await waitFor(() => {
360
+ const found = [...document.querySelectorAll('[aria-hidden="true"]')].find(
361
+ (el) => el.textContent === 'Alpha' && getComputedStyle(el).position === 'fixed',
362
+ ) as HTMLElement | undefined
363
+ if (!found) throw new Error('no ghost yet')
364
+ return found
365
+ })
366
+ const g = ghost.getBoundingClientRect()
367
+ ghost.style.pointerEvents = 'auto'
368
+ const visible = onTop(ghost)
369
+ ghost.style.pointerEvents = ''
370
+ document.dispatchEvent(new PointerEvent('pointerup', {...init(end.x, end.y), buttons: 0}))
371
+ await expect(visible).toBe(true)
372
+ // Under the pointer: the ghost keeps the grab offset, so the pointer is
373
+ // inside it, not displaced by the scroll.
374
+ await expect(end.x).toBeGreaterThanOrEqual(g.left)
375
+ await expect(end.x).toBeLessThanOrEqual(g.right)
376
+ await expect(end.y).toBeGreaterThanOrEqual(g.top)
377
+ await expect(end.y).toBeLessThanOrEqual(g.bottom)
378
+ },
379
+ }
@@ -163,3 +163,53 @@ export const AllThemes: Story = {
163
163
  await expect(canvas.getByText('high-contrast')).toBeInTheDocument()
164
164
  },
165
165
  }
166
+
167
+ const contrastStyles = css.create({
168
+ surface: {
169
+ padding: spacing.md,
170
+ borderRadius: radii.md,
171
+ backgroundColor: colors.contrastSurface,
172
+ color: colors.onContrastSurface,
173
+ },
174
+ })
175
+
176
+ /** Parse a computed `rgb(r, g, b)` / `rgba(r, g, b, a)` colour. */
177
+ function rgbOf(value: string): [number, number, number] {
178
+ const parts = value.match(/\d+(\.\d+)?/g)?.map(Number) ?? []
179
+ return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]
180
+ }
181
+
182
+ /** WCAG 2.x relative luminance contrast ratio. */
183
+ function contrastRatio(a: string, b: string): number {
184
+ const lum = (rgb: [number, number, number]) => {
185
+ const [r, g, bl] = rgb.map((c) => {
186
+ const s = c / 255
187
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
188
+ }) as [number, number, number]
189
+ return 0.2126 * r + 0.7152 * g + 0.0722 * bl
190
+ }
191
+ const [hi, lo] = [lum(rgbOf(a)), lum(rgbOf(b))].sort((x, y) => y - x) as [number, number]
192
+ return (hi + 0.05) / (lo + 0.05)
193
+ }
194
+
195
+ // contrastSurface takes the opposite tone of the theme; its text must still
196
+ // read at 4.5:1 or better in each one.
197
+ export const ContrastSurfaceInEveryTheme: Story = {
198
+ render: () => (
199
+ <html.div style={sideBySideStyles.wrapper}>
200
+ {(['dark', 'light', 'high-contrast'] as const).map((theme) => (
201
+ <ThemeProvider key={theme} theme={theme}>
202
+ <html.div style={contrastStyles.surface}>{`Contrast surface (${theme})`}</html.div>
203
+ </ThemeProvider>
204
+ ))}
205
+ </html.div>
206
+ ),
207
+ play: async ({canvas}) => {
208
+ for (const theme of ['dark', 'light', 'high-contrast']) {
209
+ const el = canvas.getByText(`Contrast surface (${theme})`)
210
+ const style = getComputedStyle(el)
211
+ const ratio = contrastRatio(style.color, style.backgroundColor)
212
+ await expect(ratio).toBeGreaterThanOrEqual(4.5)
213
+ }
214
+ },
215
+ }
@@ -3,6 +3,7 @@ import {css, html} from 'react-strict-dom'
3
3
  import {colors} from '@duro-app/tokens/tokens/colors.css'
4
4
  import {lightTheme, lightShadows} from '@duro-app/tokens/themes/light.css'
5
5
  import {highContrastTheme, highContrastShadows} from '@duro-app/tokens/themes/high-contrast.css'
6
+ import {layers} from '@duro-app/tokens/tokens/layers.css'
6
7
 
7
8
  export type ThemeName = 'dark' | 'light' | 'high-contrast'
8
9
 
@@ -32,8 +33,9 @@ const styles = css.create({
32
33
  right: 0,
33
34
  bottom: 0,
34
35
  pointerEvents: 'none',
35
- // Above Dialog (1001) so popups inside dialogs render on top.
36
- zIndex: 1100,
36
+ // The stacking context the layers scale orders: Dialog, Drawer, the
37
+ // popups and toasts all portal in here.
38
+ zIndex: layers.portal,
37
39
  },
38
40
  })
39
41
 
@@ -6,6 +6,7 @@ import {shadows} from '@duro-app/tokens/tokens/shadows.css'
6
6
  import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
7
7
  import {sizes} from '@duro-app/tokens/tokens/sizes.css'
8
8
  import {borders} from '@duro-app/tokens/tokens/borders.css'
9
+ import {layers} from '@duro-app/tokens/tokens/layers.css'
9
10
 
10
11
  const enter = css.keyframes({
11
12
  from: {opacity: 0, transform: 'translateY(10px)'},
@@ -28,6 +29,8 @@ export const styles = css.create({
28
29
  paddingLeft: spacing.md,
29
30
  paddingRight: spacing.md,
30
31
  pointerEvents: 'none',
32
+ // Above modals and popups: a toast fired while a Dialog is open shows.
33
+ zIndex: layers.toast,
31
34
  },
32
35
  toast: {
33
36
  pointerEvents: 'auto',
@@ -2,6 +2,8 @@ import {type ReactNode, useCallback, useLayoutEffect, useRef} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
3
  import {useControllableValue} from '../../hooks/useControllableValue'
4
4
  import {useToggleGroup} from '../ToggleGroup/ToggleGroupContext'
5
+ import {useGroupedControl} from '../ButtonGroup/useGroupedControl'
6
+ import {mergeRefs} from '../../shared/mergeRefs'
5
7
  import {styles} from './styles.css'
6
8
 
7
9
  export type ToggleSize = 'default' | 'small'
@@ -66,6 +68,9 @@ export function Toggle({
66
68
  )
67
69
 
68
70
  const pressed = groupPressed ?? standalonePressed
71
+ const grouped = useGroupedControl<HTMLButtonElement>({disabled})
72
+ // In a Toolbar the toolbar owns the roving tabindex, not a wrapping group.
73
+ const roving = wrap && value !== undefined && !grouped.onFocus
69
74
 
70
75
  const handleClick = useCallback(() => {
71
76
  if (disabled) return
@@ -78,10 +83,18 @@ export function Toggle({
78
83
 
79
84
  return (
80
85
  <html.button
81
- ref={ref}
86
+ ref={mergeRefs(ref, grouped.ref)}
82
87
  type="button"
83
- tabIndex={wrap && value !== undefined ? (group?.tabStopValue === value ? 0 : -1) : undefined}
84
- onFocus={wrap && value !== undefined ? () => group?.onItemFocus(value) : undefined}
88
+ tabIndex={
89
+ grouped.onFocus
90
+ ? grouped.tabIndex
91
+ : roving
92
+ ? group?.tabStopValue === value
93
+ ? 0
94
+ : -1
95
+ : undefined
96
+ }
97
+ onFocus={grouped.onFocus ?? (roving ? () => group?.onItemFocus(value) : undefined)}
85
98
  aria-pressed={pressed}
86
99
  aria-label={ariaLabel}
87
100
  disabled={disabled}
@@ -92,8 +105,11 @@ export function Toggle({
92
105
  sizeMap[size],
93
106
  pressed ? styles.pressed : styles.unpressed,
94
107
  wrap && wrappedSizeMap[size],
95
- isGrouped && !wrap && styles.grouped,
108
+ // In an attached ButtonGroup the group joins the borders instead.
109
+ isGrouped && !wrap && !grouped.attached && styles.grouped,
96
110
  disabled && styles.disabled,
111
+ grouped.style,
112
+ grouped.attached && pressed && styles.attachedPressed,
97
113
  ]}
98
114
  >
99
115
  {children}
@@ -75,6 +75,12 @@ export const styles = css.create({
75
75
  // Override any borderColor set by pressed state — dividers always use border token
76
76
  borderColor: colors.border,
77
77
  },
78
+ // Pressed, in an attached group: the accent edge is drawn inset, inside the
79
+ // border a neighbour's negative margin overlaps, so it shows on all four
80
+ // sides without a z-index (which stays for focus).
81
+ attachedPressed: {
82
+ boxShadow: `inset 0 0 0 ${borders.hairline} ${colors.accent}`,
83
+ },
78
84
  disabled: {
79
85
  opacity: 0.5,
80
86
  cursor: 'not-allowed',
@@ -6,6 +6,8 @@ import {ToggleGroupContext, type Orientation} from './ToggleGroupContext'
6
6
  import {useFieldGroupLabelling} from '../Field/FieldContext'
7
7
  import {ScrollArea} from '../ScrollArea/ScrollArea'
8
8
  import {useRovingFocus} from './useRovingFocus'
9
+ import {useToolbar} from '../Toolbar/ToolbarContext'
10
+ import {useButtonGroup} from '../ButtonGroup/ButtonGroupContext'
9
11
  import {styles} from './styles.css'
10
12
 
11
13
  interface ToggleGroupProps {
@@ -72,7 +74,12 @@ export function ToggleGroup({
72
74
  )
73
75
 
74
76
  const rootRef = useRef<HTMLDivElement>(null)
75
- const roving = useRovingFocus({enabled: wrap, pressed: value, rootRef})
77
+ // Inside a Toolbar, the toolbar's roving focus covers these toggles: one
78
+ // handler, never two. Inside an attached ButtonGroup, the group draws the
79
+ // borders and corners.
80
+ const inToolbar = useToolbar() !== null
81
+ const inAttachedGroup = useButtonGroup() !== null
82
+ const roving = useRovingFocus({enabled: wrap && !inToolbar, pressed: value, rootRef})
76
83
  const scrolls = wrap && maxRows !== undefined
77
84
  const pressedValue = value[0]
78
85
 
@@ -107,10 +114,15 @@ export function ToggleGroup({
107
114
  const group = (
108
115
  <html.div
109
116
  ref={rootRef}
110
- role="toolbar"
111
- aria-orientation={orientation}
117
+ role={inToolbar ? 'group' : 'toolbar'}
118
+ aria-orientation={inToolbar ? undefined : orientation}
112
119
  {...a11y}
113
- style={[styles.root, orientation === 'vertical' && styles.vertical, wrap && styles.wrap]}
120
+ style={[
121
+ styles.root,
122
+ orientation === 'vertical' && styles.vertical,
123
+ wrap && styles.wrap,
124
+ inAttachedGroup && styles.inAttachedGroup,
125
+ ]}
114
126
  >
115
127
  {children}
116
128
  </html.div>
@@ -44,4 +44,11 @@ export const styles = css.create({
44
44
  vertical: {
45
45
  flexDirection: 'column',
46
46
  },
47
+ // In an attached ButtonGroup: no frame of its own; its toggles join the
48
+ // group's border like any other control.
49
+ inAttachedGroup: {
50
+ borderWidth: 0,
51
+ borderRadius: 0,
52
+ overflow: 'visible',
53
+ },
47
54
  })
@@ -0,0 +1,16 @@
1
+ import type {ReactNode} from 'react'
2
+ import {ButtonGroupContext} from '../ButtonGroup/ButtonGroupContext'
3
+ import {ToolbarContext} from './ToolbarContext'
4
+
5
+ /**
6
+ * Popups and modals stop the ButtonGroup and Toolbar contexts. A portal keeps
7
+ * its React parent, so without this the buttons in a Popover opened from a
8
+ * toolbar would come out squared and with tabindex=-1.
9
+ */
10
+ export function ControlContextBoundary({children}: {children: ReactNode}) {
11
+ return (
12
+ <ButtonGroupContext.Provider value={null}>
13
+ <ToolbarContext.Provider value={null}>{children}</ToolbarContext.Provider>
14
+ </ButtonGroupContext.Provider>
15
+ )
16
+ }
@@ -0,0 +1,48 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'A row of controls with one tab stop (the WAI-ARIA toolbar pattern): Tab enters at the last focused control, Left/Right move across every control inside — through attached ButtonGroups too — and Home/End jump to the ends. A Select or Menu trigger keeps its own keys, and a control whose popup is open keeps Left/Right.',
6
+ whenToUse: [
7
+ 'An editor toolbar: text style, format toggles, insert menus',
8
+ 'A dense set of related actions that should cost one Tab, not one per control',
9
+ ],
10
+ whenNotToUse: [
11
+ 'A dialog footer or a form action row — use ButtonGroup (every button its own tab stop)',
12
+ 'A floating bulk-selection bar — use ActionBar',
13
+ 'A vertical toolbar holding a Select: a closed Select takes ArrowDown/ArrowUp to open, so use the horizontal orientation (a Menu is fine: its closed trigger handles no arrow key, Enter or Space opens it)',
14
+ ],
15
+ anatomy: {
16
+ required: ['Toolbar', 'aria-label'],
17
+ optional: ['ButtonGroup attached', 'Toggle', 'Button', 'Select', 'Menu', 'Popover'],
18
+ },
19
+ relatedTo: [
20
+ {
21
+ component: 'ButtonGroup',
22
+ kind: 'composition',
23
+ relationship:
24
+ 'Place attached ButtonGroups inside a Toolbar: the group joins the controls visually, the toolbar gives them one tab stop and arrow keys',
25
+ },
26
+ {
27
+ component: 'ToggleGroup',
28
+ kind: 'composition',
29
+ relationship:
30
+ 'A ToggleGroup inside a Toolbar hands its arrow keys to the toolbar (one roving handler)',
31
+ },
32
+ {
33
+ component: 'ActionBar',
34
+ kind: 'contrast',
35
+ relationship: 'ActionBar floats over a selection; Toolbar sits in the layout',
36
+ },
37
+ ],
38
+ example: `<Toolbar aria-label="Formatting">
39
+ <ButtonGroup attached aria-label="Text style">
40
+ <Toggle aria-label="Bold" pressed={bold} onPressedChange={setBold}>B</Toggle>
41
+ <Toggle aria-label="Italic" pressed={italic} onPressedChange={setItalic}>I</Toggle>
42
+ </ButtonGroup>
43
+ <Menu.Root>
44
+ <Menu.Trigger>Insert</Menu.Trigger>
45
+ <Menu.Popup>…</Menu.Popup>
46
+ </Menu.Root>
47
+ </Toolbar>`,
48
+ }