@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.
Files changed (154) hide show
  1. package/dist/Avatar.d.ts +13 -1
  2. package/dist/Avatar.d.ts.map +1 -1
  3. package/dist/AvatarGroup.d.ts.map +1 -1
  4. package/dist/Banner.d.ts +16 -1
  5. package/dist/Banner.d.ts.map +1 -1
  6. package/dist/Breadcrumb.d.ts +9 -0
  7. package/dist/Breadcrumb.d.ts.map +1 -1
  8. package/dist/Button.d.ts +12 -2
  9. package/dist/Button.d.ts.map +1 -1
  10. package/dist/Checkbox.d.ts +2 -0
  11. package/dist/Checkbox.d.ts.map +1 -1
  12. package/dist/Chip.d.ts +3 -2
  13. package/dist/Chip.d.ts.map +1 -1
  14. package/dist/ChipInput.d.ts +3 -1
  15. package/dist/ChipInput.d.ts.map +1 -1
  16. package/dist/ConfirmDialog.d.ts +5 -0
  17. package/dist/ConfirmDialog.d.ts.map +1 -1
  18. package/dist/DialogShell.d.ts +27 -3
  19. package/dist/DialogShell.d.ts.map +1 -1
  20. package/dist/EditableText.d.ts +6 -0
  21. package/dist/EditableText.d.ts.map +1 -1
  22. package/dist/Field.d.ts +42 -25
  23. package/dist/Field.d.ts.map +1 -1
  24. package/dist/IconButton.d.ts +3 -2
  25. package/dist/IconButton.d.ts.map +1 -1
  26. package/dist/IdentityMenu.d.ts +25 -13
  27. package/dist/IdentityMenu.d.ts.map +1 -1
  28. package/dist/Menu.d.ts +65 -82
  29. package/dist/Menu.d.ts.map +1 -1
  30. package/dist/PersonTrigger.d.ts.map +1 -1
  31. package/dist/Popover.d.ts +86 -0
  32. package/dist/Popover.d.ts.map +1 -0
  33. package/dist/PreviewCard.d.ts +39 -0
  34. package/dist/PreviewCard.d.ts.map +1 -0
  35. package/dist/Reaction.d.ts.map +1 -1
  36. package/dist/ReactionPicker.d.ts +55 -0
  37. package/dist/ReactionPicker.d.ts.map +1 -0
  38. package/dist/SearchInput.d.ts +5 -0
  39. package/dist/SearchInput.d.ts.map +1 -1
  40. package/dist/Select.d.ts +26 -16
  41. package/dist/Select.d.ts.map +1 -1
  42. package/dist/Tabs.d.ts +11 -1
  43. package/dist/Tabs.d.ts.map +1 -1
  44. package/dist/TextInput.d.ts +7 -1
  45. package/dist/TextInput.d.ts.map +1 -1
  46. package/dist/Textarea.d.ts +8 -1
  47. package/dist/Textarea.d.ts.map +1 -1
  48. package/dist/Toolbar.d.ts +95 -0
  49. package/dist/Toolbar.d.ts.map +1 -0
  50. package/dist/Tooltip.d.ts +36 -10
  51. package/dist/Tooltip.d.ts.map +1 -1
  52. package/dist/cn.d.ts.map +1 -1
  53. package/dist/fit.d.ts +7 -72
  54. package/dist/fit.d.ts.map +1 -1
  55. package/dist/index.d.ts +6 -3
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +888 -763
  58. package/dist/index.js.map +4 -4
  59. package/dist/triggerDisabled.d.ts +10 -0
  60. package/dist/triggerDisabled.d.ts.map +1 -0
  61. package/package.json +1 -1
  62. package/src/Avatar.mdx +17 -0
  63. package/src/Avatar.name.test.tsx +99 -0
  64. package/src/Avatar.tsx +25 -3
  65. package/src/AvatarGroup.tsx +4 -1
  66. package/src/Banner.mdx +10 -2
  67. package/src/Banner.stories.tsx +29 -0
  68. package/src/Banner.test.tsx +60 -0
  69. package/src/Banner.tsx +35 -3
  70. package/src/Breadcrumb.mdx +5 -2
  71. package/src/Breadcrumb.test.tsx +44 -0
  72. package/src/Breadcrumb.tsx +10 -2
  73. package/src/Button.compose.test.tsx +119 -0
  74. package/src/Button.mdx +7 -5
  75. package/src/Button.test.tsx +2 -1
  76. package/src/Button.tsx +21 -5
  77. package/src/Checkbox.tsx +3 -0
  78. package/src/Chip.tsx +3 -2
  79. package/src/ChipInput.tsx +4 -0
  80. package/src/ConfirmDialog.mdx +20 -1
  81. package/src/ConfirmDialog.test.tsx +109 -0
  82. package/src/ConfirmDialog.tsx +6 -0
  83. package/src/DialogShell.mdx +18 -4
  84. package/src/DialogShell.stories.tsx +56 -6
  85. package/src/DialogShell.test.tsx +184 -0
  86. package/src/DialogShell.tsx +108 -39
  87. package/src/EditableText.mdx +6 -1
  88. package/src/EditableText.test.tsx +156 -0
  89. package/src/EditableText.tsx +23 -5
  90. package/src/Field.mdx +23 -3
  91. package/src/Field.stories.tsx +35 -0
  92. package/src/Field.test.tsx +144 -26
  93. package/src/Field.tsx +94 -59
  94. package/src/IconButton.mdx +6 -2
  95. package/src/IconButton.test.tsx +5 -2
  96. package/src/IconButton.tsx +12 -6
  97. package/src/IdentityMenu.mdx +29 -6
  98. package/src/IdentityMenu.stories.tsx +41 -6
  99. package/src/IdentityMenu.tsx +74 -47
  100. package/src/Kbd.stories.tsx +3 -3
  101. package/src/Menu.mdx +88 -75
  102. package/src/Menu.stories.tsx +120 -52
  103. package/src/Menu.test.tsx +315 -0
  104. package/src/Menu.tsx +347 -251
  105. package/src/MenuItem.stories.tsx +135 -98
  106. package/src/NavItem.mdx +1 -1
  107. package/src/PersonTrigger.mdx +20 -14
  108. package/src/PersonTrigger.tsx +23 -1
  109. package/src/Popover.mdx +130 -0
  110. package/src/Popover.stories.tsx +222 -0
  111. package/src/Popover.test.tsx +188 -0
  112. package/src/Popover.tsx +156 -0
  113. package/src/PreviewCard.mdx +98 -0
  114. package/src/PreviewCard.stories.tsx +168 -0
  115. package/src/PreviewCard.test.tsx +83 -0
  116. package/src/PreviewCard.tsx +91 -0
  117. package/src/Rail.mdx +63 -0
  118. package/src/Rail.stories.tsx +81 -0
  119. package/src/Reaction.mdx +4 -2
  120. package/src/Reaction.tsx +4 -2
  121. package/src/ReactionPicker.mdx +85 -0
  122. package/src/ReactionPicker.stories.tsx +120 -0
  123. package/src/ReactionPicker.test.tsx +118 -0
  124. package/src/ReactionPicker.tsx +88 -0
  125. package/src/SearchInput.mdx +6 -2
  126. package/src/SearchInput.tsx +7 -1
  127. package/src/Select.mdx +16 -5
  128. package/src/Select.test.tsx +157 -0
  129. package/src/Select.tsx +89 -214
  130. package/src/Sidebar.mdx +6 -2
  131. package/src/Tabs.mdx +5 -0
  132. package/src/Tabs.test.tsx +15 -0
  133. package/src/Tabs.tsx +17 -2
  134. package/src/TextInput.mdx +8 -2
  135. package/src/TextInput.tsx +10 -7
  136. package/src/Textarea.mdx +6 -2
  137. package/src/Textarea.tsx +14 -9
  138. package/src/Toolbar.mdx +104 -0
  139. package/src/Toolbar.stories.tsx +142 -0
  140. package/src/Toolbar.test.tsx +253 -0
  141. package/src/Toolbar.tsx +158 -0
  142. package/src/Tooltip.mdx +43 -5
  143. package/src/Tooltip.stories.tsx +26 -0
  144. package/src/Tooltip.test.tsx +195 -0
  145. package/src/Tooltip.tsx +150 -48
  146. package/src/cn.ts +1 -1
  147. package/src/fit.test.ts +101 -0
  148. package/src/fit.ts +19 -63
  149. package/src/index.ts +6 -3
  150. package/src/triggerDisabled.ts +13 -0
  151. package/stories/Choosing.mdx +8 -3
  152. package/tailwind-preset.js +7 -0
  153. package/src/Menu.fit.test.ts +0 -90
  154. 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 { useCallback, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
2
- import { createPortal } from 'react-dom'
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), verbatim, in one file.
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
- * `Tooltip` is the surface: a 30px elevated pill with a caption. `WithTooltip`
10
- * wraps a trigger and portals the surface above or below it on hover, fixed
11
- * to the viewport and kept 8px inside it. The wrapper is `inline-flex` and
12
- * shrinks nothing wrap a control, never a block (a form inside it
13
- * collapses to its content width; Ship learnt that).
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 interface TooltipProps {
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
- export function WithTooltip({ label, shortcut, placement = 'top', wrapperClassName, children }: WithTooltipProps) {
47
- const [show, setShow] = useState(false)
48
- const ref = useRef<HTMLDivElement>(null)
49
- const tooltipRef = useRef<HTMLDivElement>(null)
50
- const [style, setStyle] = useState<CSSProperties>({ position: 'fixed', zIndex: 9999, pointerEvents: 'none', visibility: 'hidden' })
51
-
52
- const reposition = useCallback(() => {
53
- const trigger = ref.current
54
- const tip = tooltipRef.current
55
- if (!trigger || !tip) return
56
- const triggerRect = trigger.getBoundingClientRect()
57
- const tipRect = tip.getBoundingClientRect()
58
- let top = placement === 'bottom' ? triggerRect.bottom + GAP : triggerRect.top - tipRect.height - GAP
59
- // The vertical axis flips and clamps like the horizontal one always has
60
- // (2026-09-03): a `top` tooltip on a control near the viewport's top edge
61
- // was the one floating surface left that could leave the screen.
62
- if (placement === 'top' && top < VIEWPORT_PAD) top = triggerRect.bottom + GAP
63
- else if (placement === 'bottom' && top + tipRect.height > window.innerHeight - VIEWPORT_PAD) top = triggerRect.top - tipRect.height - GAP
64
- top = Math.max(VIEWPORT_PAD, Math.min(top, window.innerHeight - tipRect.height - VIEWPORT_PAD))
65
- let left = triggerRect.left + triggerRect.width / 2 - tipRect.width / 2
66
- left = Math.max(VIEWPORT_PAD, Math.min(left, window.innerWidth - tipRect.width - VIEWPORT_PAD))
67
- setStyle({ position: 'fixed', top, left, zIndex: 9999, pointerEvents: 'none', visibility: 'visible' })
68
- }, [placement])
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
- useLayoutEffect(() => {
71
- if (show) reposition()
72
- }, [show, reposition])
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
- <div ref={ref} className={cn('inline-flex shrink-0', wrapperClassName)} onMouseEnter={() => setShow(true)} onMouseLeave={() => setShow(false)}>
76
- {children}
77
- {show &&
78
- createPortal(
79
- <div ref={tooltipRef} style={style}>
80
- <Tooltip label={label} shortcut={shortcut} />
81
- </div>,
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({
@@ -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
- * Viewport geometry for everything that floats — pure, so the decisions are
3
- * testable without a browser.
2
+ * The last of the hand-written viewport geometry.
4
3
  *
5
- * One module, because the failure it prevents keeps recurring one surface at
6
- * a time: the files-panel picker was cut off at the right edge (fixed in
7
- * Select, 2026-09-01), then a reply menu's highlight submenu was cut off the
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). A menu that hand-rolls its own geometry is a menu waiting to
10
- * be the next screenshot; the shell calls these instead.
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 menu of this size goes, given its anchor and the viewport.
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
- * at `cap` — Select's option list keeps the old `max-h-72` (288), a menu
21
- * panel passes none and uses all the room it opens into, so it scrolls only
22
- * when the screen truly has no space (the identity panel got Select's cap
23
- * by accident and grew a scrollbar at full height, 2026-09-03) but never
24
- * taller than that room; when the room below the anchor is smaller than
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, useFieldControlId, type FieldProps } from './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'