@estiva-app/ui 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Avatar.d.ts +13 -1
- package/dist/Avatar.d.ts.map +1 -1
- package/dist/AvatarGroup.d.ts.map +1 -1
- package/dist/Banner.d.ts +16 -1
- package/dist/Banner.d.ts.map +1 -1
- package/dist/Breadcrumb.d.ts +9 -0
- package/dist/Breadcrumb.d.ts.map +1 -1
- package/dist/Button.d.ts +12 -2
- package/dist/Button.d.ts.map +1 -1
- package/dist/Checkbox.d.ts +2 -0
- package/dist/Checkbox.d.ts.map +1 -1
- package/dist/Chip.d.ts +3 -2
- package/dist/Chip.d.ts.map +1 -1
- package/dist/ChipInput.d.ts +3 -1
- package/dist/ChipInput.d.ts.map +1 -1
- package/dist/ConfirmDialog.d.ts +5 -0
- package/dist/ConfirmDialog.d.ts.map +1 -1
- package/dist/DialogShell.d.ts +27 -3
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/EditableText.d.ts +6 -0
- package/dist/EditableText.d.ts.map +1 -1
- package/dist/Field.d.ts +42 -25
- package/dist/Field.d.ts.map +1 -1
- package/dist/IconButton.d.ts +3 -2
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/IdentityMenu.d.ts +25 -13
- package/dist/IdentityMenu.d.ts.map +1 -1
- package/dist/Menu.d.ts +65 -82
- package/dist/Menu.d.ts.map +1 -1
- package/dist/PersonTrigger.d.ts.map +1 -1
- package/dist/Popover.d.ts +86 -0
- package/dist/Popover.d.ts.map +1 -0
- package/dist/PreviewCard.d.ts +39 -0
- package/dist/PreviewCard.d.ts.map +1 -0
- package/dist/Reaction.d.ts.map +1 -1
- package/dist/ReactionPicker.d.ts +55 -0
- package/dist/ReactionPicker.d.ts.map +1 -0
- package/dist/SearchInput.d.ts +5 -0
- package/dist/SearchInput.d.ts.map +1 -1
- package/dist/Select.d.ts +26 -16
- package/dist/Select.d.ts.map +1 -1
- package/dist/Tabs.d.ts +11 -1
- package/dist/Tabs.d.ts.map +1 -1
- package/dist/TextInput.d.ts +7 -1
- package/dist/TextInput.d.ts.map +1 -1
- package/dist/Textarea.d.ts +8 -1
- package/dist/Textarea.d.ts.map +1 -1
- package/dist/Toolbar.d.ts +95 -0
- package/dist/Toolbar.d.ts.map +1 -0
- package/dist/Tooltip.d.ts +36 -10
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/cn.d.ts.map +1 -1
- package/dist/fit.d.ts +7 -72
- package/dist/fit.d.ts.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +882 -763
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/Avatar.mdx +17 -0
- package/src/Avatar.name.test.tsx +99 -0
- package/src/Avatar.tsx +25 -3
- package/src/AvatarGroup.tsx +4 -1
- package/src/Banner.mdx +10 -2
- package/src/Banner.stories.tsx +29 -0
- package/src/Banner.test.tsx +60 -0
- package/src/Banner.tsx +35 -3
- package/src/Breadcrumb.mdx +5 -2
- package/src/Breadcrumb.test.tsx +44 -0
- package/src/Breadcrumb.tsx +10 -2
- package/src/Button.compose.test.tsx +119 -0
- package/src/Button.mdx +7 -5
- package/src/Button.test.tsx +2 -1
- package/src/Button.tsx +21 -5
- package/src/Checkbox.tsx +3 -0
- package/src/Chip.tsx +3 -2
- package/src/ChipInput.tsx +4 -0
- package/src/ConfirmDialog.mdx +20 -1
- package/src/ConfirmDialog.test.tsx +109 -0
- package/src/ConfirmDialog.tsx +6 -0
- package/src/DialogShell.mdx +18 -4
- package/src/DialogShell.stories.tsx +56 -6
- package/src/DialogShell.test.tsx +184 -0
- package/src/DialogShell.tsx +108 -39
- package/src/EditableText.mdx +6 -1
- package/src/EditableText.test.tsx +156 -0
- package/src/EditableText.tsx +23 -5
- package/src/Field.mdx +23 -3
- package/src/Field.stories.tsx +35 -0
- package/src/Field.test.tsx +117 -27
- package/src/Field.tsx +86 -58
- package/src/IconButton.mdx +6 -2
- package/src/IconButton.test.tsx +5 -2
- package/src/IconButton.tsx +12 -6
- package/src/IdentityMenu.mdx +29 -6
- package/src/IdentityMenu.stories.tsx +41 -6
- package/src/IdentityMenu.tsx +74 -47
- package/src/Kbd.stories.tsx +3 -3
- package/src/Menu.mdx +88 -75
- package/src/Menu.stories.tsx +120 -52
- package/src/Menu.test.tsx +315 -0
- package/src/Menu.tsx +341 -251
- package/src/MenuItem.stories.tsx +135 -98
- package/src/NavItem.mdx +1 -1
- package/src/PersonTrigger.mdx +20 -14
- package/src/PersonTrigger.tsx +23 -1
- package/src/Popover.mdx +130 -0
- package/src/Popover.stories.tsx +222 -0
- package/src/Popover.test.tsx +188 -0
- package/src/Popover.tsx +153 -0
- package/src/PreviewCard.mdx +98 -0
- package/src/PreviewCard.stories.tsx +168 -0
- package/src/PreviewCard.test.tsx +83 -0
- package/src/PreviewCard.tsx +91 -0
- package/src/Rail.mdx +63 -0
- package/src/Rail.stories.tsx +81 -0
- package/src/Reaction.mdx +4 -2
- package/src/Reaction.tsx +4 -2
- package/src/ReactionPicker.mdx +85 -0
- package/src/ReactionPicker.stories.tsx +120 -0
- package/src/ReactionPicker.test.tsx +118 -0
- package/src/ReactionPicker.tsx +88 -0
- package/src/SearchInput.mdx +6 -2
- package/src/SearchInput.tsx +7 -1
- package/src/Select.mdx +16 -5
- package/src/Select.test.tsx +157 -0
- package/src/Select.tsx +89 -214
- package/src/Sidebar.mdx +6 -2
- package/src/Tabs.mdx +5 -0
- package/src/Tabs.test.tsx +15 -0
- package/src/Tabs.tsx +17 -2
- package/src/TextInput.mdx +8 -2
- package/src/TextInput.tsx +10 -7
- package/src/Textarea.mdx +6 -2
- package/src/Textarea.tsx +14 -9
- package/src/Toolbar.mdx +104 -0
- package/src/Toolbar.stories.tsx +142 -0
- package/src/Toolbar.test.tsx +198 -0
- package/src/Toolbar.tsx +158 -0
- package/src/Tooltip.mdx +43 -5
- package/src/Tooltip.stories.tsx +26 -0
- package/src/Tooltip.test.tsx +195 -0
- package/src/Tooltip.tsx +150 -48
- package/src/cn.ts +1 -1
- package/src/fit.test.ts +101 -0
- package/src/fit.ts +19 -63
- package/src/index.ts +6 -3
- package/stories/Choosing.mdx +8 -3
- package/tailwind-preset.js +7 -0
- package/src/Menu.fit.test.ts +0 -90
- 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 {
|
|
3
|
-
import {
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
96
|
+
<BaseSelect.Value className="truncate">{() => selected?.label ?? placeholder}</BaseSelect.Value>
|
|
226
97
|
</span>
|
|
227
|
-
<
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
249
|
-
<
|
|
130
|
+
{options.map((option) => (
|
|
131
|
+
<BaseSelect.Item
|
|
250
132
|
key={option.value}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
<
|
|
142
|
+
<BaseSelect.ItemText className="truncate">{option.label}</BaseSelect.ItemText>
|
|
270
143
|
</span>
|
|
271
|
-
|
|
272
|
-
|
|
144
|
+
<BaseSelect.ItemIndicator
|
|
145
|
+
render={<IconCheck size={16} stroke={1.5} className="shrink-0 text-text-secondary" />}
|
|
146
|
+
/>
|
|
147
|
+
</BaseSelect.Item>
|
|
273
148
|
))}
|
|
274
|
-
</
|
|
275
|
-
|
|
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="
|
|
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="
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
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',
|
|
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
|
-
<
|
|
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
|
|
32
|
-
|
|
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,
|
|
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
|
-
<
|
|
16
|
-
ref={ref}
|
|
17
|
-
|
|
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
|
})
|
package/src/Toolbar.mdx
ADDED
|
@@ -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} />
|