@duro-app/ui 4.4.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{Table-JTgcR_Fh.js → Table-CUp12BKZ.js} +78 -75
- package/dist/Table-CUp12BKZ.js.map +1 -0
- package/dist/components/Combobox/Combobox.d.ts.map +1 -1
- package/dist/components/Combobox/Combobox.meta.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.d.ts.map +1 -1
- package/dist/components/Drawer/DrawerScroll.stories.d.ts +13 -0
- package/dist/components/Drawer/DrawerScroll.stories.d.ts.map +1 -0
- package/dist/components/Drawer/useSwipeDismiss.d.ts +7 -2
- package/dist/components/Drawer/useSwipeDismiss.d.ts.map +1 -1
- package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
- package/dist/components/Popover/Popover.d.ts.map +1 -1
- package/dist/components/Popover/Popover.meta.d.ts.map +1 -1
- package/dist/components/Popover/PopoverLayerContext.d.ts +10 -0
- package/dist/components/Popover/PopoverLayerContext.d.ts.map +1 -0
- package/dist/components/Popover/PopoverLayers.stories.d.ts +13 -0
- package/dist/components/Popover/PopoverLayers.stories.d.ts.map +1 -0
- package/dist/components/ScrollArea/ScrollArea.d.ts +8 -2
- package/dist/components/ScrollArea/ScrollArea.d.ts.map +1 -1
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.meta.d.ts.map +1 -1
- package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
- package/dist/components/Toggle/Toggle.d.ts.map +1 -1
- package/dist/components/Toggle/rowHeight.d.ts +17 -0
- package/dist/components/Toggle/rowHeight.d.ts.map +1 -0
- package/dist/components/Toggle/styles.css.d.ts +10 -0
- package/dist/components/Toggle/styles.css.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroup.d.ts +7 -1
- package/dist/components/ToggleGroup/ToggleGroup.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroup.meta.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroup.stories.d.ts +4 -0
- package/dist/components/ToggleGroup/ToggleGroup.stories.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroupContext.d.ts +6 -0
- package/dist/components/ToggleGroup/ToggleGroupContext.d.ts.map +1 -1
- package/dist/components/ToggleGroup/styles.css.d.ts +19 -0
- package/dist/components/ToggleGroup/styles.css.d.ts.map +1 -1
- package/dist/components/ToggleGroup/useRovingFocus.d.ts +17 -0
- package/dist/components/ToggleGroup/useRovingFocus.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.js +2737 -2469
- package/dist/index.js.map +1 -1
- package/dist/table.js +1 -1
- package/package.json +2 -2
- package/src/components/Combobox/Combobox.meta.ts +6 -0
- package/src/components/Combobox/Combobox.tsx +5 -0
- package/src/components/Drawer/Drawer.meta.ts +1 -1
- package/src/components/Drawer/Drawer.tsx +12 -2
- package/src/components/Drawer/DrawerScroll.stories.tsx +292 -0
- package/src/components/Drawer/useSwipeDismiss.ts +53 -8
- package/src/components/Menu/useMenuRoot.ts +5 -1
- package/src/components/Popover/Popover.meta.ts +7 -0
- package/src/components/Popover/Popover.tsx +30 -24
- package/src/components/Popover/PopoverLayerContext.ts +20 -0
- package/src/components/Popover/PopoverLayers.stories.tsx +234 -0
- package/src/components/ScrollArea/ScrollArea.meta.ts +1 -1
- package/src/components/ScrollArea/ScrollArea.tsx +12 -6
- package/src/components/Select/Select.meta.ts +6 -0
- package/src/components/Select/Select.tsx +8 -1
- package/src/components/Select/useSelectRoot.ts +6 -2
- package/src/components/Toggle/Toggle.meta.ts +1 -1
- package/src/components/Toggle/Toggle.tsx +19 -2
- package/src/components/Toggle/rowHeight.ts +12 -0
- package/src/components/Toggle/styles.css.ts +12 -0
- package/src/components/ToggleGroup/ToggleGroup.meta.ts +11 -1
- package/src/components/ToggleGroup/ToggleGroup.stories.tsx +156 -0
- package/src/components/ToggleGroup/ToggleGroup.tsx +87 -10
- package/src/components/ToggleGroup/ToggleGroupContext.ts +6 -0
- package/src/components/ToggleGroup/styles.css.ts +30 -1
- package/src/components/ToggleGroup/useRovingFocus.ts +121 -0
- package/dist/Table-JTgcR_Fh.js.map +0 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {type RefObject, createContext, useContext, useLayoutEffect} from 'react'
|
|
2
|
+
|
|
3
|
+
// A Popover's nested popups (a Select listbox, a Combobox listbox, a child
|
|
4
|
+
// Popover) are portalled out of its DOM subtree. They register here so a press
|
|
5
|
+
// on them counts as inside the Popover.
|
|
6
|
+
export interface PopoverLayerContextValue {
|
|
7
|
+
register: (el: Element) => () => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const PopoverLayerContext = createContext<PopoverLayerContextValue | null>(null)
|
|
11
|
+
|
|
12
|
+
/** Register the ref's element as part of the enclosing Popover's layer while `active`. */
|
|
13
|
+
export function usePopoverLayer(ref: RefObject<Element | null>, {active}: {active: boolean}) {
|
|
14
|
+
const layer = useContext(PopoverLayerContext)
|
|
15
|
+
useLayoutEffect(() => {
|
|
16
|
+
const el = ref.current
|
|
17
|
+
if (!layer || !active || !el) return
|
|
18
|
+
return layer.register(el)
|
|
19
|
+
}, [layer, active, ref])
|
|
20
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
+
import {expect, waitFor, within} from 'storybook/test'
|
|
3
|
+
import {userEvent} from '@testing-library/user-event'
|
|
4
|
+
import {html} from 'react-strict-dom'
|
|
5
|
+
import {Popover} from './Popover'
|
|
6
|
+
import {Select} from '../Select/Select'
|
|
7
|
+
import {Combobox} from '../Combobox/Combobox'
|
|
8
|
+
import {Menu} from '../Menu/Menu'
|
|
9
|
+
import {Stack} from '../Stack/Stack'
|
|
10
|
+
|
|
11
|
+
const meta: Meta = {
|
|
12
|
+
title: 'Components/Popover/Layers',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default meta
|
|
16
|
+
type Story = StoryObj
|
|
17
|
+
|
|
18
|
+
// Nested popups are portalled out of the canvas, so query the document.
|
|
19
|
+
const page = () => within(document.body)
|
|
20
|
+
|
|
21
|
+
const dialog = () => page().queryByRole('dialog', {name: 'Style'})
|
|
22
|
+
|
|
23
|
+
function SelectItems() {
|
|
24
|
+
return (
|
|
25
|
+
<Select.Popup>
|
|
26
|
+
<Select.Item value="primary">
|
|
27
|
+
<Select.ItemText>Primary</Select.ItemText>
|
|
28
|
+
</Select.Item>
|
|
29
|
+
<Select.Item value="secondary">
|
|
30
|
+
<Select.ItemText>Secondary</Select.ItemText>
|
|
31
|
+
</Select.Item>
|
|
32
|
+
</Select.Popup>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function VariantSelect() {
|
|
37
|
+
return (
|
|
38
|
+
<Select.Root defaultValue="primary">
|
|
39
|
+
<Select.Trigger aria-label="Variant">
|
|
40
|
+
<Select.Value />
|
|
41
|
+
<Select.Icon />
|
|
42
|
+
</Select.Trigger>
|
|
43
|
+
<SelectItems />
|
|
44
|
+
</Select.Root>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function FruitCombobox() {
|
|
49
|
+
return (
|
|
50
|
+
<Combobox.Root>
|
|
51
|
+
<Combobox.Input placeholder="Search fruit...">
|
|
52
|
+
<Combobox.Trigger />
|
|
53
|
+
</Combobox.Input>
|
|
54
|
+
<Combobox.Popup>
|
|
55
|
+
<Combobox.Item value="apple">
|
|
56
|
+
<Combobox.ItemText>Apple</Combobox.ItemText>
|
|
57
|
+
</Combobox.Item>
|
|
58
|
+
<Combobox.Item value="banana">
|
|
59
|
+
<Combobox.ItemText>Banana</Combobox.ItemText>
|
|
60
|
+
</Combobox.Item>
|
|
61
|
+
</Combobox.Popup>
|
|
62
|
+
</Combobox.Root>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function Host({children}: {children: React.ReactNode}) {
|
|
67
|
+
return (
|
|
68
|
+
<Stack gap="md">
|
|
69
|
+
<Popover.Root>
|
|
70
|
+
<Popover.Trigger>Style</Popover.Trigger>
|
|
71
|
+
<Popover.Popup label="Style">{children}</Popover.Popup>
|
|
72
|
+
</Popover.Root>
|
|
73
|
+
<html.button type="button">Elsewhere</html.button>
|
|
74
|
+
</Stack>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const SelectOutsideField: Story = {
|
|
79
|
+
render: () => (
|
|
80
|
+
<Host>
|
|
81
|
+
<VariantSelect />
|
|
82
|
+
</Host>
|
|
83
|
+
),
|
|
84
|
+
play: async ({canvas}) => {
|
|
85
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Style'}))
|
|
86
|
+
const trigger = page().getByRole('combobox', {name: 'Variant'})
|
|
87
|
+
await userEvent.click(trigger)
|
|
88
|
+
await userEvent.click(page().getByRole('option', {name: 'Secondary'}))
|
|
89
|
+
await expect(trigger).toHaveTextContent('Secondary')
|
|
90
|
+
await expect(dialog()).toBeInTheDocument()
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const ComboboxOutsideField: Story = {
|
|
95
|
+
render: () => (
|
|
96
|
+
<Host>
|
|
97
|
+
<FruitCombobox />
|
|
98
|
+
</Host>
|
|
99
|
+
),
|
|
100
|
+
play: async ({canvas}) => {
|
|
101
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Style'}))
|
|
102
|
+
const input = page().getByRole('combobox')
|
|
103
|
+
await userEvent.click(input)
|
|
104
|
+
await userEvent.click(await page().findByRole('option', {name: 'Banana'}))
|
|
105
|
+
await waitFor(() => expect(input).toHaveValue('Banana'))
|
|
106
|
+
await expect(dialog()).toBeInTheDocument()
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const SelectBackdropPress: Story = {
|
|
111
|
+
render: () => (
|
|
112
|
+
<Host>
|
|
113
|
+
<VariantSelect />
|
|
114
|
+
</Host>
|
|
115
|
+
),
|
|
116
|
+
play: async ({canvas}) => {
|
|
117
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Style'}))
|
|
118
|
+
const trigger = page().getByRole('combobox', {name: 'Variant'})
|
|
119
|
+
await userEvent.click(trigger)
|
|
120
|
+
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
121
|
+
|
|
122
|
+
// The backdrop covers the viewport: a press at the corner lands on it.
|
|
123
|
+
const backdrop = document.elementFromPoint(2, 2) as HTMLElement
|
|
124
|
+
await userEvent.click(backdrop)
|
|
125
|
+
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
|
126
|
+
await expect(dialog()).toBeInTheDocument()
|
|
127
|
+
|
|
128
|
+
// With the Select closed, an outside press closes the Popover.
|
|
129
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Elsewhere'}))
|
|
130
|
+
await expect(dialog()).not.toBeInTheDocument()
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const SelectOutsidePopover: Story = {
|
|
135
|
+
render: () => (
|
|
136
|
+
<Stack gap="md">
|
|
137
|
+
<Popover.Root>
|
|
138
|
+
<Popover.Trigger>Style</Popover.Trigger>
|
|
139
|
+
<Popover.Popup label="Style">
|
|
140
|
+
<Popover.Close>Done</Popover.Close>
|
|
141
|
+
</Popover.Popup>
|
|
142
|
+
</Popover.Root>
|
|
143
|
+
<VariantSelect />
|
|
144
|
+
</Stack>
|
|
145
|
+
),
|
|
146
|
+
play: async ({canvas}) => {
|
|
147
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Style'}))
|
|
148
|
+
await expect(dialog()).toBeInTheDocument()
|
|
149
|
+
// The Select is not inside the Popover: choosing from it is an outside press.
|
|
150
|
+
await userEvent.click(canvas.getByRole('combobox', {name: 'Variant'}))
|
|
151
|
+
await userEvent.click(page().getByRole('option', {name: 'Secondary'}))
|
|
152
|
+
await expect(dialog()).not.toBeInTheDocument()
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const EscapeInNestedSelect: Story = {
|
|
157
|
+
render: () => (
|
|
158
|
+
<Host>
|
|
159
|
+
<VariantSelect />
|
|
160
|
+
</Host>
|
|
161
|
+
),
|
|
162
|
+
play: async ({canvas}) => {
|
|
163
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Style'}))
|
|
164
|
+
const trigger = page().getByRole('combobox', {name: 'Variant'})
|
|
165
|
+
await userEvent.click(trigger)
|
|
166
|
+
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
167
|
+
await userEvent.keyboard('{Escape}')
|
|
168
|
+
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
|
169
|
+
await expect(dialog()).toBeInTheDocument()
|
|
170
|
+
await userEvent.keyboard('{Escape}')
|
|
171
|
+
await expect(dialog()).not.toBeInTheDocument()
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const EscapeInNestedMenu: Story = {
|
|
176
|
+
render: () => (
|
|
177
|
+
<Host>
|
|
178
|
+
<Menu.Root>
|
|
179
|
+
<Menu.Trigger>Actions</Menu.Trigger>
|
|
180
|
+
<Menu.Popup>
|
|
181
|
+
<Menu.Item>Copy</Menu.Item>
|
|
182
|
+
<Menu.Item>Paste</Menu.Item>
|
|
183
|
+
</Menu.Popup>
|
|
184
|
+
</Menu.Root>
|
|
185
|
+
</Host>
|
|
186
|
+
),
|
|
187
|
+
play: async ({canvas}) => {
|
|
188
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Style'}))
|
|
189
|
+
await userEvent.click(page().getByRole('button', {name: 'Actions'}))
|
|
190
|
+
await expect(await page().findByRole('menu')).toBeVisible()
|
|
191
|
+
await userEvent.keyboard('{Escape}')
|
|
192
|
+
await waitFor(() => expect(page().queryByRole('menu')).not.toBeInTheDocument())
|
|
193
|
+
await expect(dialog()).toBeInTheDocument()
|
|
194
|
+
await userEvent.keyboard('{Escape}')
|
|
195
|
+
await expect(dialog()).not.toBeInTheDocument()
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export const EscapeInNestedCombobox: Story = {
|
|
200
|
+
render: () => (
|
|
201
|
+
<Host>
|
|
202
|
+
<FruitCombobox />
|
|
203
|
+
</Host>
|
|
204
|
+
),
|
|
205
|
+
play: async ({canvas}) => {
|
|
206
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Style'}))
|
|
207
|
+
const input = page().getByRole('combobox')
|
|
208
|
+
await userEvent.click(input)
|
|
209
|
+
await expect(await page().findByRole('listbox')).toBeVisible()
|
|
210
|
+
await userEvent.keyboard('{Escape}')
|
|
211
|
+
await waitFor(() => expect(page().queryByRole('listbox')).not.toBeInTheDocument())
|
|
212
|
+
await expect(dialog()).toBeInTheDocument()
|
|
213
|
+
await userEvent.keyboard('{Escape}')
|
|
214
|
+
await expect(dialog()).not.toBeInTheDocument()
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export const TabFromNestedSelect: Story = {
|
|
219
|
+
render: () => (
|
|
220
|
+
<Host>
|
|
221
|
+
<VariantSelect />
|
|
222
|
+
<html.button type="button">After</html.button>
|
|
223
|
+
</Host>
|
|
224
|
+
),
|
|
225
|
+
play: async ({canvas}) => {
|
|
226
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Style'}))
|
|
227
|
+
const trigger = page().getByRole('combobox', {name: 'Variant'})
|
|
228
|
+
await userEvent.click(trigger)
|
|
229
|
+
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
230
|
+
await userEvent.tab()
|
|
231
|
+
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
|
232
|
+
await waitFor(() => expect(trigger).not.toHaveFocus())
|
|
233
|
+
},
|
|
234
|
+
}
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Custom scrollbar region with draggable thumb. Supports vertical and horizontal scrolling. Compound component — Root is required.',
|
|
5
|
+
'Custom scrollbar region with draggable thumb. Supports vertical and horizontal scrolling. Compound component — Root is required. Inside a Drawer, a drag in the viewport scrolls it; the drawer only takes a drag toward its edge from the scroll start.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Custom-styled scrollbars for content overflow areas',
|
|
8
8
|
'Chat logs, code blocks, or long lists with constrained height',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {type ReactNode, useRef, useCallback} from 'react'
|
|
1
|
+
import {type ComponentProps, type ReactNode, useRef, useCallback} from 'react'
|
|
2
2
|
import {html} from 'react-strict-dom'
|
|
3
3
|
import {styles} from './styles.css'
|
|
4
4
|
import {ScrollAreaContext, useScrollArea} from './ScrollAreaContext'
|
|
@@ -25,19 +25,25 @@ function Root({children}: RootProps) {
|
|
|
25
25
|
interface ViewportProps {
|
|
26
26
|
children: ReactNode
|
|
27
27
|
maxHeight?: number | string
|
|
28
|
+
/** Extra styles for the viewport (e.g. a pointer-aware max height). */
|
|
29
|
+
style?: ComponentProps<typeof html.div>['style']
|
|
30
|
+
/** A roving-focus group owns the keyboard: drop the viewport's own tab stop
|
|
31
|
+
* and region role. */
|
|
32
|
+
rovingOwner?: boolean
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
function Viewport({children, maxHeight}: ViewportProps) {
|
|
35
|
+
function Viewport({children, maxHeight, style, rovingOwner = false}: ViewportProps) {
|
|
31
36
|
const {viewportRef, viewportId} = useScrollArea()
|
|
32
37
|
|
|
33
38
|
return (
|
|
34
39
|
<html.div
|
|
35
40
|
ref={viewportRef}
|
|
36
41
|
id={viewportId}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
data-duro-scroll=""
|
|
43
|
+
tabIndex={rovingOwner ? undefined : 0}
|
|
44
|
+
role={rovingOwner ? undefined : 'region'}
|
|
45
|
+
aria-label={rovingOwner ? undefined : 'Scrollable content'}
|
|
46
|
+
style={[styles.viewport, maxHeight != null && styles.viewportMaxHeight(maxHeight), style]}
|
|
41
47
|
>
|
|
42
48
|
{children}
|
|
43
49
|
</html.div>
|
|
@@ -17,6 +17,12 @@ export const meta: ComponentMeta = {
|
|
|
17
17
|
optional: ['Value', 'Icon', 'ItemText'],
|
|
18
18
|
},
|
|
19
19
|
relatedTo: [
|
|
20
|
+
{
|
|
21
|
+
component: 'Popover',
|
|
22
|
+
kind: 'composition',
|
|
23
|
+
relationship:
|
|
24
|
+
'Inside Popover.Popup a Select keeps the popover open while you choose; its backdrop press and Escape close only the list',
|
|
25
|
+
},
|
|
20
26
|
{
|
|
21
27
|
component: 'Menu',
|
|
22
28
|
kind: 'contrast',
|
|
@@ -6,6 +6,7 @@ import {SelectContext, useSelect} from './SelectContext'
|
|
|
6
6
|
import {useSelectRoot} from './useSelectRoot'
|
|
7
7
|
import {useFieldContext} from '../Field/FieldContext'
|
|
8
8
|
import {usePortalMount} from '../ThemeProvider/ThemeProvider'
|
|
9
|
+
import {usePopoverLayer} from '../Popover/PopoverLayerContext'
|
|
9
10
|
|
|
10
11
|
// --- Root ---
|
|
11
12
|
interface RootProps {
|
|
@@ -141,10 +142,16 @@ function Popup({children}: {children: ReactNode}) {
|
|
|
141
142
|
}
|
|
142
143
|
}, [open, triggerRef])
|
|
143
144
|
|
|
145
|
+
const listboxRef = useRef<HTMLDivElement>(null)
|
|
146
|
+
const backdropRef = useRef<HTMLDivElement>(null)
|
|
147
|
+
usePopoverLayer(listboxRef, {active: open})
|
|
148
|
+
usePopoverLayer(backdropRef, {active: open})
|
|
149
|
+
|
|
144
150
|
const node = (
|
|
145
151
|
<>
|
|
146
|
-
{open && <html.div style={styles.backdrop} onClick={close} />}
|
|
152
|
+
{open && <html.div ref={backdropRef} style={styles.backdrop} onClick={close} />}
|
|
147
153
|
<html.div
|
|
154
|
+
ref={listboxRef}
|
|
148
155
|
id={listboxId}
|
|
149
156
|
role="listbox"
|
|
150
157
|
aria-hidden={!open}
|
|
@@ -88,7 +88,12 @@ export function useSelectRoot({
|
|
|
88
88
|
function handleKeyDown(e: KeyboardEvent) {
|
|
89
89
|
const order = orderRef.current
|
|
90
90
|
const items = itemsRef.current
|
|
91
|
-
if (
|
|
91
|
+
if (e.key === 'Escape') {
|
|
92
|
+
e.preventDefault()
|
|
93
|
+
close()
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (order.length === 0 && e.key !== 'Tab') return
|
|
92
97
|
|
|
93
98
|
switch (e.key) {
|
|
94
99
|
case 'ArrowDown': {
|
|
@@ -132,7 +137,6 @@ export function useSelectRoot({
|
|
|
132
137
|
})
|
|
133
138
|
break
|
|
134
139
|
}
|
|
135
|
-
case 'Escape':
|
|
136
140
|
case 'Tab': {
|
|
137
141
|
close()
|
|
138
142
|
break
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Toggle button with pressed/unpressed state. Works standalone or within ToggleGroup for multi/single selection.',
|
|
5
|
+
'Toggle button with pressed/unpressed state. Works standalone or within ToggleGroup for multi/single selection. Under a coarse pointer (touch) every Toggle is at least 44×44px; in a wrapping ToggleGroup each keeps its own border and a fixed row height.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Toolbar buttons that toggle on/off (bold, italic, grid view)',
|
|
8
8
|
'Within ToggleGroup for selecting from visible options',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {type ReactNode, useCallback} from 'react'
|
|
1
|
+
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'
|
|
@@ -23,6 +23,11 @@ interface ToggleProps {
|
|
|
23
23
|
children: ReactNode
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
const wrappedSizeMap = {
|
|
27
|
+
default: styles.wrappedDefault,
|
|
28
|
+
small: styles.wrappedSmall,
|
|
29
|
+
} as const
|
|
30
|
+
|
|
26
31
|
const sizeMap = {
|
|
27
32
|
default: styles.sizeDefault,
|
|
28
33
|
small: styles.sizeSmall,
|
|
@@ -46,6 +51,14 @@ export function Toggle({
|
|
|
46
51
|
const disabled = disabledProp || (group?.disabled ?? false)
|
|
47
52
|
const size = group?.size ?? sizeProp
|
|
48
53
|
|
|
54
|
+
const wrap = group?.wrap ?? false
|
|
55
|
+
const ref = useRef<HTMLButtonElement>(null)
|
|
56
|
+
const register = group?.register
|
|
57
|
+
useLayoutEffect(() => {
|
|
58
|
+
if (!wrap || !register || value === undefined || !ref.current) return
|
|
59
|
+
return register(value, ref.current)
|
|
60
|
+
}, [wrap, register, value])
|
|
61
|
+
|
|
49
62
|
const [standalonePressed, setStandalonePressed] = useControllableValue(
|
|
50
63
|
controlledPressed,
|
|
51
64
|
defaultPressed,
|
|
@@ -65,7 +78,10 @@ export function Toggle({
|
|
|
65
78
|
|
|
66
79
|
return (
|
|
67
80
|
<html.button
|
|
81
|
+
ref={ref}
|
|
68
82
|
type="button"
|
|
83
|
+
tabIndex={wrap && value !== undefined ? (group?.tabStopValue === value ? 0 : -1) : undefined}
|
|
84
|
+
onFocus={wrap && value !== undefined ? () => group?.onItemFocus(value) : undefined}
|
|
69
85
|
aria-pressed={pressed}
|
|
70
86
|
aria-label={ariaLabel}
|
|
71
87
|
disabled={disabled}
|
|
@@ -75,7 +91,8 @@ export function Toggle({
|
|
|
75
91
|
styles.base,
|
|
76
92
|
sizeMap[size],
|
|
77
93
|
pressed ? styles.pressed : styles.unpressed,
|
|
78
|
-
|
|
94
|
+
wrap && wrappedSizeMap[size],
|
|
95
|
+
isGrouped && !wrap && styles.grouped,
|
|
79
96
|
disabled && styles.disabled,
|
|
80
97
|
]}
|
|
81
98
|
>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fixed block size of a Toggle in a wrapping group, per size, and under a
|
|
3
|
+
* coarse pointer. One source for ToggleGroup's `maxRows` arithmetic and the
|
|
4
|
+
* stories that measure it.
|
|
5
|
+
*
|
|
6
|
+
* holds-until: StyleX can import a constant into a static style. Toggle's
|
|
7
|
+
* `styles.css.ts` must repeat these numbers in its `wrapped*` heights. The
|
|
8
|
+
* ToggleGroup stories (small, default) and the Drawer coarse-pointer story
|
|
9
|
+
* measure a rendered toggle against this module to ±0.5px, so a drift in
|
|
10
|
+
* either file fails a test rather than a layout.
|
|
11
|
+
*/
|
|
12
|
+
export const ROW_HEIGHT = {small: 28, default: 39, coarse: 44} as const
|
|
@@ -21,6 +21,8 @@ export const styles = css.create({
|
|
|
21
21
|
outlineStyle: 'solid',
|
|
22
22
|
outlineColor: colors.accent,
|
|
23
23
|
outlineOffset: 2,
|
|
24
|
+
minWidth: {default: null, '@media (pointer: coarse)': 44},
|
|
25
|
+
minHeight: {default: null, '@media (pointer: coarse)': 44},
|
|
24
26
|
},
|
|
25
27
|
sizeDefault: {
|
|
26
28
|
padding: `${spacing.sm} ${spacing.md}`,
|
|
@@ -34,6 +36,16 @@ export const styles = css.create({
|
|
|
34
36
|
borderRadius: radii.sm,
|
|
35
37
|
gap: spacing.xs,
|
|
36
38
|
},
|
|
39
|
+
// wrapped toggles: a fixed block size per size, 44px under a coarse pointer.
|
|
40
|
+
// Must equal ROW_HEIGHT in ./rowHeight (StyleX cannot import it here).
|
|
41
|
+
wrappedDefault: {
|
|
42
|
+
boxSizing: 'border-box',
|
|
43
|
+
height: {default: 39, '@media (pointer: coarse)': 44},
|
|
44
|
+
},
|
|
45
|
+
wrappedSmall: {
|
|
46
|
+
boxSizing: 'border-box',
|
|
47
|
+
height: {default: 28, '@media (pointer: coarse)': 44},
|
|
48
|
+
},
|
|
37
49
|
unpressed: {
|
|
38
50
|
backgroundColor: {
|
|
39
51
|
default: 'transparent',
|
|
@@ -6,10 +6,11 @@ export const meta: ComponentMeta = {
|
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Visible option set where user picks one or more (view mode, filter categories)',
|
|
8
8
|
'Segmented control pattern',
|
|
9
|
+
'A visible grid of icon choices: wrap + maxRows (rows scroll past maxRows, one tab stop with arrow keys, 44px touch targets)',
|
|
9
10
|
],
|
|
10
11
|
whenNotToUse: [
|
|
11
12
|
'Dropdown selection — use Select',
|
|
12
|
-
'Many options (5+) — use Select for space efficiency',
|
|
13
|
+
'Many text options (5+) — use Select for space efficiency; does not apply to icon-only choices, which keep options visible and open no layer',
|
|
13
14
|
'Form checkboxes — use Checkbox group in Fieldset',
|
|
14
15
|
],
|
|
15
16
|
relatedTo: [
|
|
@@ -30,5 +31,14 @@ export const meta: ComponentMeta = {
|
|
|
30
31
|
<Toggle value="list">List view</Toggle>
|
|
31
32
|
<Toggle value="grid">Grid view</Toggle>
|
|
32
33
|
<Toggle value="board">Board view</Toggle>
|
|
34
|
+
</ToggleGroup>
|
|
35
|
+
|
|
36
|
+
// Icon choices: wraps onto rows, scrolls past three, one tab stop
|
|
37
|
+
<ToggleGroup wrap maxRows={3} size="small" value={[icon]} onValueChange={(v) => v[0] && setIcon(v[0])} aria-label="Icon">
|
|
38
|
+
{names.map((name) => (
|
|
39
|
+
<Toggle key={name} value={name} aria-label={name}>
|
|
40
|
+
<Icon name={name} size="sm" />
|
|
41
|
+
</Toggle>
|
|
42
|
+
))}
|
|
33
43
|
</ToggleGroup>`,
|
|
34
44
|
}
|
|
@@ -3,6 +3,8 @@ import {expect, fn} from 'storybook/test'
|
|
|
3
3
|
import {css, html} from 'react-strict-dom'
|
|
4
4
|
import {ToggleGroup} from './ToggleGroup'
|
|
5
5
|
import {Toggle} from '../Toggle/Toggle'
|
|
6
|
+
import {Icon, type IconName} from '../Icon'
|
|
7
|
+
import {ROW_HEIGHT} from '../Toggle/rowHeight'
|
|
6
8
|
import {spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
7
9
|
|
|
8
10
|
const meta: Meta<typeof ToggleGroup> = {
|
|
@@ -158,3 +160,157 @@ export const AllVariants: Story = {
|
|
|
158
160
|
await expect(groups.length).toBe(4)
|
|
159
161
|
},
|
|
160
162
|
}
|
|
163
|
+
|
|
164
|
+
const ICON_NAMES: IconName[] = [
|
|
165
|
+
'x-circle',
|
|
166
|
+
'check-circle',
|
|
167
|
+
'check-done',
|
|
168
|
+
'clock',
|
|
169
|
+
'forbidden',
|
|
170
|
+
'info-circle',
|
|
171
|
+
'alert-triangle',
|
|
172
|
+
'shield',
|
|
173
|
+
'lock',
|
|
174
|
+
'key',
|
|
175
|
+
'map',
|
|
176
|
+
'layers',
|
|
177
|
+
'repeat',
|
|
178
|
+
'database',
|
|
179
|
+
'shield-check',
|
|
180
|
+
'route',
|
|
181
|
+
'git-branch',
|
|
182
|
+
'menu',
|
|
183
|
+
'pin',
|
|
184
|
+
'server',
|
|
185
|
+
'hard-drive',
|
|
186
|
+
'box',
|
|
187
|
+
'image',
|
|
188
|
+
'tag',
|
|
189
|
+
'pie-chart',
|
|
190
|
+
'users',
|
|
191
|
+
'user-plus',
|
|
192
|
+
'mail',
|
|
193
|
+
'file-text',
|
|
194
|
+
'plug',
|
|
195
|
+
'search',
|
|
196
|
+
'mic',
|
|
197
|
+
'sun',
|
|
198
|
+
'moon',
|
|
199
|
+
'monitor',
|
|
200
|
+
'contrast',
|
|
201
|
+
'info-circle-filled',
|
|
202
|
+
'alert-triangle-filled',
|
|
203
|
+
'check-circle-filled',
|
|
204
|
+
'x-circle-filled',
|
|
205
|
+
'shield-filled',
|
|
206
|
+
'lock-filled',
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
const iconStyles = css.create({
|
|
210
|
+
frame: {width: 320},
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
function IconChoices({
|
|
214
|
+
names,
|
|
215
|
+
pressed,
|
|
216
|
+
size = 'small',
|
|
217
|
+
}: {
|
|
218
|
+
names: IconName[]
|
|
219
|
+
pressed: string
|
|
220
|
+
size?: 'small' | 'default'
|
|
221
|
+
}) {
|
|
222
|
+
return (
|
|
223
|
+
<html.div style={iconStyles.frame}>
|
|
224
|
+
<ToggleGroup wrap maxRows={3} size={size} defaultValue={[pressed]} aria-label="Icon">
|
|
225
|
+
{names.map((name) => (
|
|
226
|
+
<Toggle key={name} value={name} aria-label={name}>
|
|
227
|
+
<Icon name={name} size="sm" />
|
|
228
|
+
</Toggle>
|
|
229
|
+
))}
|
|
230
|
+
</ToggleGroup>
|
|
231
|
+
</html.div>
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export const IconChoicesStory: Story = {
|
|
236
|
+
name: 'Icon choices',
|
|
237
|
+
render: () => <IconChoices names={ICON_NAMES} pressed={ICON_NAMES[39]} />,
|
|
238
|
+
play: async ({canvas, canvasElement}) => {
|
|
239
|
+
const scrollTopBefore = document.scrollingElement?.scrollTop
|
|
240
|
+
await expect(ICON_NAMES.length).toBe(42)
|
|
241
|
+
const viewport = canvasElement.querySelector<HTMLElement>('[data-duro-scroll]')!
|
|
242
|
+
await expect(Math.abs(viewport.clientHeight - 118)).toBeLessThanOrEqual(2)
|
|
243
|
+
|
|
244
|
+
const pressed = canvas.getByRole('button', {name: ICON_NAMES[39]})
|
|
245
|
+
const other = canvas.getByRole('button', {name: ICON_NAMES[0]})
|
|
246
|
+
await expect(pressed).toHaveAttribute('aria-pressed', 'true')
|
|
247
|
+
for (const el of [pressed, other]) {
|
|
248
|
+
const cs = getComputedStyle(el)
|
|
249
|
+
await expect(cs.borderTopWidth).toBe('1px')
|
|
250
|
+
await expect(cs.borderRightWidth).toBe('1px')
|
|
251
|
+
await expect(cs.borderBottomWidth).toBe('1px')
|
|
252
|
+
await expect(cs.borderLeftWidth).toBe('1px')
|
|
253
|
+
// radii.sm
|
|
254
|
+
await expect(cs.borderTopLeftRadius).toBe('8px')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// the pressed toggle is visible inside the viewport
|
|
258
|
+
const v = viewport.getBoundingClientRect()
|
|
259
|
+
const p = pressed.getBoundingClientRect()
|
|
260
|
+
await expect(p.top).toBeGreaterThanOrEqual(v.top - 1)
|
|
261
|
+
await expect(p.bottom).toBeLessThanOrEqual(v.bottom + 1)
|
|
262
|
+
await expect(document.scrollingElement?.scrollTop).toBe(scrollTopBefore)
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export const FiveIcons: Story = {
|
|
267
|
+
name: 'Five icons',
|
|
268
|
+
render: () => <IconChoices names={ICON_NAMES.slice(0, 5)} pressed={ICON_NAMES[0]} />,
|
|
269
|
+
play: async ({canvas}) => {
|
|
270
|
+
const group = canvas.getByRole('toolbar')
|
|
271
|
+
await expect(group.getBoundingClientRect().height).toBe(ROW_HEIGHT.small)
|
|
272
|
+
// exact: Toggle's styles repeat ROW_HEIGHT, and a drift must fail here
|
|
273
|
+
const toggle = canvas.getAllByRole('button')[0].getBoundingClientRect()
|
|
274
|
+
await expect(Math.abs(toggle.height - ROW_HEIGHT.small)).toBeLessThanOrEqual(0.5)
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const KeyboardRoving: Story = {
|
|
279
|
+
render: () => <IconChoices names={ICON_NAMES} pressed={ICON_NAMES[3]} />,
|
|
280
|
+
play: async ({canvas, userEvent}) => {
|
|
281
|
+
const btn = (i: number) => canvas.getByRole('button', {name: ICON_NAMES[i]})
|
|
282
|
+
await userEvent.tab()
|
|
283
|
+
await expect(btn(3)).toHaveFocus()
|
|
284
|
+
await userEvent.keyboard('{ArrowRight}')
|
|
285
|
+
await expect(btn(4)).toHaveFocus()
|
|
286
|
+
await userEvent.keyboard('{ArrowLeft}{ArrowLeft}')
|
|
287
|
+
await expect(btn(2)).toHaveFocus()
|
|
288
|
+
// Down moves to the next row, Up back
|
|
289
|
+
const top = btn(2).getBoundingClientRect().top
|
|
290
|
+
await userEvent.keyboard('{ArrowDown}')
|
|
291
|
+
const active = document.activeElement as HTMLElement
|
|
292
|
+
await expect(active.getBoundingClientRect().top).toBeGreaterThan(top + 20)
|
|
293
|
+
await userEvent.keyboard('{ArrowUp}')
|
|
294
|
+
await expect(btn(2)).toHaveFocus()
|
|
295
|
+
await userEvent.keyboard('{End}')
|
|
296
|
+
await expect(btn(41)).toHaveFocus()
|
|
297
|
+
await userEvent.keyboard('{Home}')
|
|
298
|
+
await expect(btn(0)).toHaveFocus()
|
|
299
|
+
await userEvent.keyboard('{Enter}')
|
|
300
|
+
await expect(btn(0)).toHaveAttribute('aria-pressed', 'true')
|
|
301
|
+
// a second tab leaves the group
|
|
302
|
+
await userEvent.tab()
|
|
303
|
+
await expect(canvas.getAllByRole('button')).not.toContain(document.activeElement)
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export const DefaultSizeRows: Story = {
|
|
308
|
+
name: 'Default size rows',
|
|
309
|
+
render: () => (
|
|
310
|
+
<IconChoices names={ICON_NAMES.slice(0, 5)} pressed={ICON_NAMES[0]} size="default" />
|
|
311
|
+
),
|
|
312
|
+
play: async ({canvas}) => {
|
|
313
|
+
const toggle = canvas.getAllByRole('button')[0].getBoundingClientRect()
|
|
314
|
+
await expect(Math.abs(toggle.height - ROW_HEIGHT.default)).toBeLessThanOrEqual(0.5)
|
|
315
|
+
},
|
|
316
|
+
}
|