@estiva-app/ui 0.8.0 → 0.10.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/Avatar.d.ts +13 -1
- package/dist/Avatar.d.ts.map +1 -1
- package/dist/AvatarGroup.d.ts.map +1 -1
- package/dist/Banner.d.ts +16 -1
- package/dist/Banner.d.ts.map +1 -1
- package/dist/Breadcrumb.d.ts +9 -0
- package/dist/Breadcrumb.d.ts.map +1 -1
- package/dist/Button.d.ts +12 -2
- package/dist/Button.d.ts.map +1 -1
- package/dist/Checkbox.d.ts +2 -0
- package/dist/Checkbox.d.ts.map +1 -1
- package/dist/Chip.d.ts +3 -2
- package/dist/Chip.d.ts.map +1 -1
- package/dist/ChipInput.d.ts +3 -1
- package/dist/ChipInput.d.ts.map +1 -1
- package/dist/ConfirmDialog.d.ts +5 -0
- package/dist/ConfirmDialog.d.ts.map +1 -1
- package/dist/DialogShell.d.ts +27 -3
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/EditableText.d.ts +6 -0
- package/dist/EditableText.d.ts.map +1 -1
- package/dist/Field.d.ts +42 -25
- package/dist/Field.d.ts.map +1 -1
- package/dist/IconButton.d.ts +3 -2
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/IdentityMenu.d.ts +25 -13
- package/dist/IdentityMenu.d.ts.map +1 -1
- package/dist/Menu.d.ts +65 -82
- package/dist/Menu.d.ts.map +1 -1
- package/dist/PersonTrigger.d.ts.map +1 -1
- package/dist/Popover.d.ts +86 -0
- package/dist/Popover.d.ts.map +1 -0
- package/dist/PreviewCard.d.ts +39 -0
- package/dist/PreviewCard.d.ts.map +1 -0
- package/dist/Reaction.d.ts.map +1 -1
- package/dist/ReactionPicker.d.ts +55 -0
- package/dist/ReactionPicker.d.ts.map +1 -0
- package/dist/SearchInput.d.ts +5 -0
- package/dist/SearchInput.d.ts.map +1 -1
- package/dist/Select.d.ts +26 -16
- package/dist/Select.d.ts.map +1 -1
- package/dist/Tabs.d.ts +11 -1
- package/dist/Tabs.d.ts.map +1 -1
- package/dist/TextInput.d.ts +7 -1
- package/dist/TextInput.d.ts.map +1 -1
- package/dist/Textarea.d.ts +8 -1
- package/dist/Textarea.d.ts.map +1 -1
- package/dist/Toolbar.d.ts +95 -0
- package/dist/Toolbar.d.ts.map +1 -0
- package/dist/Tooltip.d.ts +36 -10
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/cn.d.ts.map +1 -1
- package/dist/fit.d.ts +7 -72
- package/dist/fit.d.ts.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +882 -763
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/Avatar.mdx +17 -0
- package/src/Avatar.name.test.tsx +99 -0
- package/src/Avatar.tsx +25 -3
- package/src/AvatarGroup.tsx +4 -1
- package/src/Banner.mdx +10 -2
- package/src/Banner.stories.tsx +29 -0
- package/src/Banner.test.tsx +60 -0
- package/src/Banner.tsx +35 -3
- package/src/Breadcrumb.mdx +5 -2
- package/src/Breadcrumb.test.tsx +44 -0
- package/src/Breadcrumb.tsx +10 -2
- package/src/Button.compose.test.tsx +119 -0
- package/src/Button.mdx +7 -5
- package/src/Button.test.tsx +2 -1
- package/src/Button.tsx +21 -5
- package/src/Checkbox.tsx +3 -0
- package/src/Chip.tsx +3 -2
- package/src/ChipInput.tsx +4 -0
- package/src/ConfirmDialog.mdx +20 -1
- package/src/ConfirmDialog.test.tsx +109 -0
- package/src/ConfirmDialog.tsx +6 -0
- package/src/DialogShell.mdx +18 -4
- package/src/DialogShell.stories.tsx +56 -6
- package/src/DialogShell.test.tsx +184 -0
- package/src/DialogShell.tsx +108 -39
- package/src/EditableText.mdx +6 -1
- package/src/EditableText.test.tsx +156 -0
- package/src/EditableText.tsx +23 -5
- package/src/Field.mdx +23 -3
- package/src/Field.stories.tsx +35 -0
- package/src/Field.test.tsx +117 -27
- package/src/Field.tsx +86 -58
- package/src/IconButton.mdx +6 -2
- package/src/IconButton.test.tsx +5 -2
- package/src/IconButton.tsx +12 -6
- package/src/IdentityMenu.mdx +29 -6
- package/src/IdentityMenu.stories.tsx +41 -6
- package/src/IdentityMenu.tsx +74 -47
- package/src/Kbd.stories.tsx +3 -3
- package/src/Menu.mdx +88 -75
- package/src/Menu.stories.tsx +120 -52
- package/src/Menu.test.tsx +315 -0
- package/src/Menu.tsx +341 -251
- package/src/MenuItem.stories.tsx +135 -98
- package/src/NavItem.mdx +1 -1
- package/src/PersonTrigger.mdx +20 -14
- package/src/PersonTrigger.tsx +23 -1
- package/src/Popover.mdx +130 -0
- package/src/Popover.stories.tsx +222 -0
- package/src/Popover.test.tsx +188 -0
- package/src/Popover.tsx +153 -0
- package/src/PreviewCard.mdx +98 -0
- package/src/PreviewCard.stories.tsx +168 -0
- package/src/PreviewCard.test.tsx +83 -0
- package/src/PreviewCard.tsx +91 -0
- package/src/Rail.mdx +63 -0
- package/src/Rail.stories.tsx +81 -0
- package/src/Reaction.mdx +4 -2
- package/src/Reaction.tsx +4 -2
- package/src/ReactionPicker.mdx +85 -0
- package/src/ReactionPicker.stories.tsx +120 -0
- package/src/ReactionPicker.test.tsx +118 -0
- package/src/ReactionPicker.tsx +88 -0
- package/src/SearchInput.mdx +6 -2
- package/src/SearchInput.tsx +7 -1
- package/src/Select.mdx +16 -5
- package/src/Select.test.tsx +157 -0
- package/src/Select.tsx +89 -214
- package/src/Sidebar.mdx +6 -2
- package/src/Tabs.mdx +5 -0
- package/src/Tabs.test.tsx +15 -0
- package/src/Tabs.tsx +17 -2
- package/src/TextInput.mdx +8 -2
- package/src/TextInput.tsx +10 -7
- package/src/Textarea.mdx +6 -2
- package/src/Textarea.tsx +14 -9
- package/src/Toolbar.mdx +104 -0
- package/src/Toolbar.stories.tsx +142 -0
- package/src/Toolbar.test.tsx +198 -0
- package/src/Toolbar.tsx +158 -0
- package/src/Tooltip.mdx +43 -5
- package/src/Tooltip.stories.tsx +26 -0
- package/src/Tooltip.test.tsx +195 -0
- package/src/Tooltip.tsx +150 -48
- package/src/cn.ts +1 -1
- package/src/fit.test.ts +101 -0
- package/src/fit.ts +19 -63
- package/src/index.ts +6 -3
- package/stories/Choosing.mdx +8 -3
- package/stories/TokensPage.tsx +6 -2
- package/tailwind-preset.js +12 -0
- package/tokens.css +8 -0
- package/src/Menu.fit.test.ts +0 -90
- package/src/Select.fit.test.ts +0 -101
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconArrowBackUp, IconArrowForwardUp, IconBold, IconItalic, IconLink, IconSquareRounded, IconUnderline } from '@tabler/icons-react'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { IconButton } from './IconButton'
|
|
5
|
+
import { Popover } from './Popover'
|
|
6
|
+
import { Toolbar, ToolbarButton, ToolbarInput, ToolbarSeparator } from './Toolbar'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A strip of controls that behaves as **one** control: Tab in, arrow keys
|
|
10
|
+
* along, Tab out.
|
|
11
|
+
*
|
|
12
|
+
* It draws the elevated box a floating strip needs — the same one a `Menu`
|
|
13
|
+
* draws — so a toolbar over a card, a paragraph or an image is separated from
|
|
14
|
+
* it without the caller drawing anything.
|
|
15
|
+
*/
|
|
16
|
+
const meta = {
|
|
17
|
+
title: 'Primitives/Toolbar',
|
|
18
|
+
component: Toolbar,
|
|
19
|
+
args: { 'aria-label': 'Formatting', children: null },
|
|
20
|
+
argTypes: { children: { control: false } },
|
|
21
|
+
} satisfies Meta<typeof Toolbar>
|
|
22
|
+
|
|
23
|
+
export default meta
|
|
24
|
+
type Story = StoryObj<typeof meta>
|
|
25
|
+
|
|
26
|
+
const icon = <IconSquareRounded size={16} stroke={1.5} />
|
|
27
|
+
|
|
28
|
+
/** Four controls, one Tab stop. */
|
|
29
|
+
export const Default: Story = {
|
|
30
|
+
render: (args) => (
|
|
31
|
+
<Toolbar {...args}>
|
|
32
|
+
<ToolbarButton aria-label="Item one" tooltip="Item one">{icon}</ToolbarButton>
|
|
33
|
+
<ToolbarButton aria-label="Item two" tooltip="Item two">{icon}</ToolbarButton>
|
|
34
|
+
<ToolbarButton aria-label="Item three" tooltip="Item three">{icon}</ToolbarButton>
|
|
35
|
+
<ToolbarButton aria-label="Item four" tooltip="Item four">{icon}</ToolbarButton>
|
|
36
|
+
</Toolbar>
|
|
37
|
+
),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Groups, divided. The separator carries the role as well as the hairline. */
|
|
41
|
+
export const Grouped: Story = {
|
|
42
|
+
parameters: { controls: { disable: true } },
|
|
43
|
+
render: () => (
|
|
44
|
+
<Toolbar aria-label="Formatting">
|
|
45
|
+
<ToolbarButton aria-label="Undo" tooltip="Undo" tooltipShortcut="Cmd+Z">
|
|
46
|
+
<IconArrowBackUp size={16} stroke={1.5} />
|
|
47
|
+
</ToolbarButton>
|
|
48
|
+
<ToolbarButton aria-label="Redo" tooltip="Redo" tooltipShortcut="Cmd+Shift+Z">
|
|
49
|
+
<IconArrowForwardUp size={16} stroke={1.5} />
|
|
50
|
+
</ToolbarButton>
|
|
51
|
+
<ToolbarSeparator />
|
|
52
|
+
<ToolbarButton aria-label="Bold" tooltip="Bold" tooltipShortcut="Cmd+B">
|
|
53
|
+
<IconBold size={16} stroke={1.5} />
|
|
54
|
+
</ToolbarButton>
|
|
55
|
+
<ToolbarButton aria-label="Italic" tooltip="Italic" tooltipShortcut="Cmd+I">
|
|
56
|
+
<IconItalic size={16} stroke={1.5} />
|
|
57
|
+
</ToolbarButton>
|
|
58
|
+
<ToolbarButton aria-label="Underline" tooltip="Underline" tooltipShortcut="Cmd+U">
|
|
59
|
+
<IconUnderline size={16} stroke={1.5} />
|
|
60
|
+
</ToolbarButton>
|
|
61
|
+
</Toolbar>
|
|
62
|
+
),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A field in the strip. While it has focus the arrow keys are the caret's —
|
|
67
|
+
* which is what a field is for — and Tab resumes the walk. That is why a field
|
|
68
|
+
* in a toolbar is `ToolbarInput` rather than a `TextInput` dropped in the row.
|
|
69
|
+
*/
|
|
70
|
+
export const WithAField: Story = {
|
|
71
|
+
parameters: { controls: { disable: true } },
|
|
72
|
+
render: function WithField() {
|
|
73
|
+
const [url, setUrl] = useState('')
|
|
74
|
+
return (
|
|
75
|
+
<Toolbar aria-label="Link">
|
|
76
|
+
<ToolbarButton aria-label="Link" tooltip="Link">
|
|
77
|
+
<IconLink size={16} stroke={1.5} />
|
|
78
|
+
</ToolbarButton>
|
|
79
|
+
<ToolbarSeparator />
|
|
80
|
+
<ToolbarInput value={url} onChange={(e) => setUrl(e.target.value)} placeholder="Paste a link" aria-label="Link address" className="h-7 w-48" />
|
|
81
|
+
</Toolbar>
|
|
82
|
+
)
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A control that cannot be used **keeps its place in the walk**. A strip whose
|
|
88
|
+
* controls come and go from the arrow keys as their state changes is a strip
|
|
89
|
+
* you cannot learn — and a `disabledReason` you cannot reach is a reason
|
|
90
|
+
* nobody reads.
|
|
91
|
+
*/
|
|
92
|
+
export const WithADisabledControl: Story = {
|
|
93
|
+
parameters: { controls: { disable: true } },
|
|
94
|
+
render: () => (
|
|
95
|
+
<Toolbar aria-label="Formatting">
|
|
96
|
+
<ToolbarButton aria-label="Item one" tooltip="Item one">{icon}</ToolbarButton>
|
|
97
|
+
<ToolbarButton aria-label="Item two" disabledReason="Available once there is a selection">{icon}</ToolbarButton>
|
|
98
|
+
<ToolbarButton aria-label="Item three" tooltip="Item three">{icon}</ToolbarButton>
|
|
99
|
+
</Toolbar>
|
|
100
|
+
),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Down rather than across: ↑ and ↓ walk it. */
|
|
104
|
+
export const Vertical: Story = {
|
|
105
|
+
parameters: { controls: { disable: true } },
|
|
106
|
+
render: () => (
|
|
107
|
+
<Toolbar aria-label="Formatting" orientation="vertical">
|
|
108
|
+
<ToolbarButton aria-label="Item one" tooltip="Item one" tooltipPlacement="bottom">{icon}</ToolbarButton>
|
|
109
|
+
<ToolbarButton aria-label="Item two" tooltip="Item two" tooltipPlacement="bottom">{icon}</ToolbarButton>
|
|
110
|
+
<ToolbarButton aria-label="Item three" tooltip="Item three" tooltipPlacement="bottom">{icon}</ToolbarButton>
|
|
111
|
+
</Toolbar>
|
|
112
|
+
),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* **Inside something that already draws a box** — a `Popover`, a dialog, a
|
|
117
|
+
* card's own panel — the strip drops its own with `surface={false}`. Two
|
|
118
|
+
* boxes inside each other is the tell.
|
|
119
|
+
*/
|
|
120
|
+
export const OnAnExistingSurface: Story = {
|
|
121
|
+
parameters: { controls: { disable: true }, layout: 'fullscreen' },
|
|
122
|
+
render: () => (
|
|
123
|
+
<div className="flex h-[220px] w-full items-end justify-center pb-4">
|
|
124
|
+
<Popover
|
|
125
|
+
/* An `IconButton`, not a `ToolbarButton`: the trigger stands outside the
|
|
126
|
+
strip, and a toolbar part outside a `Toolbar` throws. */
|
|
127
|
+
trigger={<IconButton aria-label="Open" tooltip="Open">{icon}</IconButton>}
|
|
128
|
+
ariaLabel="Formatting"
|
|
129
|
+
/* Above the control that opened it: a strip acts on what is under it.
|
|
130
|
+
`side` is the preference; Base UI flips it when there is no room. */
|
|
131
|
+
side="top"
|
|
132
|
+
className="w-auto min-w-0 p-1"
|
|
133
|
+
>
|
|
134
|
+
<Toolbar aria-label="Formatting" surface={false}>
|
|
135
|
+
<ToolbarButton aria-label="Item one" tooltip="Item one">{icon}</ToolbarButton>
|
|
136
|
+
<ToolbarButton aria-label="Item two" tooltip="Item two">{icon}</ToolbarButton>
|
|
137
|
+
<ToolbarButton aria-label="Item three" tooltip="Item three">{icon}</ToolbarButton>
|
|
138
|
+
</Toolbar>
|
|
139
|
+
</Popover>
|
|
140
|
+
</div>
|
|
141
|
+
),
|
|
142
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the Toolbar page claims, pinned — and the reason the component exists
|
|
4
|
+
* is the first test: a strip of controls is one Tab stop, not one per button.
|
|
5
|
+
*/
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
8
|
+
import userEvent from '@testing-library/user-event'
|
|
9
|
+
import { IconButton } from './IconButton'
|
|
10
|
+
import { Toolbar, ToolbarButton, ToolbarInput, ToolbarSeparator } from './Toolbar'
|
|
11
|
+
|
|
12
|
+
afterEach(cleanup)
|
|
13
|
+
|
|
14
|
+
const dot = <span aria-hidden="true">·</span>
|
|
15
|
+
|
|
16
|
+
function Strip({ label = 'Formatting' }: { label?: string }) {
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<button type="button">Before</button>
|
|
20
|
+
<Toolbar aria-label={label}>
|
|
21
|
+
<ToolbarButton aria-label="One">{dot}</ToolbarButton>
|
|
22
|
+
<ToolbarButton aria-label="Two">{dot}</ToolbarButton>
|
|
23
|
+
<ToolbarButton aria-label="Three">{dot}</ToolbarButton>
|
|
24
|
+
</Toolbar>
|
|
25
|
+
<button type="button">After</button>
|
|
26
|
+
</>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const focused = () => document.activeElement?.getAttribute('aria-label') ?? document.activeElement?.textContent
|
|
31
|
+
|
|
32
|
+
describe('Toolbar', () => {
|
|
33
|
+
it('is a named toolbar', () => {
|
|
34
|
+
render(<Strip />)
|
|
35
|
+
expect(screen.getByRole('toolbar', { name: 'Formatting' })).toBeTruthy()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/** The whole point. Three buttons, one stop. */
|
|
39
|
+
it('is one Tab stop, whatever it holds', async () => {
|
|
40
|
+
const user = userEvent.setup()
|
|
41
|
+
render(<Strip />)
|
|
42
|
+
screen.getByRole('button', { name: 'Before' }).focus()
|
|
43
|
+
await user.tab()
|
|
44
|
+
expect(focused()).toBe('One')
|
|
45
|
+
await user.tab()
|
|
46
|
+
expect(focused()).toBe('After')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('and coming back lands on the toolbar, not inside it', async () => {
|
|
50
|
+
const user = userEvent.setup()
|
|
51
|
+
render(<Strip />)
|
|
52
|
+
screen.getByRole('button', { name: 'After' }).focus()
|
|
53
|
+
await user.tab({ shift: true })
|
|
54
|
+
expect(focused()).toBe('One')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('the arrow keys walk it, and wrap', async () => {
|
|
58
|
+
const user = userEvent.setup()
|
|
59
|
+
render(<Strip />)
|
|
60
|
+
screen.getByRole('button', { name: 'One' }).focus()
|
|
61
|
+
await user.keyboard('{ArrowRight}')
|
|
62
|
+
expect(focused()).toBe('Two')
|
|
63
|
+
await user.keyboard('{ArrowRight}{ArrowRight}')
|
|
64
|
+
expect(focused()).toBe('One')
|
|
65
|
+
await user.keyboard('{ArrowLeft}')
|
|
66
|
+
expect(focused()).toBe('Three')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The gap, pinned as a gap. Base UI's composite implements Home and End
|
|
71
|
+
* behind `enableHomeAndEndKeys`, and `Toolbar.Root` does not pass it — so
|
|
72
|
+
* they do nothing here, the page does not claim them, and this test fails
|
|
73
|
+
* the day that changes.
|
|
74
|
+
*/
|
|
75
|
+
it('Home and End do nothing, which is Base UI’s choice and is stated', async () => {
|
|
76
|
+
const user = userEvent.setup()
|
|
77
|
+
render(<Strip />)
|
|
78
|
+
screen.getByRole('button', { name: 'Two' }).focus()
|
|
79
|
+
await user.keyboard('{End}')
|
|
80
|
+
expect(focused()).toBe('Two')
|
|
81
|
+
await user.keyboard('{Home}')
|
|
82
|
+
expect(focused()).toBe('Two')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('a vertical toolbar walks with ↑ and ↓', async () => {
|
|
86
|
+
const user = userEvent.setup()
|
|
87
|
+
render(
|
|
88
|
+
<Toolbar aria-label="Formatting" orientation="vertical">
|
|
89
|
+
<ToolbarButton aria-label="One">{dot}</ToolbarButton>
|
|
90
|
+
<ToolbarButton aria-label="Two">{dot}</ToolbarButton>
|
|
91
|
+
</Toolbar>,
|
|
92
|
+
)
|
|
93
|
+
expect(screen.getByRole('toolbar').getAttribute('aria-orientation')).toBe('vertical')
|
|
94
|
+
screen.getByRole('button', { name: 'One' }).focus()
|
|
95
|
+
await user.keyboard('{ArrowDown}')
|
|
96
|
+
expect(focused()).toBe('Two')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('a button still runs its onClick, and is still an IconButton', async () => {
|
|
100
|
+
const user = userEvent.setup()
|
|
101
|
+
const onClick = vi.fn()
|
|
102
|
+
render(
|
|
103
|
+
<Toolbar aria-label="Formatting">
|
|
104
|
+
<ToolbarButton aria-label="One" onClick={onClick}>{dot}</ToolbarButton>
|
|
105
|
+
</Toolbar>,
|
|
106
|
+
)
|
|
107
|
+
const button = screen.getByRole('button', { name: 'One' })
|
|
108
|
+
expect(button.tagName).toBe('BUTTON')
|
|
109
|
+
await user.click(button)
|
|
110
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A strip whose controls come and go from the arrow keys as their state
|
|
115
|
+
* changes is a strip you cannot learn — and a `disabledReason` you cannot
|
|
116
|
+
* reach is a reason nobody reads.
|
|
117
|
+
*/
|
|
118
|
+
it('a disabled control keeps its place in the walk', async () => {
|
|
119
|
+
const user = userEvent.setup()
|
|
120
|
+
render(
|
|
121
|
+
<Toolbar aria-label="Formatting">
|
|
122
|
+
<ToolbarButton aria-label="One">{dot}</ToolbarButton>
|
|
123
|
+
<ToolbarButton aria-label="Two" disabledReason="Not yet">{dot}</ToolbarButton>
|
|
124
|
+
<ToolbarButton aria-label="Three">{dot}</ToolbarButton>
|
|
125
|
+
</Toolbar>,
|
|
126
|
+
)
|
|
127
|
+
screen.getByRole('button', { name: 'One' }).focus()
|
|
128
|
+
await user.keyboard('{ArrowRight}')
|
|
129
|
+
expect(focused()).toBe('Two')
|
|
130
|
+
expect(screen.getByRole('button', { name: 'Two' }).getAttribute('aria-disabled')).toBe('true')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
/** The box, which a floating strip needs and Peek had drawn twice. */
|
|
134
|
+
it('draws the elevated box, and drops it on request', () => {
|
|
135
|
+
const withBox = render(<Toolbar aria-label="Formatting"><ToolbarButton aria-label="One">{dot}</ToolbarButton></Toolbar>)
|
|
136
|
+
const panel = withBox.container.firstElementChild as HTMLElement
|
|
137
|
+
expect(panel.className).toContain('bg-bg-elevated')
|
|
138
|
+
expect(panel.querySelector('[role="toolbar"]')).toBeTruthy()
|
|
139
|
+
// The strip stays a row inside a panel that is a column for a menu's rows.
|
|
140
|
+
expect(panel.querySelector('[role="toolbar"]')!.className).not.toContain('flex-col')
|
|
141
|
+
withBox.unmount()
|
|
142
|
+
|
|
143
|
+
const bare = render(<Toolbar aria-label="Formatting" surface={false}><ToolbarButton aria-label="One">{dot}</ToolbarButton></Toolbar>)
|
|
144
|
+
expect((bare.container.firstElementChild as HTMLElement).getAttribute('role')).toBe('toolbar')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('a separator is a separator, not just a hairline', () => {
|
|
148
|
+
render(
|
|
149
|
+
<Toolbar aria-label="Formatting">
|
|
150
|
+
<ToolbarButton aria-label="One">{dot}</ToolbarButton>
|
|
151
|
+
<ToolbarSeparator />
|
|
152
|
+
<ToolbarButton aria-label="Two">{dot}</ToolbarButton>
|
|
153
|
+
</Toolbar>,
|
|
154
|
+
)
|
|
155
|
+
expect(screen.getByRole('separator')).toBeTruthy()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
/** While a field has focus the arrow keys are the caret's, which is what a
|
|
159
|
+
* field is for; Tab resumes the walk. */
|
|
160
|
+
it('a field in the strip keeps the arrow keys for its caret', async () => {
|
|
161
|
+
const user = userEvent.setup()
|
|
162
|
+
render(
|
|
163
|
+
<Toolbar aria-label="Link">
|
|
164
|
+
<ToolbarButton aria-label="One">{dot}</ToolbarButton>
|
|
165
|
+
<ToolbarInput aria-label="Link address" defaultValue="abc" />
|
|
166
|
+
</Toolbar>,
|
|
167
|
+
)
|
|
168
|
+
const field = screen.getByRole('textbox', { name: 'Link address' }) as HTMLInputElement
|
|
169
|
+
field.focus()
|
|
170
|
+
field.setSelectionRange(3, 3)
|
|
171
|
+
await user.keyboard('{ArrowLeft}')
|
|
172
|
+
expect(document.activeElement).toBe(field)
|
|
173
|
+
expect(field.selectionStart).toBe(2)
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('a loose row, for comparison', () => {
|
|
178
|
+
/** The measurement the component exists to change: the same three buttons
|
|
179
|
+
* outside a toolbar are three Tab stops. */
|
|
180
|
+
it('is one Tab stop per button', async () => {
|
|
181
|
+
const user = userEvent.setup()
|
|
182
|
+
render(
|
|
183
|
+
<>
|
|
184
|
+
<button type="button">Before</button>
|
|
185
|
+
<div className="flex">
|
|
186
|
+
<IconButton aria-label="One">{dot}</IconButton>
|
|
187
|
+
<IconButton aria-label="Two">{dot}</IconButton>
|
|
188
|
+
<IconButton aria-label="Three">{dot}</IconButton>
|
|
189
|
+
</div>
|
|
190
|
+
<button type="button">After</button>
|
|
191
|
+
</>,
|
|
192
|
+
)
|
|
193
|
+
screen.getByRole('button', { name: 'Before' }).focus()
|
|
194
|
+
const walk: (string | null | undefined)[] = []
|
|
195
|
+
for (let i = 0; i < 4; i++) { await user.tab(); walk.push(focused()) }
|
|
196
|
+
expect(walk).toEqual(['One', 'Two', 'Three', 'After'])
|
|
197
|
+
})
|
|
198
|
+
})
|
package/src/Toolbar.tsx
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { ReactNode, Ref } from 'react'
|
|
2
|
+
import { Toolbar as BaseToolbar } from '@base-ui/react/toolbar'
|
|
3
|
+
import { cn } from './cn'
|
|
4
|
+
import { IconButton, type IconButtonProps } from './IconButton'
|
|
5
|
+
import { MenuPanel } from './Menu'
|
|
6
|
+
import { TextInput, type TextInputProps } from './TextInput'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A strip of controls that behaves as **one** control.
|
|
10
|
+
*
|
|
11
|
+
* New at Katerina's request, 2026-09-08, pulled forward from stage 6.
|
|
12
|
+
*
|
|
13
|
+
* **It exists because every strip in the suite is as many Tab stops as it has
|
|
14
|
+
* buttons.** Ship's reaction row, Peek's composer strip and the editor's
|
|
15
|
+
* formatting strip are each a hand-rolled `<div class="flex">` of
|
|
16
|
+
* `IconButton`s: eight buttons, eight stops, and no way to get past them but
|
|
17
|
+
* through them. A toolbar is one stop — Tab in, arrow keys along, Tab out —
|
|
18
|
+
* which is what the `toolbar` role means and what Base UI's part implements.
|
|
19
|
+
*
|
|
20
|
+
* What it is not: a `Menu` (that is a list of *verbs* you open and choose
|
|
21
|
+
* from, and it closes when you do) or a row of unrelated buttons (a form's
|
|
22
|
+
* Cancel and Save are two separate answers, and each deserves its own stop).
|
|
23
|
+
* A toolbar is a set of things you do *to* something that is still there.
|
|
24
|
+
*
|
|
25
|
+
* **It draws the box.** A toolbar floats over what it acts on — a card, a
|
|
26
|
+
* paragraph, an image — so it carries the elevated surface that separates it
|
|
27
|
+
* from that: the same `MenuPanel` a `Menu` draws, because a floating strip and
|
|
28
|
+
* a floating list are the same box. Peek had built this twice and the two had
|
|
29
|
+
* already drifted — `ConversationQuickMenu` is `rounded-sm` with `shadow-sm`
|
|
30
|
+
* and a subtle border, `ReactionPicker` `rounded-lg` with `shadow-lg` and a
|
|
31
|
+
* default one. There is one box now. `surface={false}` for a strip inside
|
|
32
|
+
* something that already draws it.
|
|
33
|
+
*
|
|
34
|
+
* **The gap, stated: Home and End do nothing.** Base UI's composite has them
|
|
35
|
+
* and its `Toolbar` does not switch them on (`enableHomeAndEndKeys`, read in
|
|
36
|
+
* `useCompositeRoot`, not passed by `ToolbarRoot`) — so this page does not
|
|
37
|
+
* claim them. The arrow keys wrap, which reaches both ends in one press of a
|
|
38
|
+
* strip this size.
|
|
39
|
+
*/
|
|
40
|
+
export interface ToolbarProps {
|
|
41
|
+
/**
|
|
42
|
+
* Names the strip for assistive tech — "Formatting", "Reactions". A toolbar
|
|
43
|
+
* is a landmark-ish grouping and is announced as a bare "toolbar" without
|
|
44
|
+
* one, which says nothing when a page has two.
|
|
45
|
+
*/
|
|
46
|
+
'aria-label': string
|
|
47
|
+
/** `horizontal` walks with ← →, `vertical` with ↑ ↓. Default horizontal. */
|
|
48
|
+
orientation?: 'horizontal' | 'vertical'
|
|
49
|
+
/** Whether the arrow keys wrap at the ends. Default true, as Base UI's. */
|
|
50
|
+
loopFocus?: boolean
|
|
51
|
+
/**
|
|
52
|
+
* The elevated box around the strip. **On by default** — a toolbar floats,
|
|
53
|
+
* and this is what floating looks like here.
|
|
54
|
+
*
|
|
55
|
+
* Off for a strip inside a surface that already draws one: a `Popover`, a
|
|
56
|
+
* dialog, a card's own panel. Two boxes inside each other is the tell.
|
|
57
|
+
*/
|
|
58
|
+
surface?: boolean
|
|
59
|
+
children: ReactNode
|
|
60
|
+
/** The row's own layout — its gap, its padding, its wrapping. */
|
|
61
|
+
className?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function Toolbar({ 'aria-label': ariaLabel, orientation = 'horizontal', loopFocus = true, surface = true, children, className }: ToolbarProps) {
|
|
65
|
+
const strip = (
|
|
66
|
+
<BaseToolbar.Root
|
|
67
|
+
aria-label={ariaLabel}
|
|
68
|
+
orientation={orientation}
|
|
69
|
+
loopFocus={loopFocus}
|
|
70
|
+
className={cn('flex items-center gap-1', orientation === 'vertical' && 'flex-col', className)}
|
|
71
|
+
>
|
|
72
|
+
{children}
|
|
73
|
+
</BaseToolbar.Root>
|
|
74
|
+
)
|
|
75
|
+
if (!surface) return strip
|
|
76
|
+
/*
|
|
77
|
+
The box WRAPS the strip rather than being composed onto it. `MenuPanel` is
|
|
78
|
+
a flex COLUMN — right for a menu's rows, wrong for a row of controls — and
|
|
79
|
+
Tailwind emits `flex-col` after `flex-row`, so a merged class list would
|
|
80
|
+
stand the toolbar on its end whatever order the classes arrived in. One
|
|
81
|
+
extra element, and the box keeps its single definition.
|
|
82
|
+
|
|
83
|
+
`p-1` is the tighter of Peek's two paddings and the right one for a strip
|
|
84
|
+
of 24px controls; `MenuPanel`'s own `p-2` is a menu's, where the rows run
|
|
85
|
+
the full width.
|
|
86
|
+
*/
|
|
87
|
+
return <MenuPanel className="w-fit p-1">{strip}</MenuPanel>
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One control in the strip. It is this package's `IconButton` — the same
|
|
92
|
+
* square, the same variants, the same `tooltip` and `disabledReason` — joined
|
|
93
|
+
* to the toolbar's roving focus.
|
|
94
|
+
*
|
|
95
|
+
* A disabled button **keeps its place in the walk**: a strip whose controls
|
|
96
|
+
* come and go from the arrow keys as their state changes is a strip you cannot
|
|
97
|
+
* learn. It is also what lets a `disabledReason` be read.
|
|
98
|
+
*
|
|
99
|
+
* **It must be inside a `Toolbar`.** Base UI throws otherwise —
|
|
100
|
+
* "ToolbarRootContext is missing" — because a part with no strip has no walk
|
|
101
|
+
* to join. A control beside a strip is an `IconButton`.
|
|
102
|
+
*/
|
|
103
|
+
export interface ToolbarButtonProps extends IconButtonProps {
|
|
104
|
+
ref?: Ref<HTMLButtonElement>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function ToolbarButton({ ref, disabled, disabledReason, ...props }: ToolbarButtonProps) {
|
|
108
|
+
return (
|
|
109
|
+
<BaseToolbar.Button
|
|
110
|
+
ref={ref}
|
|
111
|
+
/*
|
|
112
|
+
The part has to be told, not only the button inside it. The toolbar
|
|
113
|
+
keeps its own map of which items are disabled — it is what decides
|
|
114
|
+
where an arrow key lands — and a `Toolbar.Button` that renders a
|
|
115
|
+
disabled `IconButton` without saying so leaves the two disagreeing:
|
|
116
|
+
measured, the element came out `aria-disabled="false"` over a button
|
|
117
|
+
that was disabled.
|
|
118
|
+
*/
|
|
119
|
+
disabled={disabled || !!disabledReason}
|
|
120
|
+
/* Keep it in the walk either way. A strip whose controls come and go
|
|
121
|
+
from the arrow keys as their state changes is a strip you cannot
|
|
122
|
+
learn, and a `disabledReason` you cannot reach is a reason nobody
|
|
123
|
+
reads. */
|
|
124
|
+
focusableWhenDisabled
|
|
125
|
+
render={<IconButton disabled={disabled} disabledReason={disabledReason} {...props} />}
|
|
126
|
+
/>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A text field inside the strip — the link editor in a selection toolbar.
|
|
132
|
+
*
|
|
133
|
+
* The arrow keys belong to the field while it has focus, which is the whole
|
|
134
|
+
* point: ← and → move the caret. The toolbar's walk resumes at Tab. That is
|
|
135
|
+
* Base UI's `Toolbar.Input`, and it is why a field in a toolbar cannot just be
|
|
136
|
+
* a `TextInput` dropped in the row.
|
|
137
|
+
*/
|
|
138
|
+
export type ToolbarInputProps = TextInputProps
|
|
139
|
+
|
|
140
|
+
export function ToolbarInput(props: ToolbarInputProps) {
|
|
141
|
+
return <BaseToolbar.Input render={<TextInput />} {...props} />
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The hairline between two groups of controls. It is `Divider`'s rule and
|
|
146
|
+
* `Toolbar.Separator`'s role, so a screen reader hears the grouping a sighted
|
|
147
|
+
* reader sees.
|
|
148
|
+
*/
|
|
149
|
+
export function ToolbarSeparator({ className }: { className?: string }) {
|
|
150
|
+
return (
|
|
151
|
+
<BaseToolbar.Separator
|
|
152
|
+
orientation="vertical"
|
|
153
|
+
/* `shrink-0` on a hairline in a flex row, always: without it a 1px rule
|
|
154
|
+
renders at 0px and nobody notices (the package's own trap table). */
|
|
155
|
+
className={cn('mx-1 h-4 w-px shrink-0 bg-border-default', className)}
|
|
156
|
+
/>
|
|
157
|
+
)
|
|
158
|
+
}
|
package/src/Tooltip.mdx
CHANGED
|
@@ -6,8 +6,7 @@ import * as TooltipStories from './Tooltip.stories'
|
|
|
6
6
|
# Tooltip
|
|
7
7
|
|
|
8
8
|
A 30px elevated pill with a caption, and **WithTooltip**, the wrapper that
|
|
9
|
-
shows it
|
|
10
|
-
viewport, kept 8px inside its edges.
|
|
9
|
+
shows it beside a trigger — on hover, and on keyboard focus.
|
|
11
10
|
|
|
12
11
|
<Canvas of={TooltipStories.OnHoverTop} />
|
|
13
12
|
|
|
@@ -32,7 +31,7 @@ viewport, kept 8px inside its edges.
|
|
|
32
31
|
- Anything the person must read to proceed → put it in the surface, not
|
|
33
32
|
behind a hover.
|
|
34
33
|
- Anything interactive inside → **Menu** or **DialogShell**; a tooltip is
|
|
35
|
-
`pointer-events: none` by design.
|
|
34
|
+
`pointer-events: none` by design and cannot be pointed at.
|
|
36
35
|
|
|
37
36
|
## How
|
|
38
37
|
|
|
@@ -48,13 +47,52 @@ import { WithTooltip } from '@estiva-app/ui'
|
|
|
48
47
|
shrinks nothing, so a wrapped block collapses to its content width.
|
|
49
48
|
- A truncating label keeps truncating if you pass
|
|
50
49
|
`wrapperClassName="min-w-0 shrink"`.
|
|
51
|
-
-
|
|
52
|
-
|
|
50
|
+
- **`Button` and `IconButton` need no wrapper**: pass `tooltip` (or
|
|
51
|
+
`disabledReason`) and the button itself becomes the trigger. That is what
|
|
52
|
+
lets a `Dialog.Close` or a `Menu.Trigger` be one of them — the component's
|
|
53
|
+
root is the `<button>`, with nothing in between.
|
|
53
54
|
- `shortcut` renders, it does not format. A modifier is called Cmd on Apple
|
|
54
55
|
platforms and Ctrl elsewhere, and only the caller knows which it is
|
|
55
56
|
looking at — so pass the finished label. `IconButton` forwards its own
|
|
56
57
|
`tooltipShortcut` here.
|
|
57
58
|
|
|
59
|
+
### The delay, and why a toolbar only pauses once
|
|
60
|
+
|
|
61
|
+
A tooltip waits **300ms** before it opens. Mount **one `TooltipProvider` at
|
|
62
|
+
the top of the app** and every tooltip below it shares that delay: the first
|
|
63
|
+
waits, and while the group stays warm the neighbours open as the pointer
|
|
64
|
+
arrives — so reading along a row of icon buttons is one pause, not one per
|
|
65
|
+
button. Without the provider nothing breaks; each simply waits its own 300ms.
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
import { TooltipProvider } from '@estiva-app/ui'
|
|
69
|
+
|
|
70
|
+
<TooltipProvider>
|
|
71
|
+
<App />
|
|
72
|
+
</TooltipProvider>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
<Canvas of={TooltipStories.InAToolbar} />
|
|
76
|
+
|
|
77
|
+
### The motion
|
|
78
|
+
|
|
79
|
+
It fades in over **120ms** and travels **4px** away from the trigger — up
|
|
80
|
+
from below when it stands above a control, down from above when it hangs
|
|
81
|
+
beneath one — and fades out over **80ms**. Moving between triggers inside a
|
|
82
|
+
warm group skips the animation entirely, so a toolbar sweep does not flicker.
|
|
83
|
+
`prefers-reduced-motion` removes it.
|
|
84
|
+
|
|
85
|
+
## Keys
|
|
86
|
+
|
|
87
|
+
| Input | What happens |
|
|
88
|
+
|---|---|
|
|
89
|
+
| pointer rests on the trigger | opens after 300ms — instantly if a neighbour's tooltip was just up |
|
|
90
|
+
| pointer leaves | closes |
|
|
91
|
+
| Tab onto the control | opens, with no delay |
|
|
92
|
+
| Tab away | closes |
|
|
93
|
+
| Escape while it is up | closes |
|
|
94
|
+
| a click on the trigger | closes it — the tooltip has said its piece |
|
|
95
|
+
|
|
58
96
|
## Props
|
|
59
97
|
|
|
60
98
|
<Controls of={TooltipStories.Default} />
|
package/src/Tooltip.stories.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconArchive, IconPin, IconTrash } from '@tabler/icons-react'
|
|
2
3
|
import { Button } from './Button'
|
|
4
|
+
import { IconButton } from './IconButton'
|
|
3
5
|
import { Tooltip, WithTooltip } from './Tooltip'
|
|
4
6
|
|
|
5
7
|
const meta = {
|
|
@@ -60,3 +62,27 @@ export const OnADisabledControl: Story = {
|
|
|
60
62
|
</WithTooltip>
|
|
61
63
|
),
|
|
62
64
|
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A row of controls, which is where the shared delay shows itself: the first
|
|
69
|
+
* tooltip waits 300ms, and while the group stays warm the neighbours open as
|
|
70
|
+
* the pointer arrives — and the pill moves between them without re-animating.
|
|
71
|
+
* An app gets this by mounting one `TooltipProvider` at its root.
|
|
72
|
+
*/
|
|
73
|
+
export const InAToolbar: Story = {
|
|
74
|
+
parameters: { controls: { disable: true } },
|
|
75
|
+
render: () => (
|
|
76
|
+
<div className="flex items-center gap-1 rounded-lg border border-border-default bg-bg-elevated p-1">
|
|
77
|
+
<IconButton aria-label="Pin" tooltip="Pin">
|
|
78
|
+
<IconPin size={16} stroke={1.5} />
|
|
79
|
+
</IconButton>
|
|
80
|
+
<IconButton aria-label="Archive" tooltip="Archive">
|
|
81
|
+
<IconArchive size={16} stroke={1.5} />
|
|
82
|
+
</IconButton>
|
|
83
|
+
<IconButton aria-label="Delete" tooltip="Delete" tooltipShortcut="Del">
|
|
84
|
+
<IconTrash size={16} stroke={1.5} />
|
|
85
|
+
</IconButton>
|
|
86
|
+
</div>
|
|
87
|
+
),
|
|
88
|
+
}
|