@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.
- package/dist/Table-Cm5cCnZD.js +1524 -0
- package/dist/Table-Cm5cCnZD.js.map +1 -0
- package/dist/components/ActionBar/styles.css.d.ts +1 -1
- package/dist/components/ActionBar/styles.css.d.ts.map +1 -1
- package/dist/components/Badge/Badge.d.ts +1 -1
- package/dist/components/Badge/Badge.d.ts.map +1 -1
- package/dist/components/Badge/Badge.stories.d.ts +3 -0
- package/dist/components/Badge/Badge.stories.d.ts.map +1 -1
- package/dist/components/Badge/styles.css.d.ts +4 -0
- package/dist/components/Badge/styles.css.d.ts.map +1 -1
- package/dist/components/Button/Button.d.ts +11 -2
- package/dist/components/Button/Button.d.ts.map +1 -1
- package/dist/components/Button/Button.stories.d.ts +1 -0
- package/dist/components/Button/Button.stories.d.ts.map +1 -1
- package/dist/components/ButtonGroup/ButtonGroup.d.ts +9 -2
- package/dist/components/ButtonGroup/ButtonGroup.d.ts.map +1 -1
- package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts.map +1 -1
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +19 -0
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
- package/dist/components/ButtonGroup/ButtonGroupContext.d.ts +13 -0
- package/dist/components/ButtonGroup/ButtonGroupContext.d.ts.map +1 -0
- package/dist/components/ButtonGroup/attached.css.d.ts +34 -0
- package/dist/components/ButtonGroup/attached.css.d.ts.map +1 -0
- package/dist/components/ButtonGroup/styles.css.d.ts +7 -0
- package/dist/components/ButtonGroup/styles.css.d.ts.map +1 -1
- package/dist/components/ButtonGroup/useGroupedControl.d.ts +25 -0
- package/dist/components/ButtonGroup/useGroupedControl.d.ts.map +1 -0
- package/dist/components/Combobox/Combobox.d.ts +1 -1
- package/dist/components/Combobox/Combobox.d.ts.map +1 -1
- package/dist/components/Combobox/styles.css.d.ts +0 -64
- package/dist/components/Combobox/styles.css.d.ts.map +1 -1
- package/dist/components/Dialog/Dialog.d.ts +11 -3
- package/dist/components/Dialog/Dialog.d.ts.map +1 -1
- package/dist/components/Dialog/Dialog.stories.d.ts +4 -0
- package/dist/components/Dialog/Dialog.stories.d.ts.map +1 -1
- package/dist/components/Dialog/styles.css.d.ts +3 -2
- package/dist/components/Dialog/styles.css.d.ts.map +1 -1
- package/dist/components/DragDrop/DragDrop.d.ts.map +1 -1
- package/dist/components/DragDrop/styles.css.d.ts +11 -3
- package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.d.ts.map +1 -1
- package/dist/components/Drawer/styles.css.d.ts +2 -2
- package/dist/components/Drawer/styles.css.d.ts.map +1 -1
- package/dist/components/Grid/Grid.d.ts +11 -2
- package/dist/components/Grid/Grid.d.ts.map +1 -1
- package/dist/components/Grid/Grid.stories.d.ts +2 -0
- package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
- package/dist/components/Input/Input.d.ts +6 -1
- package/dist/components/Input/Input.d.ts.map +1 -1
- package/dist/components/Input/Input.meta.d.ts.map +1 -1
- package/dist/components/Input/Input.stories.d.ts +1 -0
- package/dist/components/Input/Input.stories.d.ts.map +1 -1
- package/dist/components/Input/styles.css.d.ts +18 -0
- package/dist/components/Input/styles.css.d.ts.map +1 -1
- package/dist/components/InputGroup/InputGroup.d.ts +6 -1
- package/dist/components/InputGroup/InputGroup.d.ts.map +1 -1
- package/dist/components/InputGroup/InputGroup.stories.d.ts +1 -0
- package/dist/components/InputGroup/InputGroup.stories.d.ts.map +1 -1
- package/dist/components/Listbox/Listbox.d.ts +75 -0
- package/dist/components/Listbox/Listbox.d.ts.map +1 -0
- package/dist/components/Listbox/Listbox.meta.d.ts +3 -0
- package/dist/components/Listbox/Listbox.meta.d.ts.map +1 -0
- package/dist/components/Listbox/Listbox.stories.d.ts +7 -0
- package/dist/components/Listbox/Listbox.stories.d.ts.map +1 -0
- package/dist/components/Listbox/styles.css.d.ts +75 -0
- package/dist/components/Listbox/styles.css.d.ts.map +1 -0
- package/dist/components/Menu/Menu.d.ts +18 -5
- package/dist/components/Menu/Menu.d.ts.map +1 -1
- package/dist/components/Menu/Menu.meta.d.ts.map +1 -1
- package/dist/components/Menu/Menu.stories.d.ts +7 -0
- package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
- package/dist/components/Menu/MenuContext.d.ts +3 -0
- package/dist/components/Menu/MenuContext.d.ts.map +1 -1
- package/dist/components/Menu/styles.css.d.ts +24 -15
- package/dist/components/Menu/styles.css.d.ts.map +1 -1
- package/dist/components/Menu/useMenuRoot.d.ts +14 -1
- package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
- package/dist/components/Popover/Popover.d.ts +7 -2
- package/dist/components/Popover/Popover.d.ts.map +1 -1
- package/dist/components/Popover/styles.css.d.ts +4 -1
- package/dist/components/Popover/styles.css.d.ts.map +1 -1
- package/dist/components/ScrollArea/styles.css.d.ts +1 -1
- package/dist/components/ScrollArea/styles.css.d.ts.map +1 -1
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.stories.d.ts +2 -0
- package/dist/components/Select/Select.stories.d.ts.map +1 -1
- package/dist/components/Select/styles.css.d.ts +2 -2
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
- package/dist/components/TextLink/TextLink.d.ts +24 -0
- package/dist/components/TextLink/TextLink.d.ts.map +1 -0
- package/dist/components/TextLink/TextLink.meta.d.ts +3 -0
- package/dist/components/TextLink/TextLink.meta.d.ts.map +1 -0
- package/dist/components/TextLink/TextLink.stories.d.ts +9 -0
- package/dist/components/TextLink/TextLink.stories.d.ts.map +1 -0
- package/dist/components/TextLink/styles.css.d.ts +28 -0
- package/dist/components/TextLink/styles.css.d.ts.map +1 -0
- package/dist/components/ThemeProvider/Layers.stories.d.ts +24 -0
- package/dist/components/ThemeProvider/Layers.stories.d.ts.map +1 -0
- package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
- package/dist/components/ThemeProvider/ThemeProvider.stories.d.ts +1 -0
- package/dist/components/ThemeProvider/ThemeProvider.stories.d.ts.map +1 -1
- package/dist/components/Toast/styles.css.d.ts +1 -0
- package/dist/components/Toast/styles.css.d.ts.map +1 -1
- package/dist/components/Toggle/Toggle.d.ts.map +1 -1
- package/dist/components/Toggle/styles.css.d.ts +3 -0
- package/dist/components/Toggle/styles.css.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroup.d.ts.map +1 -1
- package/dist/components/ToggleGroup/styles.css.d.ts +5 -0
- package/dist/components/ToggleGroup/styles.css.d.ts.map +1 -1
- package/dist/components/Toolbar/ControlContextBoundary.d.ts +10 -0
- package/dist/components/Toolbar/ControlContextBoundary.d.ts.map +1 -0
- package/dist/components/Toolbar/Toolbar.d.ts +22 -0
- package/dist/components/Toolbar/Toolbar.d.ts.map +1 -0
- package/dist/components/Toolbar/Toolbar.meta.d.ts +3 -0
- package/dist/components/Toolbar/Toolbar.meta.d.ts.map +1 -0
- package/dist/components/Toolbar/Toolbar.stories.d.ts +28 -0
- package/dist/components/Toolbar/Toolbar.stories.d.ts.map +1 -0
- package/dist/components/Toolbar/ToolbarContext.d.ts +10 -0
- package/dist/components/Toolbar/ToolbarContext.d.ts.map +1 -0
- package/dist/components/Toolbar/styles.css.d.ts +16 -0
- package/dist/components/Toolbar/styles.css.d.ts.map +1 -0
- package/dist/components/Tooltip/styles.css.d.ts +1 -1
- package/dist/components/Tooltip/styles.css.d.ts.map +1 -1
- package/dist/components/VirtualTable/styles.css.d.ts +1 -1
- package/dist/components/VirtualTable/styles.css.d.ts.map +1 -1
- package/dist/components/VisuallyHidden/VisuallyHidden.d.ts +14 -0
- package/dist/components/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
- package/dist/components/VisuallyHidden/VisuallyHidden.meta.d.ts +3 -0
- package/dist/components/VisuallyHidden/VisuallyHidden.meta.d.ts.map +1 -0
- package/dist/components/VisuallyHidden/VisuallyHidden.stories.d.ts +7 -0
- package/dist/components/VisuallyHidden/VisuallyHidden.stories.d.ts.map +1 -0
- package/dist/docs/FlexGrow.stories.d.ts +7 -0
- package/dist/docs/FlexGrow.stories.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3768 -3356
- package/dist/index.js.map +1 -1
- package/dist/index.native.d.ts +1 -0
- package/dist/index.native.d.ts.map +1 -1
- package/dist/shared/ModalContext.d.ts +14 -0
- package/dist/shared/ModalContext.d.ts.map +1 -0
- package/dist/shared/mergeRefs.d.ts +4 -0
- package/dist/shared/mergeRefs.d.ts.map +1 -0
- package/dist/shared/useOrderedRegistry.d.ts +26 -0
- package/dist/shared/useOrderedRegistry.d.ts.map +1 -0
- package/dist/styles/visually-hidden.css.d.ts +6 -0
- package/dist/styles/visually-hidden.css.d.ts.map +1 -1
- package/dist/table.js +45 -45
- package/dist/table.js.map +1 -1
- package/package.json +2 -2
- package/src/components/ActionBar/styles.css.ts +2 -1
- package/src/components/Badge/Badge.stories.tsx +62 -1
- package/src/components/Badge/Badge.tsx +1 -1
- package/src/components/Badge/styles.css.ts +5 -0
- package/src/components/Button/Button.stories.tsx +41 -0
- package/src/components/Button/Button.tsx +22 -1
- package/src/components/ButtonGroup/ButtonGroup.meta.ts +14 -1
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +271 -1
- package/src/components/ButtonGroup/ButtonGroup.tsx +46 -14
- package/src/components/ButtonGroup/ButtonGroupContext.ts +19 -0
- package/src/components/ButtonGroup/attached.css.ts +44 -0
- package/src/components/ButtonGroup/styles.css.ts +8 -0
- package/src/components/ButtonGroup/useGroupedControl.ts +75 -0
- package/src/components/Combobox/Combobox.tsx +25 -79
- package/src/components/Combobox/styles.css.ts +0 -74
- package/src/components/Dialog/Dialog.meta.ts +1 -1
- package/src/components/Dialog/Dialog.stories.tsx +109 -3
- package/src/components/Dialog/Dialog.tsx +68 -10
- package/src/components/Dialog/styles.css.ts +5 -2
- package/src/components/DragDrop/DragDrop.tsx +15 -8
- package/src/components/DragDrop/styles.css.ts +20 -4
- package/src/components/Drawer/Drawer.tsx +5 -1
- package/src/components/Drawer/styles.css.ts +3 -2
- package/src/components/Grid/Grid.meta.ts +1 -1
- package/src/components/Grid/Grid.stories.tsx +53 -1
- package/src/components/Grid/Grid.tsx +23 -11
- package/src/components/Input/Input.meta.ts +10 -3
- package/src/components/Input/Input.stories.tsx +52 -2
- package/src/components/Input/Input.tsx +12 -3
- package/src/components/Input/styles.css.ts +29 -0
- package/src/components/InputGroup/InputGroup.stories.tsx +20 -0
- package/src/components/InputGroup/InputGroup.tsx +20 -2
- package/src/components/Listbox/Listbox.meta.ts +52 -0
- package/src/components/Listbox/Listbox.stories.tsx +170 -0
- package/src/components/Listbox/Listbox.tsx +278 -0
- package/src/components/Listbox/styles.css.ts +90 -0
- package/src/components/Menu/Menu.meta.ts +2 -1
- package/src/components/Menu/Menu.stories.tsx +289 -8
- package/src/components/Menu/Menu.tsx +107 -29
- package/src/components/Menu/MenuContext.ts +3 -0
- package/src/components/Menu/styles.css.ts +32 -15
- package/src/components/Menu/useMenuRoot.ts +105 -48
- package/src/components/Popover/Popover.tsx +26 -5
- package/src/components/Popover/styles.css.ts +9 -1
- package/src/components/ScrollArea/styles.css.ts +2 -1
- package/src/components/Select/Select.stories.tsx +20 -1
- package/src/components/Select/Select.tsx +9 -3
- package/src/components/Select/styles.css.ts +3 -2
- package/src/components/Select/useSelectRoot.ts +17 -0
- package/src/components/TextLink/TextLink.meta.ts +33 -0
- package/src/components/TextLink/TextLink.stories.tsx +63 -0
- package/src/components/TextLink/TextLink.tsx +46 -0
- package/src/components/TextLink/styles.css.ts +58 -0
- package/src/components/ThemeProvider/Layers.stories.tsx +379 -0
- package/src/components/ThemeProvider/ThemeProvider.stories.tsx +50 -0
- package/src/components/ThemeProvider/ThemeProvider.tsx +4 -2
- package/src/components/Toast/styles.css.ts +3 -0
- package/src/components/Toggle/Toggle.tsx +20 -4
- package/src/components/Toggle/styles.css.ts +6 -0
- package/src/components/ToggleGroup/ToggleGroup.tsx +16 -4
- package/src/components/ToggleGroup/styles.css.ts +7 -0
- package/src/components/Toolbar/ControlContextBoundary.tsx +16 -0
- package/src/components/Toolbar/Toolbar.meta.ts +48 -0
- package/src/components/Toolbar/Toolbar.stories.tsx +468 -0
- package/src/components/Toolbar/Toolbar.tsx +113 -0
- package/src/components/Toolbar/ToolbarContext.ts +15 -0
- package/src/components/Toolbar/styles.css.ts +17 -0
- package/src/components/Tooltip/styles.css.ts +2 -1
- package/src/components/VirtualTable/styles.css.ts +2 -1
- package/src/components/VisuallyHidden/VisuallyHidden.meta.ts +19 -0
- package/src/components/VisuallyHidden/VisuallyHidden.stories.tsx +30 -0
- package/src/components/VisuallyHidden/VisuallyHidden.tsx +22 -0
- package/src/docs/FlexGrow.stories.tsx +83 -0
- package/src/index.native.ts +1 -0
- package/src/index.ts +7 -1
- package/src/shared/ModalContext.ts +18 -0
- package/src/shared/mergeRefs.ts +11 -0
- package/src/shared/useOrderedRegistry.ts +57 -0
- package/src/styles/visually-hidden.css.ts +8 -0
- package/dist/Table-BkrgH0jb.js +0 -1199
- package/dist/Table-BkrgH0jb.js.map +0 -1
|
@@ -2,11 +2,12 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Groups related buttons together with consistent spacing and layout. Supports horizontal and vertical orientation with alignment control.',
|
|
5
|
+
'Groups related buttons together with consistent spacing and layout. Supports horizontal and vertical orientation with alignment control. With `attached`, the group renders one joined control (shared borders, square inner corners) from any mix of Button, Toggle and Select/Menu/Popover triggers.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Dialog footers with Save/Cancel actions',
|
|
8
8
|
'Toolbar-style groupings of related actions',
|
|
9
9
|
'Form submission areas with multiple actions',
|
|
10
|
+
'`attached`: a segmented control of mixed controls — [Normal | Arial | 15px] as three Selects, [B | I | U | link] as Toggles and a Popover trigger',
|
|
10
11
|
],
|
|
11
12
|
whenNotToUse: [
|
|
12
13
|
'Single button — just use Button directly',
|
|
@@ -14,6 +15,18 @@ export const meta: ComponentMeta = {
|
|
|
14
15
|
'Navigation items — use Inline or SideNav',
|
|
15
16
|
],
|
|
16
17
|
relatedTo: [
|
|
18
|
+
{
|
|
19
|
+
component: 'Toolbar',
|
|
20
|
+
kind: 'composition',
|
|
21
|
+
relationship:
|
|
22
|
+
'Put attached groups in a Toolbar for one tab stop and arrow keys across them; a ButtonGroup alone leaves every control its own tab stop',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
component: 'ToggleGroup',
|
|
26
|
+
kind: 'contrast',
|
|
27
|
+
relationship:
|
|
28
|
+
'ToggleGroup owns a selection (single or multiple) across Toggles; ButtonGroup attached only joins controls visually, each keeps its own state',
|
|
29
|
+
},
|
|
17
30
|
{
|
|
18
31
|
component: 'Inline',
|
|
19
32
|
kind: 'contrast',
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
import {useState} from 'react'
|
|
1
2
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
-
import {expect} from 'storybook/test'
|
|
3
|
+
import {expect, userEvent, within} from 'storybook/test'
|
|
4
|
+
import {BORDERS_PX, LAYERS, RADII_PX} from '@duro-app/tokens/keys'
|
|
3
5
|
import {ButtonGroup} from './ButtonGroup'
|
|
4
6
|
import {Button} from '../Button/Button'
|
|
7
|
+
import {Popover} from '../Popover/Popover'
|
|
8
|
+
import {Select} from '../Select/Select'
|
|
5
9
|
import {Stack} from '../Stack/Stack'
|
|
10
|
+
import {Text} from '../Text/Text'
|
|
11
|
+
import {ThemeProvider, type ThemeName} from '../ThemeProvider/ThemeProvider'
|
|
12
|
+
import {Toggle} from '../Toggle/Toggle'
|
|
6
13
|
|
|
7
14
|
const meta: Meta<typeof ButtonGroup> = {
|
|
8
15
|
title: 'Components/ButtonGroup',
|
|
@@ -102,3 +109,266 @@ export const WithGaps: Story = {
|
|
|
102
109
|
await expect(groups.length).toBe(3)
|
|
103
110
|
},
|
|
104
111
|
}
|
|
112
|
+
|
|
113
|
+
// --- Attached (5.3) ---
|
|
114
|
+
|
|
115
|
+
const R = `${RADII_PX.sm}px`
|
|
116
|
+
|
|
117
|
+
/** The four computed corner radii, clockwise from top-left. */
|
|
118
|
+
function corners(el: Element) {
|
|
119
|
+
const cs = getComputedStyle(el)
|
|
120
|
+
return [
|
|
121
|
+
cs.borderTopLeftRadius,
|
|
122
|
+
cs.borderTopRightRadius,
|
|
123
|
+
cs.borderBottomRightRadius,
|
|
124
|
+
cs.borderBottomLeftRadius,
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const EXPECTED_CORNERS = {
|
|
129
|
+
first: [R, '0px', '0px', R],
|
|
130
|
+
middle: ['0px', '0px', '0px', '0px'],
|
|
131
|
+
last: ['0px', R, R, '0px'],
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Each control's corners match its place: outer radii.sm, inner 0. */
|
|
135
|
+
async function expectJoined(controls: Element[]) {
|
|
136
|
+
await expect(controls.length).toBeGreaterThan(1)
|
|
137
|
+
for (const [i, el] of controls.entries()) {
|
|
138
|
+
const place = i === 0 ? 'first' : i === controls.length - 1 ? 'last' : 'middle'
|
|
139
|
+
await expect(corners(el)).toEqual(EXPECTED_CORNERS[place])
|
|
140
|
+
}
|
|
141
|
+
// No gaps: each control's left border sits on its neighbour's right one.
|
|
142
|
+
for (let i = 1; i < controls.length; i++) {
|
|
143
|
+
const before = controls[i - 1].getBoundingClientRect()
|
|
144
|
+
const after = controls[i].getBoundingClientRect()
|
|
145
|
+
await expect(Math.round(before.right - after.left)).toBe(BORDERS_PX.hairline)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function TextStyleSelects({named = false}: {named?: boolean}) {
|
|
150
|
+
return (
|
|
151
|
+
<ButtonGroup attached aria-label="Text style">
|
|
152
|
+
<Select.Root name={named ? 'block' : undefined} defaultValue="normal">
|
|
153
|
+
<Select.Trigger aria-label="Block type">
|
|
154
|
+
<Select.Value />
|
|
155
|
+
<Select.Icon />
|
|
156
|
+
</Select.Trigger>
|
|
157
|
+
<Select.Popup>
|
|
158
|
+
<Select.Item value="normal">Normal</Select.Item>
|
|
159
|
+
<Select.Item value="h1">Heading 1</Select.Item>
|
|
160
|
+
</Select.Popup>
|
|
161
|
+
</Select.Root>
|
|
162
|
+
<Select.Root name={named ? 'font' : undefined} defaultValue="arial">
|
|
163
|
+
<Select.Trigger aria-label="Font">
|
|
164
|
+
<Select.Value />
|
|
165
|
+
<Select.Icon />
|
|
166
|
+
</Select.Trigger>
|
|
167
|
+
<Select.Popup>
|
|
168
|
+
<Select.Item value="arial">Arial</Select.Item>
|
|
169
|
+
<Select.Item value="georgia">Georgia</Select.Item>
|
|
170
|
+
</Select.Popup>
|
|
171
|
+
</Select.Root>
|
|
172
|
+
<Select.Root name={named ? 'size' : undefined} defaultValue="15">
|
|
173
|
+
<Select.Trigger aria-label="Font size">
|
|
174
|
+
<Select.Value />
|
|
175
|
+
<Select.Icon />
|
|
176
|
+
</Select.Trigger>
|
|
177
|
+
<Select.Popup>
|
|
178
|
+
<Select.Item value="13">13px</Select.Item>
|
|
179
|
+
<Select.Item value="15">15px</Select.Item>
|
|
180
|
+
</Select.Popup>
|
|
181
|
+
</Select.Root>
|
|
182
|
+
</ButtonGroup>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** [Normal | Arial | 15px]: three Selects joined into one control. */
|
|
187
|
+
export const AttachedSelects: Story = {
|
|
188
|
+
render: () => <TextStyleSelects />,
|
|
189
|
+
play: async ({canvas}) => {
|
|
190
|
+
await expectJoined(canvas.getAllByRole('combobox'))
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Named Selects render a hidden input before each trigger; position comes
|
|
195
|
+
* from DOM order, so the trigger buttons still join. */
|
|
196
|
+
export const AttachedNamedSelects: Story = {
|
|
197
|
+
render: () => <TextStyleSelects named />,
|
|
198
|
+
play: async ({canvas, canvasElement}) => {
|
|
199
|
+
await expect(canvasElement.querySelectorAll('input[type="hidden"]').length).toBe(3)
|
|
200
|
+
await expectJoined(canvas.getAllByRole('combobox'))
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const THEMES: ThemeName[] = ['dark', 'light', 'high-contrast']
|
|
205
|
+
|
|
206
|
+
export const AttachedSelectsThemes: Story = {
|
|
207
|
+
render: () => (
|
|
208
|
+
<Stack gap="md">
|
|
209
|
+
{THEMES.map((theme) => (
|
|
210
|
+
<ThemeProvider key={theme} theme={theme}>
|
|
211
|
+
<Stack gap="xs">
|
|
212
|
+
<Text variant="label">{theme}</Text>
|
|
213
|
+
<TextStyleSelects />
|
|
214
|
+
</Stack>
|
|
215
|
+
</ThemeProvider>
|
|
216
|
+
))}
|
|
217
|
+
</Stack>
|
|
218
|
+
),
|
|
219
|
+
play: async ({canvas}) => {
|
|
220
|
+
const groups = canvas.getAllByRole('group', {name: 'Text style'})
|
|
221
|
+
await expect(groups.length).toBe(3)
|
|
222
|
+
for (const group of groups) await expectJoined(within(group).getAllByRole('combobox'))
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Keyboard focus on the middle Select: it rises above both neighbours. */
|
|
227
|
+
export const AttachedFocusMiddle: Story = {
|
|
228
|
+
render: () => <TextStyleSelects />,
|
|
229
|
+
play: async ({canvas}) => {
|
|
230
|
+
const [first, middle, last] = canvas.getAllByRole('combobox')
|
|
231
|
+
first.focus()
|
|
232
|
+
await userEvent.tab()
|
|
233
|
+
await expect(middle).toHaveFocus()
|
|
234
|
+
await expect(getComputedStyle(middle).zIndex).toBe(String(LAYERS.raised))
|
|
235
|
+
await expect(getComputedStyle(first).zIndex).toBe('auto')
|
|
236
|
+
await expect(getComputedStyle(last).zIndex).toBe('auto')
|
|
237
|
+
// The shared edges are drawn by the focused trigger, not its neighbours.
|
|
238
|
+
const r = middle.getBoundingClientRect()
|
|
239
|
+
const y = r.top + r.height / 2
|
|
240
|
+
await expect(middle.contains(document.elementFromPoint(r.left + 0.5, y))).toBe(true)
|
|
241
|
+
await expect(middle.contains(document.elementFromPoint(r.right - 0.5, y))).toBe(true)
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function FormatGroup() {
|
|
246
|
+
const [pressed, setPressed] = useState<Record<string, boolean>>({u: true})
|
|
247
|
+
const toggle = (key: string) => (next: boolean) => setPressed((p) => ({...p, [key]: next}))
|
|
248
|
+
return (
|
|
249
|
+
<ButtonGroup attached aria-label="Format">
|
|
250
|
+
<Toggle aria-label="Bold" pressed={!!pressed.b} onPressedChange={toggle('b')}>
|
|
251
|
+
B
|
|
252
|
+
</Toggle>
|
|
253
|
+
<Toggle aria-label="Italic" pressed={!!pressed.i} onPressedChange={toggle('i')}>
|
|
254
|
+
I
|
|
255
|
+
</Toggle>
|
|
256
|
+
<Toggle aria-label="Underline" pressed={!!pressed.u} onPressedChange={toggle('u')}>
|
|
257
|
+
U
|
|
258
|
+
</Toggle>
|
|
259
|
+
<Toggle aria-label="Code" pressed={!!pressed.code} onPressedChange={toggle('code')}>
|
|
260
|
+
{'<>'}
|
|
261
|
+
</Toggle>
|
|
262
|
+
<Popover.Root>
|
|
263
|
+
<Popover.Trigger aria-label="Link">↗</Popover.Trigger>
|
|
264
|
+
<Popover.Popup label="Link">
|
|
265
|
+
<Stack gap="sm">
|
|
266
|
+
<Text>Paste a URL.</Text>
|
|
267
|
+
<Button size="small">Apply</Button>
|
|
268
|
+
</Stack>
|
|
269
|
+
</Popover.Popup>
|
|
270
|
+
</Popover.Root>
|
|
271
|
+
<Button variant="secondary" aria-label="Clear formatting">
|
|
272
|
+
T
|
|
273
|
+
</Button>
|
|
274
|
+
<Button variant="secondary" aria-label="Highlight">
|
|
275
|
+
H
|
|
276
|
+
</Button>
|
|
277
|
+
</ButtonGroup>
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** [B | I | U | <> | link | T | highlight]: four Toggles, a Popover trigger
|
|
282
|
+
* and two icon Buttons, joined. */
|
|
283
|
+
export const AttachedMixed: Story = {
|
|
284
|
+
render: () => <FormatGroup />,
|
|
285
|
+
play: async ({canvas}) => {
|
|
286
|
+
const group = canvas.getByRole('group', {name: 'Format'})
|
|
287
|
+
await expectJoined(within(group).getAllByRole('button'))
|
|
288
|
+
},
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** A pressed Toggle in an attached group keeps aria-pressed, its pressed
|
|
292
|
+
* fill and its joined corners. */
|
|
293
|
+
export const AttachedPressedToggle: Story = {
|
|
294
|
+
render: () => <FormatGroup />,
|
|
295
|
+
play: async ({canvas}) => {
|
|
296
|
+
const underline = canvas.getByRole('button', {name: 'Underline'})
|
|
297
|
+
await expect(underline).toHaveAttribute('aria-pressed', 'true')
|
|
298
|
+
await expect(corners(underline)).toEqual(EXPECTED_CORNERS.middle)
|
|
299
|
+
const bold = canvas.getByRole('button', {name: 'Bold'})
|
|
300
|
+
await expect(getComputedStyle(underline).backgroundColor).not.toBe(
|
|
301
|
+
getComputedStyle(bold).backgroundColor,
|
|
302
|
+
)
|
|
303
|
+
},
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** True when `el` is what is drawn at (x, y). */
|
|
307
|
+
const drawnAt = (el: Element, x: number, y: number) => el.contains(document.elementFromPoint(x, y))
|
|
308
|
+
|
|
309
|
+
/** The centre of the first whole pixel inside the border on each side: where
|
|
310
|
+
* the inset accent ring is drawn, after pixel snapping. */
|
|
311
|
+
function ringPixels(r: DOMRect) {
|
|
312
|
+
const h = BORDERS_PX.hairline
|
|
313
|
+
return {
|
|
314
|
+
left: Math.ceil(r.left + h) + 0.5,
|
|
315
|
+
right: Math.floor(r.right - h) - 0.5,
|
|
316
|
+
top: Math.ceil(r.top + h) + 0.5,
|
|
317
|
+
bottom: Math.floor(r.bottom - h) - 0.5,
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** U pressed between I and <>: its accent edge shows on all four sides. */
|
|
322
|
+
export const PressedBetweenNeighbours: Story = {
|
|
323
|
+
render: () => <FormatGroup />,
|
|
324
|
+
play: async ({canvas}) => {
|
|
325
|
+
const underline = canvas.getByRole('button', {name: 'Underline'})
|
|
326
|
+
await expect(getComputedStyle(underline).boxShadow).toMatch(/inset/)
|
|
327
|
+
const r = underline.getBoundingClientRect()
|
|
328
|
+
const ring = ringPixels(r)
|
|
329
|
+
const cx = r.left + r.width / 2
|
|
330
|
+
const cy = r.top + r.height / 2
|
|
331
|
+
// Top and bottom: nothing overlaps, so a hit test inside the border lands on U.
|
|
332
|
+
await expect(drawnAt(underline, cx, ring.top)).toBe(true)
|
|
333
|
+
await expect(drawnAt(underline, cx, ring.bottom)).toBe(true)
|
|
334
|
+
await expect(drawnAt(underline, cx, cy)).toBe(true)
|
|
335
|
+
// Left and right: the neighbours' negative margins cover U's border and no
|
|
336
|
+
// more, so the inset ring (inside the border) stays clear on both sides.
|
|
337
|
+
// Measured, not hit-tested: a 1px band does not survive pixel snapping in
|
|
338
|
+
// a scaled test frame.
|
|
339
|
+
const h = BORDERS_PX.hairline
|
|
340
|
+
const left = canvas.getByRole('button', {name: 'Italic'}).getBoundingClientRect()
|
|
341
|
+
const right = canvas.getByRole('button', {name: 'Code'}).getBoundingClientRect()
|
|
342
|
+
await expect(left.right).toBeLessThanOrEqual(r.left + h + 0.01)
|
|
343
|
+
await expect(right.left).toBeGreaterThanOrEqual(r.right - h - 0.01)
|
|
344
|
+
await expect(getComputedStyle(underline).boxShadow).toContain(`${h}px inset`)
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** B and I both pressed, then I focused: both accent edges show, and I is
|
|
349
|
+
* drawn over the shared border, so its focus ring is not covered. */
|
|
350
|
+
export const BothPressedFocusSecond: Story = {
|
|
351
|
+
render: () => <FormatGroup />,
|
|
352
|
+
play: async ({canvas}) => {
|
|
353
|
+
const bold = canvas.getByRole('button', {name: 'Bold'})
|
|
354
|
+
const italic = canvas.getByRole('button', {name: 'Italic'})
|
|
355
|
+
await userEvent.click(bold)
|
|
356
|
+
await userEvent.click(italic)
|
|
357
|
+
await expect(bold).toHaveAttribute('aria-pressed', 'true')
|
|
358
|
+
await expect(italic).toHaveAttribute('aria-pressed', 'true')
|
|
359
|
+
bold.focus()
|
|
360
|
+
await userEvent.tab()
|
|
361
|
+
await expect(italic).toHaveFocus()
|
|
362
|
+
await expect(getComputedStyle(italic).zIndex).toBe(String(LAYERS.raised))
|
|
363
|
+
const b = bold.getBoundingClientRect()
|
|
364
|
+
const i = italic.getBoundingClientRect()
|
|
365
|
+
// Sampled down the shared edge: I is on top all along it.
|
|
366
|
+
for (const t of [0.25, 0.5, 0.75]) {
|
|
367
|
+
const y = i.top + i.height * t
|
|
368
|
+
await expect(drawnAt(italic, i.left + 0.5, y)).toBe(true)
|
|
369
|
+
}
|
|
370
|
+
for (const el of [bold, italic]) await expect(getComputedStyle(el).boxShadow).toMatch(/inset/)
|
|
371
|
+
await expect(drawnAt(bold, b.left + b.width / 2, ringPixels(b).top)).toBe(true)
|
|
372
|
+
await expect(drawnAt(bold, b.left + b.width / 2, ringPixels(b).bottom)).toBe(true)
|
|
373
|
+
},
|
|
374
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import {type ReactNode, useCallback, useMemo} from 'react'
|
|
2
2
|
import {html} from 'react-strict-dom'
|
|
3
3
|
import type {SpacingToken} from '@duro-app/tokens/keys'
|
|
4
|
+
import {useOrderedRegistry} from '../../shared/useOrderedRegistry'
|
|
5
|
+
import {ButtonGroupContext, type ButtonGroupContextValue} from './ButtonGroupContext'
|
|
4
6
|
import {styles} from './styles.css'
|
|
5
7
|
|
|
6
8
|
export type ButtonGroupGap = Extract<SpacingToken, 'xs' | 'sm' | 'md'>
|
|
@@ -13,8 +15,15 @@ export interface ButtonGroupProps {
|
|
|
13
15
|
align?: 'start' | 'end' | 'center'
|
|
14
16
|
/** Disable all buttons in group */
|
|
15
17
|
disabled?: boolean
|
|
16
|
-
/** Gap between buttons. Default: 'sm' */
|
|
18
|
+
/** Gap between buttons. Default: 'sm' (ignored when `attached`) */
|
|
17
19
|
gap?: ButtonGroupGap
|
|
20
|
+
/**
|
|
21
|
+
* One joined control: no gap, shared borders, square inner corners. Holds
|
|
22
|
+
* Buttons, Toggles and the Select, Menu and Popover triggers, in any mix.
|
|
23
|
+
*/
|
|
24
|
+
attached?: boolean
|
|
25
|
+
/** Names the group for assistive tech (a toolbar segment: "Text style"). */
|
|
26
|
+
'aria-label'?: string
|
|
18
27
|
}
|
|
19
28
|
|
|
20
29
|
const gapMap = {
|
|
@@ -41,22 +50,45 @@ export function ButtonGroup({
|
|
|
41
50
|
align = 'start',
|
|
42
51
|
disabled = false,
|
|
43
52
|
gap = 'sm',
|
|
53
|
+
attached = false,
|
|
54
|
+
'aria-label': ariaLabel,
|
|
44
55
|
}: ButtonGroupProps) {
|
|
45
56
|
const isVertical = orientation === 'vertical'
|
|
46
57
|
const alignStyle = isVertical ? verticalAlignMap[align] : horizontalAlignMap[align]
|
|
58
|
+
const {order, register} = useOrderedRegistry()
|
|
59
|
+
|
|
60
|
+
const positionOf = useCallback<ButtonGroupContextValue['positionOf']>(
|
|
61
|
+
(id) => {
|
|
62
|
+
const i = order.findIndex((entry) => entry.id === id)
|
|
63
|
+
if (i < 0) return 'middle'
|
|
64
|
+
if (order.length === 1) return 'only'
|
|
65
|
+
if (i === 0) return 'first'
|
|
66
|
+
return i === order.length - 1 ? 'last' : 'middle'
|
|
67
|
+
},
|
|
68
|
+
[order],
|
|
69
|
+
)
|
|
70
|
+
const ctx = useMemo<ButtonGroupContextValue>(
|
|
71
|
+
() => ({orientation, register, positionOf}),
|
|
72
|
+
[orientation, register, positionOf],
|
|
73
|
+
)
|
|
47
74
|
|
|
48
75
|
return (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
76
|
+
// A non-attached group still resets the context, so a plain group nested
|
|
77
|
+
// in an attached one lays out on its own.
|
|
78
|
+
<ButtonGroupContext.Provider value={attached ? ctx : null}>
|
|
79
|
+
<html.div
|
|
80
|
+
role="group"
|
|
81
|
+
aria-label={ariaLabel}
|
|
82
|
+
style={[
|
|
83
|
+
styles.base,
|
|
84
|
+
isVertical ? styles.vertical : styles.horizontal,
|
|
85
|
+
alignStyle,
|
|
86
|
+
attached ? (isVertical ? styles.attachedVertical : styles.attached) : gapMap[gap],
|
|
87
|
+
disabled && styles.disabled,
|
|
88
|
+
]}
|
|
89
|
+
>
|
|
90
|
+
{children}
|
|
91
|
+
</html.div>
|
|
92
|
+
</ButtonGroupContext.Provider>
|
|
61
93
|
)
|
|
62
94
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {createContext, useContext} from 'react'
|
|
2
|
+
import type {Orientation} from '../../shared/types'
|
|
3
|
+
|
|
4
|
+
/** Where a control sits in an attached group. */
|
|
5
|
+
export type GroupPosition = 'first' | 'middle' | 'last' | 'only'
|
|
6
|
+
|
|
7
|
+
export interface ButtonGroupContextValue {
|
|
8
|
+
orientation: Orientation
|
|
9
|
+
register: (id: string, el: HTMLElement, disabled: boolean) => () => void
|
|
10
|
+
/** `middle` until the control has registered (server render, first paint). */
|
|
11
|
+
positionOf: (id: string) => GroupPosition
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Provided by an `attached` ButtonGroup only; null everywhere else. */
|
|
15
|
+
export const ButtonGroupContext = createContext<ButtonGroupContextValue | null>(null)
|
|
16
|
+
|
|
17
|
+
export function useButtonGroup() {
|
|
18
|
+
return useContext(ButtonGroupContext)
|
|
19
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
import {borders} from '@duro-app/tokens/tokens/borders.css'
|
|
3
|
+
import {layers} from '@duro-app/tokens/tokens/layers.css'
|
|
4
|
+
|
|
5
|
+
// The styles a control takes in an attached ButtonGroup, by its position.
|
|
6
|
+
// Borders collapse into the neighbour's with a negative hairline margin; only
|
|
7
|
+
// the outer corners keep the control's own radius.
|
|
8
|
+
const overlap = `calc(-1 * ${borders.hairline})`
|
|
9
|
+
|
|
10
|
+
export const attached = css.create({
|
|
11
|
+
// A focused control rises above its neighbours, so their borders never
|
|
12
|
+
// cover its focus ring. Position makes the z-index apply to a trigger that
|
|
13
|
+
// is not itself the flex item (Select and Menu triggers sit in a Root).
|
|
14
|
+
item: {
|
|
15
|
+
position: 'relative',
|
|
16
|
+
zIndex: {default: null, ':focus-visible': layers.raised},
|
|
17
|
+
},
|
|
18
|
+
horizontalFirst: {
|
|
19
|
+
borderTopRightRadius: 0,
|
|
20
|
+
borderBottomRightRadius: 0,
|
|
21
|
+
},
|
|
22
|
+
horizontalMiddle: {
|
|
23
|
+
borderRadius: 0,
|
|
24
|
+
marginLeft: overlap,
|
|
25
|
+
},
|
|
26
|
+
horizontalLast: {
|
|
27
|
+
borderTopLeftRadius: 0,
|
|
28
|
+
borderBottomLeftRadius: 0,
|
|
29
|
+
marginLeft: overlap,
|
|
30
|
+
},
|
|
31
|
+
verticalFirst: {
|
|
32
|
+
borderBottomLeftRadius: 0,
|
|
33
|
+
borderBottomRightRadius: 0,
|
|
34
|
+
},
|
|
35
|
+
verticalMiddle: {
|
|
36
|
+
borderRadius: 0,
|
|
37
|
+
marginTop: overlap,
|
|
38
|
+
},
|
|
39
|
+
verticalLast: {
|
|
40
|
+
borderTopLeftRadius: 0,
|
|
41
|
+
borderTopRightRadius: 0,
|
|
42
|
+
marginTop: overlap,
|
|
43
|
+
},
|
|
44
|
+
})
|
|
@@ -21,6 +21,14 @@ export const styles = css.create({
|
|
|
21
21
|
gapXs: {gap: spacing.xs},
|
|
22
22
|
gapSm: {gap: spacing.sm},
|
|
23
23
|
gapMd: {gap: spacing.md},
|
|
24
|
+
// One joined control: no gap, one row, every control as tall as the row.
|
|
25
|
+
attached: {
|
|
26
|
+
flexWrap: 'nowrap',
|
|
27
|
+
alignItems: 'stretch',
|
|
28
|
+
},
|
|
29
|
+
attachedVertical: {
|
|
30
|
+
alignItems: 'stretch',
|
|
31
|
+
},
|
|
24
32
|
disabled: {
|
|
25
33
|
opacity: 0.5,
|
|
26
34
|
pointerEvents: 'none',
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {type RefObject, useId, useLayoutEffect, useRef} from 'react'
|
|
2
|
+
import type {html} from 'react-strict-dom'
|
|
3
|
+
import {isNative} from '../../platform'
|
|
4
|
+
import {useToolbar} from '../Toolbar/ToolbarContext'
|
|
5
|
+
import {useButtonGroup, type GroupPosition} from './ButtonGroupContext'
|
|
6
|
+
import {attached} from './attached.css'
|
|
7
|
+
|
|
8
|
+
const positionStyles = {
|
|
9
|
+
horizontal: {
|
|
10
|
+
first: attached.horizontalFirst,
|
|
11
|
+
middle: attached.horizontalMiddle,
|
|
12
|
+
last: attached.horizontalLast,
|
|
13
|
+
only: null,
|
|
14
|
+
},
|
|
15
|
+
vertical: {
|
|
16
|
+
first: attached.verticalFirst,
|
|
17
|
+
middle: attached.verticalMiddle,
|
|
18
|
+
last: attached.verticalLast,
|
|
19
|
+
only: null,
|
|
20
|
+
},
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
type ButtonStyle = Parameters<typeof html.button>[0]['style']
|
|
24
|
+
|
|
25
|
+
export interface GroupedControl<T extends HTMLElement> {
|
|
26
|
+
ref: RefObject<T | null>
|
|
27
|
+
/** In an attached ButtonGroup. */
|
|
28
|
+
attached: boolean
|
|
29
|
+
position: GroupPosition | null
|
|
30
|
+
/** The joined style for its position; spread after the control's own. */
|
|
31
|
+
style: ButtonStyle | null
|
|
32
|
+
/** The Toolbar's roving tabindex, when in one. */
|
|
33
|
+
tabIndex: 0 | -1 | undefined
|
|
34
|
+
onFocus: (() => void) | undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What a control (Button, Toggle, the Select, Menu and Popover triggers) takes
|
|
39
|
+
* from an attached ButtonGroup and a Toolbar around it: its joined style by
|
|
40
|
+
* position, and its roving tabindex. Outside both, every field is inert.
|
|
41
|
+
*/
|
|
42
|
+
export function useGroupedControl<T extends HTMLElement>({
|
|
43
|
+
disabled = false,
|
|
44
|
+
}: {disabled?: boolean} = {}): GroupedControl<T> {
|
|
45
|
+
const id = useId()
|
|
46
|
+
const ref = useRef<T | null>(null)
|
|
47
|
+
const group = useButtonGroup()
|
|
48
|
+
const toolbar = useToolbar()
|
|
49
|
+
|
|
50
|
+
const groupRegister = group?.register
|
|
51
|
+
const toolbarRegister = toolbar?.register
|
|
52
|
+
useLayoutEffect(() => {
|
|
53
|
+
const el = ref.current
|
|
54
|
+
if (isNative || !el) return
|
|
55
|
+
const unregisterGroup = groupRegister?.(id, el, disabled)
|
|
56
|
+
const unregisterToolbar = toolbarRegister?.(id, el, disabled)
|
|
57
|
+
return () => {
|
|
58
|
+
unregisterGroup?.()
|
|
59
|
+
unregisterToolbar?.()
|
|
60
|
+
}
|
|
61
|
+
}, [id, disabled, groupRegister, toolbarRegister])
|
|
62
|
+
|
|
63
|
+
const position: GroupPosition | null = group ? group.positionOf(id) : null
|
|
64
|
+
const style: ButtonStyle | null =
|
|
65
|
+
group && position ? [attached.item, positionStyles[group.orientation][position]] : null
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
ref,
|
|
69
|
+
attached: group !== null,
|
|
70
|
+
position,
|
|
71
|
+
style,
|
|
72
|
+
tabIndex: toolbar?.tabIndexOf(id),
|
|
73
|
+
onFocus: toolbar ? () => toolbar.onItemFocus(id) : undefined,
|
|
74
|
+
}
|
|
75
|
+
}
|