@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.
Files changed (153) 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 +882 -763
  58. package/dist/index.js.map +4 -4
  59. package/package.json +1 -1
  60. package/src/Avatar.mdx +17 -0
  61. package/src/Avatar.name.test.tsx +99 -0
  62. package/src/Avatar.tsx +25 -3
  63. package/src/AvatarGroup.tsx +4 -1
  64. package/src/Banner.mdx +10 -2
  65. package/src/Banner.stories.tsx +29 -0
  66. package/src/Banner.test.tsx +60 -0
  67. package/src/Banner.tsx +35 -3
  68. package/src/Breadcrumb.mdx +5 -2
  69. package/src/Breadcrumb.test.tsx +44 -0
  70. package/src/Breadcrumb.tsx +10 -2
  71. package/src/Button.compose.test.tsx +119 -0
  72. package/src/Button.mdx +7 -5
  73. package/src/Button.test.tsx +2 -1
  74. package/src/Button.tsx +21 -5
  75. package/src/Checkbox.tsx +3 -0
  76. package/src/Chip.tsx +3 -2
  77. package/src/ChipInput.tsx +4 -0
  78. package/src/ConfirmDialog.mdx +20 -1
  79. package/src/ConfirmDialog.test.tsx +109 -0
  80. package/src/ConfirmDialog.tsx +6 -0
  81. package/src/DialogShell.mdx +18 -4
  82. package/src/DialogShell.stories.tsx +56 -6
  83. package/src/DialogShell.test.tsx +184 -0
  84. package/src/DialogShell.tsx +108 -39
  85. package/src/EditableText.mdx +6 -1
  86. package/src/EditableText.test.tsx +156 -0
  87. package/src/EditableText.tsx +23 -5
  88. package/src/Field.mdx +23 -3
  89. package/src/Field.stories.tsx +35 -0
  90. package/src/Field.test.tsx +117 -27
  91. package/src/Field.tsx +86 -58
  92. package/src/IconButton.mdx +6 -2
  93. package/src/IconButton.test.tsx +5 -2
  94. package/src/IconButton.tsx +12 -6
  95. package/src/IdentityMenu.mdx +29 -6
  96. package/src/IdentityMenu.stories.tsx +41 -6
  97. package/src/IdentityMenu.tsx +74 -47
  98. package/src/Kbd.stories.tsx +3 -3
  99. package/src/Menu.mdx +88 -75
  100. package/src/Menu.stories.tsx +120 -52
  101. package/src/Menu.test.tsx +315 -0
  102. package/src/Menu.tsx +341 -251
  103. package/src/MenuItem.stories.tsx +135 -98
  104. package/src/NavItem.mdx +1 -1
  105. package/src/PersonTrigger.mdx +20 -14
  106. package/src/PersonTrigger.tsx +23 -1
  107. package/src/Popover.mdx +130 -0
  108. package/src/Popover.stories.tsx +222 -0
  109. package/src/Popover.test.tsx +188 -0
  110. package/src/Popover.tsx +153 -0
  111. package/src/PreviewCard.mdx +98 -0
  112. package/src/PreviewCard.stories.tsx +168 -0
  113. package/src/PreviewCard.test.tsx +83 -0
  114. package/src/PreviewCard.tsx +91 -0
  115. package/src/Rail.mdx +63 -0
  116. package/src/Rail.stories.tsx +81 -0
  117. package/src/Reaction.mdx +4 -2
  118. package/src/Reaction.tsx +4 -2
  119. package/src/ReactionPicker.mdx +85 -0
  120. package/src/ReactionPicker.stories.tsx +120 -0
  121. package/src/ReactionPicker.test.tsx +118 -0
  122. package/src/ReactionPicker.tsx +88 -0
  123. package/src/SearchInput.mdx +6 -2
  124. package/src/SearchInput.tsx +7 -1
  125. package/src/Select.mdx +16 -5
  126. package/src/Select.test.tsx +157 -0
  127. package/src/Select.tsx +89 -214
  128. package/src/Sidebar.mdx +6 -2
  129. package/src/Tabs.mdx +5 -0
  130. package/src/Tabs.test.tsx +15 -0
  131. package/src/Tabs.tsx +17 -2
  132. package/src/TextInput.mdx +8 -2
  133. package/src/TextInput.tsx +10 -7
  134. package/src/Textarea.mdx +6 -2
  135. package/src/Textarea.tsx +14 -9
  136. package/src/Toolbar.mdx +104 -0
  137. package/src/Toolbar.stories.tsx +142 -0
  138. package/src/Toolbar.test.tsx +198 -0
  139. package/src/Toolbar.tsx +158 -0
  140. package/src/Tooltip.mdx +43 -5
  141. package/src/Tooltip.stories.tsx +26 -0
  142. package/src/Tooltip.test.tsx +195 -0
  143. package/src/Tooltip.tsx +150 -48
  144. package/src/cn.ts +1 -1
  145. package/src/fit.test.ts +101 -0
  146. package/src/fit.ts +19 -63
  147. package/src/index.ts +6 -3
  148. package/stories/Choosing.mdx +8 -3
  149. package/stories/TokensPage.tsx +6 -2
  150. package/tailwind-preset.js +12 -0
  151. package/tokens.css +8 -0
  152. package/src/Menu.fit.test.ts +0 -90
  153. package/src/Select.fit.test.ts +0 -101
package/src/Select.tsx CHANGED
@@ -1,24 +1,34 @@
1
1
  import { IconCheck, IconChevronDown } from '@tabler/icons-react'
2
- import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
3
- import { createPortal } from 'react-dom'
2
+ import { Select as BaseSelect } from '@base-ui/react/select'
3
+ import type { ReactNode } from 'react'
4
4
  import { cn } from './cn'
5
5
 
6
6
  /**
7
7
  * Peek's Select (2026-08-28), verbatim, plus what Ship added: an option may
8
8
  * carry a `leading` node — an avatar beside a person's name — shown in the
9
- * trigger and in the list.
9
+ * trigger and in the list. On Base UI's `Select` since stage 4 of the
10
+ * migration (2026-09-07).
10
11
  *
11
- * A button that opens a portalled listbox under itself: arrows move (and keep
12
- * the active option scrolled into view), Home and End jump, Enter and Space
13
- * pick, Escape closes and returns focus to the trigger, Tab closes, a click
14
- * outside closes, a PAGE scroll or resize closes (the list is fixed to where
15
- * the trigger was) — a scroll *inside* the list is the list's own business
16
- * and must not dismiss it. The menu keeps itself on screen: clamped to the
17
- * viewport's sides, height capped to the space it actually has, opening
18
- * upward when the room below is worse than the room above (Katerina,
19
- * 2026-09-01 the files-panel picker was cut right and bottom, and its own
20
- * scroll closed it). Two sizes; `disabled` explains nothing by itself wrap
21
- * it in a tooltip that does.
12
+ * A button that shows the choice and opens a listbox under it. **Under it:
13
+ * Base UI would rather lay the list over the trigger so the chosen option's
14
+ * text covers the trigger's, the way macOS does `alignItemWithTrigger` is
15
+ * off, because every Select in both apps opens below one today (Katerina,
16
+ * D24).**
17
+ *
18
+ * What the port deleted: `createPortal`, the whole `onKeyDown` switch, the
19
+ * outside-click, resize and scroll listeners, the `aria-activedescendant`
20
+ * bookkeeping, the `scrollIntoView` that kept the highlight visible, and
21
+ * `fit.ts` the pure geometry this component grew and then shared with the
22
+ * Menu shell. Floating UI does the fitting; `--available-height` is where the
23
+ * old 288px cap now meets the room the screen actually has.
24
+ *
25
+ * What it gained, and none of it was written here: **typeahead** — type the
26
+ * first letters of an option and the list jumps to it — a listbox whose
27
+ * highlight is managed rather than counted, and a value that can be part of a
28
+ * form.
29
+ *
30
+ * Two sizes; `disabled` explains nothing by itself — wrap it in a tooltip
31
+ * that does.
22
32
  */
23
33
  export interface SelectOption {
24
34
  value: string
@@ -33,171 +43,32 @@ export interface SelectProps {
33
43
  options: SelectOption[]
34
44
  size?: 'default' | 'small'
35
45
  ariaLabel?: string
46
+ /** Set by a `Field` with `required`; a caller inside one owes nothing. */
47
+ 'aria-required'?: boolean | 'true' | 'false'
36
48
  placeholder?: string
37
49
  disabled?: boolean
38
50
  className?: string
39
51
  }
40
52
 
41
- // The geometry lives in fit.ts now (2026-09-03) shared with the Menu
42
- // shell, so a cut-off surface is fixed once. Re-exported because this is
43
- // where it grew up and its test still names it by this address.
44
- import { fitMenu } from './fit'
45
- export { fitMenu }
46
-
47
- export function Select({ value, onChange, options, size = 'default', ariaLabel, placeholder = 'Select…', disabled, className }: SelectProps) {
48
- const id = useId()
49
- const triggerRef = useRef<HTMLButtonElement>(null)
50
- const menuRef = useRef<HTMLDivElement>(null)
51
- const [rect, setRect] = useState<DOMRect | null>(null)
52
- /** Where the menu actually goes — measured against the viewport after the
53
- * provisional render, so it is never cut off by an edge. */
54
- const [placement, setPlacement] = useState<{
55
- left: number
56
- top?: number
57
- bottom?: number
58
- maxHeight: number
59
- } | null>(null)
60
- const open = rect !== null
61
-
62
- const selectedIndex = useMemo(() => Math.max(0, options.findIndex((o) => o.value === value)), [options, value])
63
- const [activeIndex, setActiveIndex] = useState(selectedIndex)
64
-
65
- const close = useCallback((refocus: boolean) => {
66
- setRect(null)
67
- if (refocus) triggerRef.current?.focus()
68
- }, [])
69
-
70
- const openMenu = useCallback(() => {
71
- setActiveIndex(selectedIndex)
72
- setRect(triggerRef.current?.getBoundingClientRect() ?? null)
73
- }, [selectedIndex])
74
-
75
- /*
76
- * Fit the menu to the viewport, before paint.
77
- *
78
- * The provisional render sits at the trigger's corner and is invisible;
79
- * this measures it and decides the real box: left clamped inside the
80
- * viewport, height capped to the space available, and the whole thing
81
- * opening UPWARD when the room below is smaller than both the content and
82
- * the room above. A menu that is always fully on screen is also the only
83
- * kind whose scrollbar can actually be used.
84
- */
85
- useLayoutEffect(() => {
86
- if (!rect || !menuRef.current) {
87
- setPlacement(null)
88
- return
89
- }
90
- setPlacement(
91
- fitMenu({
92
- anchor: { left: rect.left, top: rect.top, bottom: rect.bottom },
93
- menu: { width: menuRef.current.offsetWidth, contentHeight: menuRef.current.scrollHeight },
94
- viewport: { width: window.innerWidth, height: window.innerHeight },
95
- // The option list keeps its classic height (the old max-h-72); a menu
96
- // panel passes no cap and stands as tall as the room it opens into.
97
- cap: 288,
98
- }),
99
- )
100
- }, [rect, options.length])
101
-
102
- // Keyboard follows the highlight: without this, arrowing past the fold
103
- // moved `activeIndex` into rows the capped menu never showed.
104
- useEffect(() => {
105
- if (!open) return
106
- // Optional call: jsdom implements neither scrolling nor this method, and
107
- // a consumer's component tests should not crash for a scroll nicety.
108
- document.getElementById(`${id}-${activeIndex}`)?.scrollIntoView?.({ block: 'nearest' })
109
- }, [open, activeIndex, id])
110
-
111
- // Outside click and Escape — the two exits every menu has. `mousedown`
112
- // rather than `click`, so a press that starts outside dismisses at once.
113
- useEffect(() => {
114
- if (!open) return
115
- const onDown = (e: MouseEvent) => {
116
- if (menuRef.current?.contains(e.target as Node)) return
117
- if (triggerRef.current?.contains(e.target as Node)) return
118
- setRect(null)
119
- }
120
- const onResize = () => setRect(null)
121
- /*
122
- * A PAGE scroll moves the anchor out from under the fixed menu, so it
123
- * closes. A scroll INSIDE the menu is the menu working as designed —
124
- * capture phase sees those too, and closing on them made the list
125
- * impossible to scroll at all (the bug this comment survives to prevent).
126
- */
127
- const onScroll = (e: Event) => {
128
- if (e.target instanceof Node && menuRef.current?.contains(e.target)) return
129
- setRect(null)
130
- }
131
- document.addEventListener('mousedown', onDown)
132
- window.addEventListener('resize', onResize)
133
- window.addEventListener('scroll', onScroll, true)
134
- return () => {
135
- document.removeEventListener('mousedown', onDown)
136
- window.removeEventListener('resize', onResize)
137
- window.removeEventListener('scroll', onScroll, true)
138
- }
139
- }, [open])
140
-
141
- const pick = (index: number) => {
142
- const option = options[index]
143
- if (!option) return
144
- onChange(option.value)
145
- close(true)
146
- }
147
-
148
- const onKeyDown = (e: KeyboardEvent) => {
149
- if (!open) {
150
- if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) {
151
- e.preventDefault()
152
- openMenu()
153
- }
154
- return
155
- }
156
- switch (e.key) {
157
- case 'Escape':
158
- e.preventDefault()
159
- close(true)
160
- break
161
- case 'ArrowDown':
162
- e.preventDefault()
163
- setActiveIndex((i) => Math.min(options.length - 1, i + 1))
164
- break
165
- case 'ArrowUp':
166
- e.preventDefault()
167
- setActiveIndex((i) => Math.max(0, i - 1))
168
- break
169
- case 'Home':
170
- e.preventDefault()
171
- setActiveIndex(0)
172
- break
173
- case 'End':
174
- e.preventDefault()
175
- setActiveIndex(options.length - 1)
176
- break
177
- case 'Enter':
178
- case ' ':
179
- e.preventDefault()
180
- pick(activeIndex)
181
- break
182
- case 'Tab':
183
- close(false)
184
- break
185
- }
186
- }
53
+ /** The 4px between the trigger and the list, and the 8px the list keeps clear
54
+ * of every screen edge the two numbers `fitMenu` used. */
55
+ const GAP = 4
56
+ const VIEWPORT_PAD = 8
187
57
 
58
+ export function Select({ value, onChange, options, size = 'default', ariaLabel, placeholder = 'Select…', disabled, className, ...aria }: SelectProps) {
188
59
  const selected = options.find((o) => o.value === value)
189
-
190
60
  return (
191
- <>
192
- <button
193
- ref={triggerRef}
194
- type="button"
195
- disabled={disabled}
196
- aria-haspopup="listbox"
197
- aria-expanded={open}
61
+ <BaseSelect.Root
62
+ value={value}
63
+ onValueChange={(next) => onChange(next as string)}
64
+ disabled={disabled}
65
+ // Non-modal, as it has always been: the page behind stays scrollable and
66
+ // keeps its scrollbar, so opening a select never shifts the layout.
67
+ modal={false}
68
+ >
69
+ <BaseSelect.Trigger
198
70
  aria-label={ariaLabel}
199
- onClick={() => (open ? close(false) : openMenu())}
200
- onKeyDown={onKeyDown}
71
+ aria-required={aria['aria-required']}
201
72
  className={cn(
202
73
  /*
203
74
  * `min-w-0 max-w-full`: a trigger must never outgrow its container
@@ -222,58 +93,62 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
222
93
  >
223
94
  <span className={cn('flex min-w-0 items-center gap-2', !selected && 'text-text-muted')}>
224
95
  {selected?.leading && <span className="flex shrink-0 items-center">{selected.leading}</span>}
225
- <span className="truncate">{selected?.label ?? placeholder}</span>
96
+ <BaseSelect.Value className="truncate">{() => selected?.label ?? placeholder}</BaseSelect.Value>
226
97
  </span>
227
- <IconChevronDown size={size === 'small' ? 14 : 16} stroke={1.5} className="shrink-0 text-text-secondary" />
228
- </button>
98
+ <BaseSelect.Icon
99
+ render={<IconChevronDown size={size === 'small' ? 14 : 16} stroke={1.5} className="shrink-0 text-text-secondary" />}
100
+ />
101
+ </BaseSelect.Trigger>
229
102
 
230
- {open &&
231
- rect &&
232
- createPortal(
233
- <div
234
- ref={menuRef}
235
- role="listbox"
236
- aria-activedescendant={`${id}-${activeIndex}`}
237
- tabIndex={-1}
238
- style={
239
- placement
240
- ? { ...placement, minWidth: rect.width }
241
- : // Provisional frame: measured by the layout effect above,
242
- // replaced before paint. Hidden so a cut-off position is
243
- // never visible, not even for a frame.
244
- { top: rect.bottom + 4, left: rect.left, minWidth: rect.width, visibility: 'hidden' }
245
- }
246
- className="fixed z-50 overflow-y-auto rounded-lg border border-border-default bg-bg-elevated p-1 shadow-lg"
103
+ <BaseSelect.Portal>
104
+ <BaseSelect.Positioner
105
+ side="bottom"
106
+ align="start"
107
+ sideOffset={GAP}
108
+ collisionPadding={VIEWPORT_PAD}
109
+ /* D24: hang the list under the trigger, as it always has, rather
110
+ than laying it over the trigger with the chosen option on top. */
111
+ alignItemWithTrigger={false}
112
+ /*
113
+ * The list follows its trigger now rather than closing when the page
114
+ * scrolls it was `fixed` to where the trigger had been, so closing
115
+ * was the only way it could avoid being left behind. `anchor-hidden`
116
+ * is the other half of that: when the trigger scrolls out of view
117
+ * entirely the list goes with it, instead of floating over whatever
118
+ * has scrolled into its place.
119
+ */
120
+ className="z-50 data-[anchor-hidden]:hidden"
121
+ >
122
+ <BaseSelect.Popup
123
+ /* `--anchor-width` is the trigger's width and `--available-height`
124
+ the room the list actually has after Floating UI has flipped and
125
+ clamped it — the two numbers `fitMenu` used to compute here. The
126
+ 288px is the old `max-h-72`, now a ceiling on that room rather
127
+ than a height applied blind. */
128
+ className="max-h-[min(288px,var(--available-height))] min-w-[var(--anchor-width)] overflow-y-auto rounded-lg border border-border-default bg-bg-elevated p-1 shadow-lg"
247
129
  >
248
- {options.map((option, index) => (
249
- <div
130
+ {options.map((option) => (
131
+ <BaseSelect.Item
250
132
  key={option.value}
251
- id={`${id}-${index}`}
252
- role="option"
253
- aria-selected={option.value === value}
254
- onMouseEnter={() => setActiveIndex(index)}
255
- onMouseDown={(e) => {
256
- // The document-level dismiss also listens on mousedown.
257
- e.preventDefault()
258
- pick(index)
259
- }}
260
- className={cn(
261
- 'flex h-9 cursor-pointer items-center justify-between gap-2 rounded-lg px-3 transition-colors',
262
- 'text-[14px] font-normal leading-[1.4] text-text-primary',
263
- index === activeIndex && 'bg-bg-hover',
264
- option.value === value && 'font-medium',
265
- )}
133
+ value={option.value}
134
+ /* The highlight is Base UI's `data-highlighted` — pointer and
135
+ keyboard set the same attribute, so what the DOM says and
136
+ what the row looks like cannot disagree. It used to be an
137
+ index this component counted. */
138
+ className="flex h-9 cursor-pointer items-center justify-between gap-2 rounded-lg px-3 text-[14px] font-normal leading-[1.4] text-text-primary transition-colors data-[highlighted]:bg-bg-hover data-[selected]:font-medium"
266
139
  >
267
140
  <span className="flex min-w-0 items-center gap-2">
268
141
  {option.leading && <span className="flex shrink-0 items-center">{option.leading}</span>}
269
- <span className="truncate">{option.label}</span>
142
+ <BaseSelect.ItemText className="truncate">{option.label}</BaseSelect.ItemText>
270
143
  </span>
271
- {option.value === value && <IconCheck size={16} stroke={1.5} className="shrink-0 text-text-secondary" />}
272
- </div>
144
+ <BaseSelect.ItemIndicator
145
+ render={<IconCheck size={16} stroke={1.5} className="shrink-0 text-text-secondary" />}
146
+ />
147
+ </BaseSelect.Item>
273
148
  ))}
274
- </div>,
275
- document.body,
276
- )}
277
- </>
149
+ </BaseSelect.Popup>
150
+ </BaseSelect.Positioner>
151
+ </BaseSelect.Portal>
152
+ </BaseSelect.Root>
278
153
  )
279
154
  }
package/src/Sidebar.mdx CHANGED
@@ -36,14 +36,18 @@ the caller's — this is the shell.
36
36
  import { Sidebar, NavItem, SectionLabel } from '@estiva-app/ui'
37
37
 
38
38
  <Sidebar>
39
- <NavItem href="#/documents" label="Documents" active />
39
+ <NavItem href="/documents" label="Documents" active />
40
40
  <div className="mt-2 flex h-8 shrink-0 items-center px-2">
41
41
  <SectionLabel>Collections</SectionLabel>
42
42
  </div>
43
- <NavItem href="#/collections/12" label="Quarterly plan" count={7} countLabel="7 open" />
43
+ <NavItem href="/collections/12" label="Quarterly plan" count={7} countLabel="7 open" />
44
44
  </Sidebar>
45
45
  ```
46
46
 
47
+ **Every row is a plain anchor, so in a router app each click would reload
48
+ the page.** Keep NavItem's rule: a thin wrapper computes `active` from the
49
+ location, intercepts the click and navigates in place — see **NavItem**.
50
+
47
51
  Anything you place directly in the column needs `shrink-0` (the heading
48
52
  row above carries it): the column scrolls on overflow, and a flex child
49
53
  without it gets compressed instead — rows must keep their height.
package/src/Tabs.mdx CHANGED
@@ -49,6 +49,11 @@ import { Tabs } from '@estiva-app/ui'
49
49
  Wiring the panels — what shows for the active tab — is the caller's.
50
50
  - `className` lands on the outer box, around the row.
51
51
 
52
+ - **Name the row** with `aria-label` where an app has more than one, or where
53
+ a screen reader would otherwise announce two "tab lists". A visible heading
54
+ that already names it takes `aria-labelledby` instead. Unnamed is fine for
55
+ the only tab row on a page.
56
+
52
57
  ## Keys
53
58
 
54
59
  | Key | Does |
package/src/Tabs.test.tsx CHANGED
@@ -108,6 +108,21 @@ describe('Tabs', () => {
108
108
  expect(screen.getAllByRole('tab').map((t) => t.getAttribute('aria-selected'))).toEqual(['false', 'false', 'false'])
109
109
  })
110
110
 
111
+ /** A `tablist` with no name is announced as a bare "tab list" — and says the
112
+ * same nothing twice on a page with two of them. There was no way to give
113
+ * one until 2026-09-08. */
114
+ it('names the row when the caller gives it a name', () => {
115
+ render(<Tabs tabs={THREE} active="one" onChange={() => {}} aria-label="Views" />)
116
+ expect(screen.getByRole('tablist', { name: 'Views' })).toBeTruthy()
117
+ })
118
+
119
+ it('and has no name when nobody gives it one, rather than a wrong one', () => {
120
+ render(<Tabs tabs={THREE} active="one" onChange={() => {}} />)
121
+ const list = screen.getByRole('tablist')
122
+ expect(list.getAttribute('aria-label')).toBeNull()
123
+ expect(list.getAttribute('aria-labelledby')).toBeNull()
124
+ })
125
+
111
126
  it('puts className on the outer box, around the row', () => {
112
127
  const { container } = render(<Tabs tabs={THREE} active="one" onChange={() => {}} className="mt-4" />)
113
128
  const outer = container.firstElementChild as HTMLElement
package/src/Tabs.tsx CHANGED
@@ -34,11 +34,21 @@ export interface TabsProps<T extends string> {
34
34
  onChange: (id: T) => void
35
35
  /** `default` 14px; `small` 12px, the denser geometry. */
36
36
  size?: 'default' | 'small'
37
+ /**
38
+ * Names the row for assistive tech — "Views", "Filters".
39
+ *
40
+ * A `tablist` with no name is announced as a bare "tab list", which says
41
+ * nothing and says the same nothing twice on a page with two of them
42
+ * (measured 2026-09-08: no name, and no way to give one). Where a visible
43
+ * heading already names it, point at that with `aria-labelledby` instead.
44
+ */
45
+ 'aria-label'?: string
46
+ 'aria-labelledby'?: string
37
47
  /** Lands on the outer box, around the row. */
38
48
  className?: string
39
49
  }
40
50
 
41
- export function Tabs<T extends string>({ tabs, active, onChange, size = 'default', className }: TabsProps<T>) {
51
+ export function Tabs<T extends string>({ tabs, active, onChange, size = 'default', className, ...aria }: TabsProps<T>) {
42
52
  return (
43
53
  <BaseTabs.Root
44
54
  value={active}
@@ -50,7 +60,12 @@ export function Tabs<T extends string>({ tabs, active, onChange, size = 'default
50
60
  }}
51
61
  className={className}
52
62
  >
53
- <BaseTabs.List activateOnFocus className="flex items-center gap-2">
63
+ <BaseTabs.List
64
+ activateOnFocus
65
+ aria-label={aria['aria-label']}
66
+ aria-labelledby={aria['aria-labelledby']}
67
+ className="flex items-center gap-2"
68
+ >
54
69
  {tabs.map((tab) => (
55
70
  <BaseTabs.Tab
56
71
  key={tab.id}
package/src/TextInput.mdx CHANGED
@@ -30,8 +30,14 @@ import { TextInput } from '@estiva-app/ui'
30
30
  ```
31
31
 
32
32
  - It is a native `<input>` with a ref — every native prop passes through.
33
- - Name it: a Field's label is visual, so give the input an `aria-label`
34
- too.
33
+ Base UI's `Input` since stage 3, which is what finds a surrounding
34
+ `Field`.
35
+ - **Inside a `Field`, the label names it. Add nothing.** This page said the
36
+ opposite until stage 3 — "a Field's label is visual, so give the input an
37
+ `aria-label` too" — and that advice was already stale at SHA-17. An
38
+ `aria-label` there is now ignored, because the label wins.
39
+ - **Outside a `Field`, name it yourself** with `aria-label`: a placeholder
40
+ is not a name.
35
41
  - `disabled` gets the disabled surface and text; say why with a tooltip.
36
42
 
37
43
  ## Props
package/src/TextInput.tsx CHANGED
@@ -1,23 +1,26 @@
1
1
  import { forwardRef, type InputHTMLAttributes } from 'react'
2
+ import { Input } from '@base-ui/react/input'
2
3
  import { cn } from './cn'
3
- import { useFieldControlId } from './Field'
4
4
 
5
5
  /**
6
6
  * Peek's TextInput (2026-08-28): the inset field with a 8px radius, 14px
7
7
  * text, the focus border in every theme (Katerina, 2026-08-28: Ship's inputs
8
8
  * focus like Signal's) — and Signal's glow on top.
9
9
  * Plus a disabled look (Ship's addition): the disabled surface and text,
10
- * no pointer.
10
+ * no pointer. On Base UI's `Input` since stage 3 of the migration
11
+ * (2026-09-07).
12
+ *
13
+ * Base UI's `Input` is a native `<input>` that finds a surrounding `Field` on
14
+ * its own, so the id plumbing this used to do — `useFieldControlId`, and the
15
+ * context behind it — is gone. Outside a `Field` it behaves as before: the
16
+ * field context has a default, so nothing has to be wrapped.
11
17
  */
12
18
  export type TextInputProps = InputHTMLAttributes<HTMLInputElement>
13
19
 
14
- export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput({ className, type = 'text', id, ...props }, ref) {
15
- // A surrounding Field names this control (SHA-17); an explicit id still wins.
16
- const controlId = useFieldControlId(id)
20
+ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput({ className, type = 'text', ...props }, ref) {
17
21
  return (
18
- <input
22
+ <Input
19
23
  ref={ref}
20
- id={controlId}
21
24
  type={type}
22
25
  className={cn(
23
26
  'bg-bg-inset border border-border-default focus:border-border-focus rounded-lg px-3 py-2',
package/src/Textarea.mdx CHANGED
@@ -28,8 +28,12 @@ import { Textarea } from '@estiva-app/ui'
28
28
  ```
29
29
 
30
30
  - Resizing is off by design — size it with `rows` or your layout.
31
- - Native `<textarea>` with a ref; every native prop passes through; give
32
- it an accessible name.
31
+ - Native `<textarea>` with a ref; every native prop passes through. It is
32
+ Base UI's `Field.Control` told to render a textarea since stage 3 — the
33
+ part `Input` is built on — because Base UI has an `Input` and no
34
+ textarea.
35
+ - **Inside a `Field`, the label names it. Add nothing.** Outside one, give
36
+ it an `aria-label`.
33
37
 
34
38
  ## Props
35
39
 
package/src/Textarea.tsx CHANGED
@@ -1,20 +1,25 @@
1
1
  import { forwardRef, type TextareaHTMLAttributes } from 'react'
2
+ import { Field as BaseField } from '@base-ui/react/field'
2
3
  import { cn } from './cn'
3
- import { useFieldControlId } from './Field'
4
4
 
5
5
  /**
6
6
  * Peek's Textarea (2026-08-28), verbatim: TextInput's look on a textarea
7
- * that does not resize. Plus a disabled look (Ship's addition).
7
+ * that does not resize. Plus a disabled look (Ship's addition). On Base UI's
8
+ * `Field.Control` since stage 3 of the migration (2026-09-07).
9
+ *
10
+ * Base UI has an `Input` part but no textarea, so this is `Field.Control`
11
+ * rendering a `<textarea>` — the same part `Input` is built on, told which
12
+ * element to be. It finds a surrounding `Field` on its own, which is what
13
+ * retired `useFieldControlId`; outside one it renders a plain textarea, as
14
+ * before.
8
15
  */
9
16
  export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>
10
17
 
11
- export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea({ className, id, ...props }, ref) {
12
- // A surrounding Field names this control (SHA-17); an explicit id still wins.
13
- const controlId = useFieldControlId(id)
18
+ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea({ className, ...props }, ref) {
14
19
  return (
15
- <textarea
16
- ref={ref}
17
- id={controlId}
20
+ <BaseField.Control
21
+ ref={ref as React.Ref<HTMLElement>}
22
+ render={<textarea />}
18
23
  className={cn(
19
24
  'bg-bg-inset border border-border-default focus:border-border-focus rounded-lg px-3 py-2',
20
25
  'text-[14px] leading-[1.4] font-normal text-text-primary placeholder:text-text-muted',
@@ -23,7 +28,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
23
28
  'signal:transition-shadow signal:focus:shadow-focus-ring',
24
29
  className,
25
30
  )}
26
- {...props}
31
+ {...(props as object)}
27
32
  />
28
33
  )
29
34
  })
@@ -0,0 +1,104 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as ToolbarStories from './Toolbar.stories'
3
+
4
+ <Meta of={ToolbarStories} />
5
+
6
+ # Toolbar
7
+
8
+ A strip of controls that behaves as **one** control: Tab in, arrow keys along,
9
+ Tab out — on the elevated box a floating strip needs.
10
+
11
+ <Canvas of={ToolbarStories.Default} />
12
+
13
+ ## When
14
+
15
+ - **Any row of icon buttons that act on the same thing** — a formatting strip,
16
+ a card's actions, a set of view switches. This is the case it exists for.
17
+ - A row that mixes buttons with a field, like a link editor: the field is
18
+ **ToolbarInput** and keeps the arrow keys for its caret.
19
+ - Groups of those, divided by **ToolbarSeparator**.
20
+
21
+ <Canvas of={ToolbarStories.Grouped} />
22
+
23
+ ## When not
24
+
25
+ - A list of verbs you open, choose from, and close → **Menu**.
26
+ - Two answers to a question — Cancel and Save — → plain **Button**s. They are
27
+ separate answers and each deserves its own Tab stop.
28
+ - Reactions to choose from → **ReactionPicker**, which is this with the
29
+ emoji row already drawn.
30
+ - One value from a set → **Select**.
31
+
32
+ ## How
33
+
34
+ ```tsx
35
+ import { Toolbar, ToolbarButton, ToolbarSeparator } from '@estiva-app/ui'
36
+
37
+ <Toolbar aria-label="Formatting">
38
+ <ToolbarButton aria-label="Bold" tooltip="Bold" tooltipShortcut="Cmd+B" onClick={bold}>
39
+ <IconBold size={16} stroke={1.5} />
40
+ </ToolbarButton>
41
+ <ToolbarSeparator />
42
+ <ToolbarButton aria-label="Link" tooltip="Link" onClick={link}>
43
+ <IconLink size={16} stroke={1.5} />
44
+ </ToolbarButton>
45
+ </Toolbar>
46
+ ```
47
+
48
+ - **`aria-label` is required.** A toolbar with no name is announced as a bare
49
+ "toolbar", which says nothing — and says the same nothing twice on a page
50
+ with two.
51
+ - **`ToolbarButton` is this package's `IconButton`**, joined to the walk: the
52
+ same square, the same variants, the same `tooltip`, `tooltipShortcut` and
53
+ `disabledReason`. It still owes an `aria-label`, because it is still an
54
+ icon-only control. **It must be inside a `Toolbar`** — Base UI throws
55
+ otherwise, since a part with no strip has no walk to join. A control *beside*
56
+ a strip is an `IconButton`.
57
+ - **It draws the box** — see below — and `surface={false}` drops it.
58
+ - **A disabled control keeps its place in the walk.** A strip whose controls
59
+ come and go from the arrow keys as their state changes is a strip you cannot
60
+ learn, and a `disabledReason` you cannot reach is a reason nobody reads.
61
+ - **`orientation="vertical"`** walks with ↑ and ↓ instead.
62
+
63
+ <Canvas of={ToolbarStories.WithAField} />
64
+
65
+ <Canvas of={ToolbarStories.WithADisabledControl} />
66
+
67
+ ### The box
68
+
69
+ A toolbar floats over what it acts on — a card, a paragraph, an image — so it
70
+ draws the elevated surface that separates it from that: **the same `MenuPanel`
71
+ a `Menu` draws**, because a floating strip and a floating list are the same
72
+ box. Peek had built this twice and the two had already drifted: one
73
+ `rounded-sm` with `shadow-sm`, the other `rounded-lg` with `shadow-lg`.
74
+
75
+ `surface={false}` for a strip inside something that draws that box already — a
76
+ `Popover`, a dialog, a card's own panel.
77
+
78
+ **A `Popover` holding a toolbar asks for `side="top"`.** A strip acts on what
79
+ is under it, so it stands over that rather than on top of it. That is a
80
+ preference: Base UI measures the room and flips when there is none, which is
81
+ why the placement is its job and not arithmetic of ours.
82
+
83
+ <Canvas of={ToolbarStories.OnAnExistingSurface} />
84
+
85
+ ## Keys
86
+
87
+ | Input | What happens |
88
+ |---|---|
89
+ | Tab | onto the strip — once, wherever the walk left it |
90
+ | ← / → | move between controls, and wrap at the ends (↑ / ↓ when vertical) |
91
+ | Enter / Space | the focused control's own action |
92
+ | Tab again | leaves the strip entirely |
93
+ | ← / → **inside a `ToolbarInput`** | the caret's, not the strip's — Tab resumes the walk |
94
+
95
+ **Home and End do nothing**, and that is Base UI's choice rather than an
96
+ oversight: its composite implements them behind a flag `Toolbar.Root` does not
97
+ pass. The arrows wrap, which reaches both ends in one press of a strip this
98
+ size. A test pins it, so the day it changes this page is wrong on purpose.
99
+
100
+ <Canvas of={ToolbarStories.Vertical} />
101
+
102
+ ## Props
103
+
104
+ <Controls of={ToolbarStories.Default} />