@estiva-app/ui 0.9.0 → 0.10.1
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 +888 -763
- package/dist/index.js.map +4 -4
- package/dist/triggerDisabled.d.ts +10 -0
- package/dist/triggerDisabled.d.ts.map +1 -0
- 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 +144 -26
- package/src/Field.tsx +94 -59
- 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 +347 -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 +156 -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 +253 -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/src/triggerDisabled.ts +13 -0
- package/stories/Choosing.mdx +8 -3
- package/tailwind-preset.js +7 -0
- package/src/Menu.fit.test.ts +0 -90
- package/src/Select.fit.test.ts +0 -101
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the Tooltip page claims, pinned.
|
|
4
|
+
*
|
|
5
|
+
* The page said "it shows on hover only — there is no focus or touch trigger"
|
|
6
|
+
* for as long as the component was hand-written, and that sentence was the
|
|
7
|
+
* reason `disabledReason` could not be read without a mouse. Stage 4 put it on
|
|
8
|
+
* Base UI's `Tooltip`; these are the tests that let the sentence be deleted.
|
|
9
|
+
*
|
|
10
|
+
* The motion (D26) is not here: jsdom runs no transitions, so it is proved in
|
|
11
|
+
* a browser instead and the numbers live in the page.
|
|
12
|
+
*/
|
|
13
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
14
|
+
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
|
15
|
+
import userEvent from '@testing-library/user-event'
|
|
16
|
+
import { IconSquareRounded } from '@tabler/icons-react'
|
|
17
|
+
import { Tooltip, TooltipProvider, WithTooltip } from './Tooltip'
|
|
18
|
+
import { Button } from './Button'
|
|
19
|
+
import { IconButton } from './IconButton'
|
|
20
|
+
|
|
21
|
+
afterEach(cleanup)
|
|
22
|
+
|
|
23
|
+
describe('Tooltip, the surface', () => {
|
|
24
|
+
it('is a tooltip, and draws the shortcut after the label', () => {
|
|
25
|
+
render(<Tooltip label="Bold" shortcut="Cmd+B" />)
|
|
26
|
+
const tip = screen.getByRole('tooltip')
|
|
27
|
+
expect(tip.textContent).toBe('BoldCmd+B')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('takes a caller class without losing its own', () => {
|
|
31
|
+
render(<Tooltip label="Bold" className="w-40" />)
|
|
32
|
+
const tip = screen.getByRole('tooltip')
|
|
33
|
+
expect(tip.className).toContain('w-40')
|
|
34
|
+
expect(tip.className).toContain('bg-bg-elevated')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('WithTooltip', () => {
|
|
39
|
+
it('wraps its child in the inline-flex wrapper, and the child is untouched', () => {
|
|
40
|
+
const { container } = render(
|
|
41
|
+
<WithTooltip label="Add to starred">
|
|
42
|
+
<button type="button">Star</button>
|
|
43
|
+
</WithTooltip>,
|
|
44
|
+
)
|
|
45
|
+
const wrapper = container.firstElementChild as HTMLElement
|
|
46
|
+
expect(wrapper.tagName).toBe('DIV')
|
|
47
|
+
expect(wrapper.className).toContain('inline-flex')
|
|
48
|
+
expect(wrapper.className).toContain('shrink-0')
|
|
49
|
+
expect(wrapper.firstElementChild?.tagName).toBe('BUTTON')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('takes wrapperClassName, which is what keeps a truncating label truncating', () => {
|
|
53
|
+
const { container } = render(
|
|
54
|
+
<WithTooltip label="A long name" wrapperClassName="min-w-0 shrink">
|
|
55
|
+
<span className="truncate">A long name</span>
|
|
56
|
+
</WithTooltip>,
|
|
57
|
+
)
|
|
58
|
+
expect((container.firstElementChild as HTMLElement).className).toContain('min-w-0')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('opens on hover and closes when the pointer leaves', async () => {
|
|
62
|
+
const user = userEvent.setup()
|
|
63
|
+
render(
|
|
64
|
+
<WithTooltip label="Add to starred">
|
|
65
|
+
<button type="button">Star</button>
|
|
66
|
+
</WithTooltip>,
|
|
67
|
+
)
|
|
68
|
+
expect(screen.queryByRole('tooltip')).toBeNull()
|
|
69
|
+
await user.hover(screen.getByRole('button', { name: 'Star' }))
|
|
70
|
+
// The 300ms wait is D23; it was instant before.
|
|
71
|
+
expect((await screen.findByRole('tooltip')).textContent).toBe('Add to starred')
|
|
72
|
+
await user.unhover(screen.getByRole('button', { name: 'Star' }))
|
|
73
|
+
await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull())
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('closes on Escape while it is up', async () => {
|
|
77
|
+
const user = userEvent.setup()
|
|
78
|
+
render(
|
|
79
|
+
<WithTooltip label="Add to starred">
|
|
80
|
+
<button type="button">Star</button>
|
|
81
|
+
</WithTooltip>,
|
|
82
|
+
)
|
|
83
|
+
await user.tab()
|
|
84
|
+
expect(await screen.findByRole('tooltip')).toBeTruthy()
|
|
85
|
+
await user.keyboard('{Escape}')
|
|
86
|
+
await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull())
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('a click on the trigger closes it — the tooltip has said its piece', async () => {
|
|
90
|
+
const user = userEvent.setup()
|
|
91
|
+
render(
|
|
92
|
+
<WithTooltip label="Add to starred">
|
|
93
|
+
<button type="button">Star</button>
|
|
94
|
+
</WithTooltip>,
|
|
95
|
+
)
|
|
96
|
+
const star = screen.getByRole('button', { name: 'Star' })
|
|
97
|
+
await user.hover(star)
|
|
98
|
+
expect(await screen.findByRole('tooltip')).toBeTruthy()
|
|
99
|
+
await user.click(star)
|
|
100
|
+
await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull())
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
/** The gap the page wrote down about itself, and the reason Tooltip went first. */
|
|
104
|
+
it('opens when the control inside it is reached by keyboard', async () => {
|
|
105
|
+
const user = userEvent.setup()
|
|
106
|
+
render(
|
|
107
|
+
<WithTooltip label="Add to starred">
|
|
108
|
+
<button type="button">Star</button>
|
|
109
|
+
</WithTooltip>,
|
|
110
|
+
)
|
|
111
|
+
await user.tab()
|
|
112
|
+
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Star' }))
|
|
113
|
+
expect((await screen.findByRole('tooltip')).textContent).toBe('Add to starred')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('closes again when focus moves on', async () => {
|
|
117
|
+
const user = userEvent.setup()
|
|
118
|
+
render(
|
|
119
|
+
<>
|
|
120
|
+
<WithTooltip label="Add to starred">
|
|
121
|
+
<button type="button">Star</button>
|
|
122
|
+
</WithTooltip>
|
|
123
|
+
<button type="button">Next</button>
|
|
124
|
+
</>,
|
|
125
|
+
)
|
|
126
|
+
await user.tab()
|
|
127
|
+
expect(await screen.findByRole('tooltip')).toBeTruthy()
|
|
128
|
+
await user.tab()
|
|
129
|
+
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Next' }))
|
|
130
|
+
await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull())
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('a control that IS its own trigger', () => {
|
|
135
|
+
/**
|
|
136
|
+
* The finding stage 3 ended on: an `IconButton` carrying a tooltip returned
|
|
137
|
+
* `WithTooltip`'s wrapper `<div>` as its root, so a `Dialog.Close` composed
|
|
138
|
+
* onto the wrapper instead of the button. The root is the button now.
|
|
139
|
+
*/
|
|
140
|
+
it('IconButton with a tooltip has the button as its root, not a wrapper', () => {
|
|
141
|
+
const { container } = render(
|
|
142
|
+
<IconButton aria-label="Settings" tooltip="Settings">
|
|
143
|
+
<IconSquareRounded size={16} stroke={1.5} />
|
|
144
|
+
</IconButton>,
|
|
145
|
+
)
|
|
146
|
+
const root = container.firstElementChild as HTMLElement
|
|
147
|
+
expect(root.tagName).toBe('BUTTON')
|
|
148
|
+
expect(root).toBe(screen.getByRole('button', { name: 'Settings' }))
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('Button with a disabledReason has the button as its root too', () => {
|
|
152
|
+
const { container } = render(<Button disabledReason="Sign in first">Add</Button>)
|
|
153
|
+
expect((container.firstElementChild as HTMLElement).tagName).toBe('BUTTON')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
/** Disabled-with-a-reason must still be hoverable, or the reason never shows:
|
|
157
|
+
* `pointer-events-none` is dropped in that one case and Base UI swallows the
|
|
158
|
+
* click instead. */
|
|
159
|
+
it('a disabled-with-a-reason button keeps its pointer events, and its click does nothing', async () => {
|
|
160
|
+
const user = userEvent.setup()
|
|
161
|
+
let clicks = 0
|
|
162
|
+
render(
|
|
163
|
+
<Button disabledReason="Sign in first" onClick={() => { clicks += 1 }}>
|
|
164
|
+
Add
|
|
165
|
+
</Button>,
|
|
166
|
+
)
|
|
167
|
+
const button = screen.getByRole('button', { name: 'Add' })
|
|
168
|
+
expect(button.className).not.toContain('pointer-events-none')
|
|
169
|
+
await user.click(button)
|
|
170
|
+
expect(clicks).toBe(0)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('a plainly disabled button keeps pointer-events-none', () => {
|
|
174
|
+
render(<Button disabled>Add</Button>)
|
|
175
|
+
expect(screen.getByRole('button', { name: 'Add' }).className).toContain('pointer-events-none')
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('TooltipProvider', () => {
|
|
180
|
+
/** The grouping itself is a matter of timers in a real browser; what jsdom
|
|
181
|
+
* can say is that a tooltip under a provider behaves the same as one
|
|
182
|
+
* without, which is the promise the page makes to an app that forgets it. */
|
|
183
|
+
it('changes nothing about whether a tooltip opens', async () => {
|
|
184
|
+
const user = userEvent.setup()
|
|
185
|
+
render(
|
|
186
|
+
<TooltipProvider>
|
|
187
|
+
<WithTooltip label="Add to starred">
|
|
188
|
+
<button type="button">Star</button>
|
|
189
|
+
</WithTooltip>
|
|
190
|
+
</TooltipProvider>,
|
|
191
|
+
)
|
|
192
|
+
await user.hover(screen.getByRole('button', { name: 'Star' }))
|
|
193
|
+
expect((await screen.findByRole('tooltip')).textContent).toBe('Add to starred')
|
|
194
|
+
})
|
|
195
|
+
})
|
package/src/Tooltip.tsx
CHANGED
|
@@ -1,29 +1,72 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import type { ComponentPropsWithRef, ReactElement, ReactNode } from 'react'
|
|
2
|
+
import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip'
|
|
3
3
|
import { cn } from './cn'
|
|
4
4
|
import { Kbd } from './Kbd'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Peek's Tooltip and WithTooltip (2026-08-28)
|
|
7
|
+
* Peek's Tooltip and WithTooltip (2026-08-28) — the same 30px elevated pill,
|
|
8
|
+
* on Base UI's `Tooltip` since stage 4 of the migration (2026-09-07).
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* **What that fixed, and this page used to say it did not: it shows on
|
|
11
|
+
* keyboard focus.** `WithTooltip` was hover-only, which meant the reason a
|
|
12
|
+
* disabled control gives — the whole point of `disabledReason` — could not be
|
|
13
|
+
* read without a mouse. Base UI opens it on `:focus-visible` too, so tabbing
|
|
14
|
+
* to a control says what it is.
|
|
15
|
+
*
|
|
16
|
+
* Gone with it: the `createPortal`, the `getBoundingClientRect` arithmetic and
|
|
17
|
+
* the two clamps this file kept against the viewport's edges. Floating UI
|
|
18
|
+
* places and flips it now.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How long the pointer must rest on a trigger before the tooltip appears
|
|
23
|
+
* (Katerina, D23, 2026-09-07). It was instant, which flashed a pill per
|
|
24
|
+
* button when sweeping a toolbar; 300ms is long enough to be a decision and
|
|
25
|
+
* short enough not to feel like waiting.
|
|
26
|
+
*
|
|
27
|
+
* Inside a `TooltipProvider` the *neighbours* then open instantly, so a row
|
|
28
|
+
* of icon buttons pauses once and reads freely after that.
|
|
29
|
+
*/
|
|
30
|
+
const OPEN_DELAY = 300
|
|
31
|
+
|
|
32
|
+
export interface TooltipProviderProps {
|
|
33
|
+
/** Before the first tooltip in the group opens. Default 300ms (D23). */
|
|
34
|
+
delay?: number
|
|
35
|
+
/** Before one closes. Default: immediately, as it always has. */
|
|
36
|
+
closeDelay?: number
|
|
37
|
+
/** How long after one closes its neighbours still open instantly. */
|
|
38
|
+
timeout?: number
|
|
39
|
+
children: ReactNode
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Mount once at the top of an app: it makes every tooltip below it share one
|
|
44
|
+
* delay, so the second and third open instantly while the group is warm.
|
|
45
|
+
*
|
|
46
|
+
* Without it each tooltip still waits its own 300ms — nothing breaks, the row
|
|
47
|
+
* simply pauses on every button instead of once.
|
|
14
48
|
*/
|
|
15
|
-
export
|
|
49
|
+
export function TooltipProvider({ delay = OPEN_DELAY, closeDelay, timeout, children }: TooltipProviderProps) {
|
|
50
|
+
return (
|
|
51
|
+
<BaseTooltip.Provider delay={delay} closeDelay={closeDelay} timeout={timeout}>
|
|
52
|
+
{children}
|
|
53
|
+
</BaseTooltip.Provider>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `ComponentPropsWithRef` because the pill is what `Tooltip.Popup` renders —
|
|
58
|
+
* Base UI merges its own props and ref into this element, so both must land. */
|
|
59
|
+
export interface TooltipProps extends Omit<ComponentPropsWithRef<'div'>, 'children'> {
|
|
16
60
|
label: string
|
|
17
61
|
/** The key that does the same thing, drawn as the `Kbd` chip after the label.
|
|
18
62
|
* Pass it already formatted for the platform — this renders, it does not
|
|
19
63
|
* decide whether the modifier is a glyph or a word. */
|
|
20
64
|
shortcut?: string
|
|
21
|
-
className?: string
|
|
22
65
|
}
|
|
23
66
|
|
|
24
|
-
export function Tooltip({ label, shortcut, className }: TooltipProps) {
|
|
67
|
+
export function Tooltip({ label, shortcut, className, ...props }: TooltipProps) {
|
|
25
68
|
return (
|
|
26
|
-
<div role="tooltip" className={cn('bg-bg-elevated border border-border-default rounded-lg h-[30px] flex items-center justify-center gap-1.5 px-2 shadow-lg', className)}>
|
|
69
|
+
<div role="tooltip" className={cn('bg-bg-elevated border border-border-default rounded-lg h-[30px] flex items-center justify-center gap-1.5 px-2 shadow-lg', className)} {...props}>
|
|
27
70
|
<span className="text-caption text-text-primary whitespace-nowrap">{label}</span>
|
|
28
71
|
{shortcut && <Kbd>{shortcut}</Kbd>}
|
|
29
72
|
</div>
|
|
@@ -40,47 +83,106 @@ export interface WithTooltipProps {
|
|
|
40
83
|
children: ReactNode
|
|
41
84
|
}
|
|
42
85
|
|
|
86
|
+
/** The 6px between the trigger and the pill, and the 8px it keeps clear of
|
|
87
|
+
* every viewport edge — the two numbers the deleted arithmetic used. */
|
|
43
88
|
const GAP = 6
|
|
44
89
|
const VIEWPORT_PAD = 8
|
|
45
90
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
91
|
+
/**
|
|
92
|
+
* The portalled surface: everything after the trigger, written once because
|
|
93
|
+
* `WithTooltip` and the two buttons all need it.
|
|
94
|
+
*
|
|
95
|
+
* `pointer-events-none` and `disableHoverablePopup` together keep the pill
|
|
96
|
+
* untouchable, as it has always been — a tooltip is not somewhere you can put
|
|
97
|
+
* the pointer, and anything you must reach belongs in a Menu or a dialog.
|
|
98
|
+
*/
|
|
99
|
+
function TooltipSurface({ label, shortcut, placement }: { label: string; shortcut?: string; placement: 'top' | 'bottom' }) {
|
|
100
|
+
return (
|
|
101
|
+
<BaseTooltip.Portal>
|
|
102
|
+
<BaseTooltip.Positioner
|
|
103
|
+
side={placement}
|
|
104
|
+
align="center"
|
|
105
|
+
sideOffset={GAP}
|
|
106
|
+
collisionPadding={VIEWPORT_PAD}
|
|
107
|
+
// `fixed`, as the hand-written one was: the pill is placed against the
|
|
108
|
+
// viewport, so a scroll container between it and the body cannot drag
|
|
109
|
+
// it out of place.
|
|
110
|
+
positionMethod="fixed"
|
|
111
|
+
className="pointer-events-none z-[9999]"
|
|
112
|
+
>
|
|
113
|
+
<BaseTooltip.Popup
|
|
114
|
+
render={
|
|
115
|
+
<Tooltip
|
|
116
|
+
label={label}
|
|
117
|
+
shortcut={shortcut}
|
|
118
|
+
/*
|
|
119
|
+
* The motion (Katerina, D26, 2026-09-07). It had none — it was
|
|
120
|
+
* mounted and instantly there — and Base UI is what makes one
|
|
121
|
+
* possible: `data-starting-style` on the opening frame,
|
|
122
|
+
* `data-ending-style` while it closes, and the popup is kept in
|
|
123
|
+
* the DOM until the transition finishes, which is the half a
|
|
124
|
+
* hand-written tooltip cannot do.
|
|
125
|
+
*
|
|
126
|
+
* It fades and travels 4px away from the trigger: from below
|
|
127
|
+
* when it stands above the control (`data-side=top`), from above
|
|
128
|
+
* when it hangs beneath it. 120ms in, 80ms out.
|
|
129
|
+
*
|
|
130
|
+
* `data-instant` is Base UI saying skip it — the pointer moved
|
|
131
|
+
* to a neighbouring trigger inside the warm delay group, or the
|
|
132
|
+
* tooltip was dismissed. Sweeping a toolbar therefore moves the
|
|
133
|
+
* pill without re-animating it, which is the whole point of the
|
|
134
|
+
* shared delay (D23).
|
|
135
|
+
*/
|
|
136
|
+
className={cn(
|
|
137
|
+
'transition-[opacity,transform] duration-[120ms] ease-out motion-reduce:transition-none',
|
|
138
|
+
'data-[ending-style]:duration-[80ms] data-[instant]:duration-0',
|
|
139
|
+
'data-[starting-style]:opacity-0 data-[ending-style]:opacity-0',
|
|
140
|
+
'data-[side=top]:data-[starting-style]:translate-y-[4px] data-[side=top]:data-[ending-style]:translate-y-[4px]',
|
|
141
|
+
'data-[side=bottom]:data-[starting-style]:-translate-y-[4px] data-[side=bottom]:data-[ending-style]:-translate-y-[4px]',
|
|
142
|
+
)}
|
|
143
|
+
/>
|
|
144
|
+
}
|
|
145
|
+
/>
|
|
146
|
+
</BaseTooltip.Positioner>
|
|
147
|
+
</BaseTooltip.Portal>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
69
150
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
151
|
+
export function WithTooltip({ label, shortcut, placement = 'top', wrapperClassName, children }: WithTooltipProps) {
|
|
152
|
+
return (
|
|
153
|
+
<BaseTooltip.Root disableHoverablePopup>
|
|
154
|
+
{/* The wrapper stays the trigger: `WithTooltip` wraps whatever it is
|
|
155
|
+
given — a raw button, a span, a Chip — and only an element of its own
|
|
156
|
+
can carry the handlers for all of them. A control that IS the trigger
|
|
157
|
+
(Button, IconButton) composes the part onto itself instead, which is
|
|
158
|
+
what lets a `Dialog.Close` or a `Menu.Trigger` be one of those. */}
|
|
159
|
+
<BaseTooltip.Trigger delay={OPEN_DELAY} render={<div className={cn('inline-flex shrink-0', wrapperClassName)} />}>
|
|
160
|
+
{children}
|
|
161
|
+
</BaseTooltip.Trigger>
|
|
162
|
+
<TooltipSurface label={label} shortcut={shortcut} placement={placement} />
|
|
163
|
+
</BaseTooltip.Root>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
73
166
|
|
|
167
|
+
/**
|
|
168
|
+
* The tooltip a control wears itself, rather than through a wrapper.
|
|
169
|
+
*
|
|
170
|
+
* `Button` and `IconButton` call this: the Base UI button element becomes the
|
|
171
|
+
* `Tooltip.Trigger`, so the component's root stays the `<button>`. That is
|
|
172
|
+
* what stage 3 ran into and could not fix — an `IconButton` with a tooltip
|
|
173
|
+
* returned the wrapper `<div>`, so a `Dialog.Close` composed onto the wrapper
|
|
174
|
+
* and the ✕ could never be the part.
|
|
175
|
+
*
|
|
176
|
+
* Internal: the two buttons' `tooltip` / `disabledReason` props are the API.
|
|
177
|
+
*/
|
|
178
|
+
export function TooltipTrigger({ label, shortcut, placement = 'top', children }: { label: string; shortcut?: string; placement?: 'top' | 'bottom'; children: ReactElement }) {
|
|
74
179
|
return (
|
|
75
|
-
<
|
|
76
|
-
{children
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
document.body,
|
|
83
|
-
)}
|
|
84
|
-
</div>
|
|
180
|
+
<BaseTooltip.Root disableHoverablePopup>
|
|
181
|
+
{/* `children` is the control itself, so it is what the part renders —
|
|
182
|
+
a `ReactElement`, not a `ReactNode`, because `render` takes one
|
|
183
|
+
element and there is nothing sensible to do with two. */}
|
|
184
|
+
<BaseTooltip.Trigger delay={OPEN_DELAY} render={children} />
|
|
185
|
+
<TooltipSurface label={label} shortcut={shortcut} placement={placement} />
|
|
186
|
+
</BaseTooltip.Root>
|
|
85
187
|
)
|
|
86
188
|
}
|
package/src/cn.ts
CHANGED
|
@@ -28,7 +28,7 @@ const FONT_SIZE_TOKENS = [
|
|
|
28
28
|
* `shadow-glow-warning` beside `shadow-md` kept both (measured 2026-09-06).
|
|
29
29
|
* `cn.test.ts` pins these to the preset too.
|
|
30
30
|
*/
|
|
31
|
-
const BOX_SHADOW_TOKENS = ['sm', 'md', 'lg', 'focus-ring', 'glow-warning', 'highlight-inset']
|
|
31
|
+
const BOX_SHADOW_TOKENS = ['sm', 'md', 'lg', 'focus-ring', 'glow-warning', 'glow-success', 'glow-accent', 'highlight-inset']
|
|
32
32
|
const DROP_SHADOW_TOKENS = ['glow-success', 'glow-accent']
|
|
33
33
|
|
|
34
34
|
const twMerge = extendTailwindMerge({
|
package/src/fit.test.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { fitMenu } from './fit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The last hand-written geometry in the package, and its one remaining
|
|
6
|
+
* caller's.
|
|
7
|
+
*
|
|
8
|
+
* These were the Menu shell's tests (Katerina, 2026-09-03: the identity menu
|
|
9
|
+
* vanished under a z-indexed panel header, and a reply menu's highlight
|
|
10
|
+
* submenu was cut off by the right screen edge — its hand-rolled flip read a
|
|
11
|
+
* ref that was never attached, so it measured nothing). Stage 4 gave that job
|
|
12
|
+
* to Floating UI, so the `clampBox` and `fitSubmenu` blocks are gone with the
|
|
13
|
+
* functions, and what is left is `fitMenu` as **`ChipInput`** uses it: a
|
|
14
|
+
* suggestion list under a text field, capped at 240.
|
|
15
|
+
*
|
|
16
|
+
* When `ChipInput` moves onto Base UI's `Combobox` at stage 5, this file and
|
|
17
|
+
* `fit.ts` go together.
|
|
18
|
+
*/
|
|
19
|
+
const viewport = { width: 1280, height: 800 }
|
|
20
|
+
/** ChipInput's call: 48px rows, and the old `max-h-[240px]`. */
|
|
21
|
+
const fitList = (args: Omit<Parameters<typeof fitMenu>[0], 'cap'>) => fitMenu({ ...args, cap: 240 })
|
|
22
|
+
|
|
23
|
+
describe('fitMenu, as ChipInput calls it', () => {
|
|
24
|
+
it('leaves a comfortable list exactly where the field put it', () => {
|
|
25
|
+
const fit = fitList({
|
|
26
|
+
anchor: { left: 100, top: 200, bottom: 232 },
|
|
27
|
+
menu: { width: 240, contentHeight: 144 },
|
|
28
|
+
viewport,
|
|
29
|
+
})
|
|
30
|
+
expect(fit.left).toBe(100)
|
|
31
|
+
expect(fit.top).toBe(236)
|
|
32
|
+
expect(fit.bottom).toBeUndefined()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('clamps a list that would run off the right edge', () => {
|
|
36
|
+
const fit = fitList({
|
|
37
|
+
anchor: { left: 1200, top: 200, bottom: 232 },
|
|
38
|
+
menu: { width: 340, contentHeight: 144 },
|
|
39
|
+
viewport,
|
|
40
|
+
})
|
|
41
|
+
// 1280 - 340 - 8 margin: fully on screen, margin kept.
|
|
42
|
+
expect(fit.left).toBe(932)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('never pushes a list past the LEFT edge either', () => {
|
|
46
|
+
const fit = fitList({
|
|
47
|
+
anchor: { left: -20, top: 200, bottom: 232 },
|
|
48
|
+
menu: { width: 240, contentHeight: 144 },
|
|
49
|
+
viewport,
|
|
50
|
+
})
|
|
51
|
+
expect(fit.left).toBe(8)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('opens upward when the room above is better — the field low on the screen', () => {
|
|
55
|
+
const fit = fitList({
|
|
56
|
+
anchor: { left: 100, top: 700, bottom: 732 },
|
|
57
|
+
menu: { width: 240, contentHeight: 480 },
|
|
58
|
+
viewport,
|
|
59
|
+
})
|
|
60
|
+
expect(fit.top).toBeUndefined()
|
|
61
|
+
expect(fit.bottom).toBe(800 - 700 + 4) // anchored to the field's top
|
|
62
|
+
expect(fit.maxHeight).toBe(240) // still capped
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('stays below when the room below is bad but the room above is worse', () => {
|
|
66
|
+
const fit = fitList({
|
|
67
|
+
anchor: { left: 100, top: 60, bottom: 92 },
|
|
68
|
+
menu: { width: 240, contentHeight: 480 },
|
|
69
|
+
viewport,
|
|
70
|
+
})
|
|
71
|
+
expect(fit.top).toBe(96)
|
|
72
|
+
expect(fit.maxHeight).toBe(240)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('caps a long list at 240 with plenty of room — the old max-h-[240px]', () => {
|
|
76
|
+
const fit = fitList({
|
|
77
|
+
anchor: { left: 100, top: 100, bottom: 132 },
|
|
78
|
+
menu: { width: 240, contentHeight: 900 },
|
|
79
|
+
viewport,
|
|
80
|
+
})
|
|
81
|
+
expect(fit.maxHeight).toBe(240)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('keeps a 120px floor in a cramped corner — scrollable beats invisible', () => {
|
|
85
|
+
const fit = fitList({
|
|
86
|
+
anchor: { left: 100, top: 740, bottom: 772 },
|
|
87
|
+
menu: { width: 240, contentHeight: 480 },
|
|
88
|
+
viewport,
|
|
89
|
+
})
|
|
90
|
+
expect(fit.maxHeight).toBeGreaterThanOrEqual(120)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('with no cap, a tall list stands at its full height when the room is there', () => {
|
|
94
|
+
const fit = fitMenu({
|
|
95
|
+
anchor: { left: 1100, top: 20, bottom: 56 },
|
|
96
|
+
menu: { width: 288, contentHeight: 450 },
|
|
97
|
+
viewport: { width: 1440, height: 900 },
|
|
98
|
+
})
|
|
99
|
+
expect(fit.maxHeight).toBeGreaterThanOrEqual(450)
|
|
100
|
+
})
|
|
101
|
+
})
|
package/src/fit.ts
CHANGED
|
@@ -1,30 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* testable without a browser.
|
|
2
|
+
* The last of the hand-written viewport geometry.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
* a time: the files-panel picker was cut off at the right edge
|
|
7
|
-
* Select, 2026-09-01), then a reply menu's highlight submenu was cut
|
|
4
|
+
* This module existed because the same failure kept recurring one floating
|
|
5
|
+
* surface at a time: the files-panel picker was cut off at the right edge
|
|
6
|
+
* (Select, 2026-09-01), then a reply menu's highlight submenu was cut the
|
|
8
7
|
* same way and the identity menu vanished under a z-indexed header
|
|
9
|
-
* (2026-09-03).
|
|
10
|
-
*
|
|
8
|
+
* (2026-09-03). Stage 4 handed that job to Floating UI, so `Menu`, `MenuSub`
|
|
9
|
+
* and `Select` no longer call any of it, and `clampBox` and `fitSubmenu` are
|
|
10
|
+
* gone with them.
|
|
11
|
+
*
|
|
12
|
+
* **`fitMenu` survives for exactly one caller: `ChipInput`**, whose suggestion
|
|
13
|
+
* list is still hand-placed. It goes when `ChipInput` moves onto Base UI's
|
|
14
|
+
* `Combobox` at stage 5, and this file goes with it. `PLAN.md` §6 said the
|
|
15
|
+
* geometry had three callers and could be deleted at stage 4; it had four.
|
|
11
16
|
*/
|
|
12
|
-
|
|
13
17
|
const MARGIN = 8
|
|
14
18
|
const GAP = 4
|
|
15
19
|
|
|
16
20
|
/**
|
|
17
|
-
* Where a
|
|
21
|
+
* Where a list of this size goes, given its anchor and the viewport.
|
|
18
22
|
*
|
|
19
|
-
* Left is clamped inside the viewport with an 8px margin. Height is capped
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* both the content and the room above, the menu opens UPWARD (anchored to
|
|
26
|
-
* the trigger's top via `bottom`). The 120px floor keeps a menu usable even
|
|
27
|
-
* in a cramped corner — scrollable beats invisible.
|
|
23
|
+
* Left is clamped inside the viewport with an 8px margin. Height is capped at
|
|
24
|
+
* `cap` — `ChipInput` passes 240, its old `max-h-[240px]` — but never taller
|
|
25
|
+
* than the room there is; when the room below the anchor is smaller than both
|
|
26
|
+
* the content and the room above, the list opens UPWARD (anchored to the
|
|
27
|
+
* trigger's top via `bottom`). The 120px floor keeps it usable even in a
|
|
28
|
+
* cramped corner — scrollable beats invisible.
|
|
28
29
|
*/
|
|
29
30
|
export function fitMenu({
|
|
30
31
|
anchor,
|
|
@@ -47,48 +48,3 @@ export function fitMenu({
|
|
|
47
48
|
? { left, bottom: viewport.height - anchor.top + GAP, maxHeight }
|
|
48
49
|
: { left, top: anchor.bottom + GAP, maxHeight }
|
|
49
50
|
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Keep a box a caller has already placed fully on screen.
|
|
53
|
-
*
|
|
54
|
-
* For the menus whose caller measured a rect and chose the corner itself
|
|
55
|
-
* (`position`): the caller's math stays authoritative, but its result can no
|
|
56
|
-
* longer land off screen — it is slid inside the viewport, never flipped,
|
|
57
|
-
* and capped to the viewport's height with the same 120px floor as fitMenu.
|
|
58
|
-
*/
|
|
59
|
-
export function clampBox({
|
|
60
|
-
box,
|
|
61
|
-
viewport,
|
|
62
|
-
}: {
|
|
63
|
-
box: { left: number; top: number; width: number; height: number }
|
|
64
|
-
viewport: { width: number; height: number }
|
|
65
|
-
}): { left: number; top: number; maxHeight: number } {
|
|
66
|
-
const left = Math.max(MARGIN, Math.min(box.left, viewport.width - box.width - MARGIN))
|
|
67
|
-
const maxHeight = Math.max(Math.min(box.height, viewport.height - 2 * MARGIN), 120)
|
|
68
|
-
const top = Math.max(MARGIN, Math.min(box.top, viewport.height - maxHeight - MARGIN))
|
|
69
|
-
return { left, top, maxHeight }
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Where a submenu goes, given its trigger row and the viewport.
|
|
74
|
-
*
|
|
75
|
-
* To the RIGHT of the row when it fits, flipped to the LEFT when it does not
|
|
76
|
-
* — the measured flip two menus hand-rolled and one of them broke (the copy
|
|
77
|
-
* dropped the ref its measurement read, so it measured nothing and always
|
|
78
|
-
* opened rightward, off the screen). Top-aligned with the row, slid up when
|
|
79
|
-
* the tail would run past the bottom edge.
|
|
80
|
-
*/
|
|
81
|
-
export function fitSubmenu({
|
|
82
|
-
row,
|
|
83
|
-
panel,
|
|
84
|
-
viewport,
|
|
85
|
-
}: {
|
|
86
|
-
row: { left: number; right: number; top: number }
|
|
87
|
-
panel: { width: number; height: number }
|
|
88
|
-
viewport: { width: number; height: number }
|
|
89
|
-
}): { left: number; top: number } {
|
|
90
|
-
const fitsRight = row.right + GAP + panel.width <= viewport.width - MARGIN
|
|
91
|
-
const left = Math.max(MARGIN, fitsRight ? row.right + GAP : row.left - GAP - panel.width)
|
|
92
|
-
const top = Math.max(MARGIN, Math.min(row.top, viewport.height - panel.height - MARGIN))
|
|
93
|
-
return { left, top }
|
|
94
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -20,8 +20,8 @@ export { ChipInput, InputChip, type ChipInputOption, type ChipInputProps, type I
|
|
|
20
20
|
export { IconButton, type IconButtonProps, type IconButtonVariant } from './IconButton'
|
|
21
21
|
export { Kbd, type KbdProps } from './Kbd'
|
|
22
22
|
export { IdentityMenu, IdentityPanel, type Identity, type IdentityMenuProps, type IdentityPanelProps } from './IdentityMenu'
|
|
23
|
-
export { Tooltip, WithTooltip, type TooltipProps, type WithTooltipProps } from './Tooltip'
|
|
24
|
-
export { Field,
|
|
23
|
+
export { Tooltip, TooltipProvider, WithTooltip, type TooltipProps, type TooltipProviderProps, type WithTooltipProps } from './Tooltip'
|
|
24
|
+
export { Field, type FieldProps } from './Field'
|
|
25
25
|
export { Select, type SelectOption, type SelectProps } from './Select'
|
|
26
26
|
export { TextInput, type TextInputProps } from './TextInput'
|
|
27
27
|
export { Textarea, type TextareaProps } from './Textarea'
|
|
@@ -33,8 +33,11 @@ export { EmptyState, type EmptyStateProps } from './EmptyState'
|
|
|
33
33
|
export { SkeletonBar, SkeletonList, SkeletonRow } from './Skeleton'
|
|
34
34
|
export { Breadcrumb, type BreadcrumbProps, type Crumb } from './Breadcrumb'
|
|
35
35
|
export { EnterHint, Menu, MenuItem, MenuPanel, MenuRow, MenuSection, MenuSub, type MenuItemProps, type MenuPanelProps, type MenuProps, type MenuSubProps } from './Menu'
|
|
36
|
-
export { clampBox, fitMenu, fitSubmenu } from './fit'
|
|
37
36
|
export { NavItem, type NavItemProps } from './NavItem'
|
|
37
|
+
export { Popover, type PopoverProps } from './Popover'
|
|
38
|
+
export { ReactionPicker, type ReactionOption, type ReactionPickerProps } from './ReactionPicker'
|
|
39
|
+
export { Toolbar, ToolbarButton, ToolbarInput, ToolbarSeparator, type ToolbarProps, type ToolbarButtonProps, type ToolbarInputProps } from './Toolbar'
|
|
40
|
+
export { PreviewCard, type PreviewCardProps } from './PreviewCard'
|
|
38
41
|
export { Person, type PersonProps } from './Person'
|
|
39
42
|
export { Rail, type RailProps } from './Rail'
|
|
40
43
|
export { RailItem, type RailItemProps } from './RailItem'
|