@estiva-app/ui 0.13.1 → 0.14.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 +1 -1
- package/dist/Avatar.d.ts.map +1 -1
- package/dist/AvatarGroup.d.ts.map +1 -1
- package/dist/Divider.d.ts +5 -2
- package/dist/Divider.d.ts.map +1 -1
- package/dist/EmptyState.d.ts +3 -0
- package/dist/EmptyState.d.ts.map +1 -1
- package/dist/Property.d.ts +7 -0
- package/dist/Property.d.ts.map +1 -1
- package/dist/Reaction.d.ts +12 -6
- package/dist/Reaction.d.ts.map +1 -1
- package/dist/TextInput.d.ts +10 -1
- package/dist/TextInput.d.ts.map +1 -1
- package/dist/Toast.d.ts +19 -6
- package/dist/Toast.d.ts.map +1 -1
- package/dist/Toolbar.d.ts +1 -1
- package/dist/Toolbar.d.ts.map +1 -1
- package/dist/index.js +272 -245
- package/dist/index.js.map +3 -3
- package/package.json +1 -1
- package/src/AttachmentCard.tsx +14 -14
- package/src/Avatar.mdx +7 -2
- package/src/Avatar.picture.test.tsx +60 -0
- package/src/Avatar.stories.tsx +1 -1
- package/src/Avatar.tsx +50 -14
- package/src/AvatarGroup.tsx +1 -0
- package/src/Breadcrumb.tsx +2 -2
- package/src/Checkbox.tsx +1 -1
- package/src/Chip.tsx +1 -1
- package/src/DialogShell.stories.tsx +1 -1
- package/src/Divider.mdx +3 -2
- package/src/Divider.test.tsx +15 -0
- package/src/Divider.tsx +11 -9
- package/src/EditableText.stories.tsx +2 -2
- package/src/EmptyState.mdx +3 -2
- package/src/EmptyState.stories.tsx +7 -3
- package/src/EmptyState.test.tsx +15 -0
- package/src/EmptyState.tsx +4 -1
- package/src/IconButton.stories.tsx +1 -1
- package/src/Kbd.tsx +3 -3
- package/src/Menu.stories.tsx +2 -2
- package/src/Menu.tsx +3 -3
- package/src/Popover.stories.tsx +1 -1
- package/src/PreviewCard.stories.tsx +5 -5
- package/src/Property.mdx +8 -0
- package/src/Property.stories.tsx +4 -4
- package/src/Property.test.tsx +44 -0
- package/src/Property.tsx +23 -8
- package/src/RailItem.tsx +1 -1
- package/src/Reaction.mdx +14 -3
- package/src/Reaction.test.tsx +62 -0
- package/src/Reaction.tsx +21 -12
- package/src/ReactionPicker.tsx +1 -1
- package/src/SectionLabel.stories.tsx +1 -1
- package/src/SectionLabel.tsx +1 -1
- package/src/Select.tsx +3 -3
- package/src/TextInput.mdx +6 -0
- package/src/TextInput.stories.tsx +45 -0
- package/src/TextInput.test.tsx +38 -0
- package/src/TextInput.tsx +16 -4
- package/src/Textarea.tsx +1 -1
- package/src/Toast.mdx +28 -4
- package/src/Toast.stories.tsx +12 -1
- package/src/Toast.test.tsx +137 -0
- package/src/Toast.tsx +127 -83
- package/src/Toolbar.tsx +4 -2
- package/src/cn.ts +1 -1
- package/stories/Choosing.mdx +1 -0
- package/stories/TokensPage.tsx +6 -1
- package/tailwind-preset.js +7 -0
package/package.json
CHANGED
package/src/AttachmentCard.tsx
CHANGED
|
@@ -80,9 +80,9 @@ const formatBytes = (bytes: number) => {
|
|
|
80
80
|
/** `'PDF'`, `'ZIP'`, `'DOCX'`, `'FILE'`. */
|
|
81
81
|
const typeLabelOf = (name: string) => extensionOf(name).toUpperCase() || 'FILE'
|
|
82
82
|
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
const
|
|
83
|
+
const TILE_CLASSES = 'size-9 rounded-md bg-bg-active flex items-center justify-center shrink-0 text-text-secondary'
|
|
84
|
+
const NAME_CLASSES = 'text-caption font-medium text-text-primary truncate'
|
|
85
|
+
const NOTE_CLASSES = 'text-small tracking-wide leading-tight truncate'
|
|
86
86
|
|
|
87
87
|
function TypeIcon({ name }: { name: string }) {
|
|
88
88
|
const Icon = ICON_BY_EXTENSION[extensionOf(name)] ?? IconFile
|
|
@@ -191,7 +191,7 @@ export function AttachmentCard({
|
|
|
191
191
|
className={cn('group relative flex items-center gap-2 w-[200px] p-1.5 pr-3', failed ? 'border-error-default' : warning && 'border-warning-default', className)}
|
|
192
192
|
{...props}
|
|
193
193
|
>
|
|
194
|
-
<div className={cn(
|
|
194
|
+
<div className={cn(TILE_CLASSES, 'overflow-hidden')}>
|
|
195
195
|
{state === 'uploading' ? (
|
|
196
196
|
<IconLoader2 size={16} stroke={1.5} className="animate-spin" />
|
|
197
197
|
) : failed ? (
|
|
@@ -201,15 +201,15 @@ export function AttachmentCard({
|
|
|
201
201
|
) : image && src ? (
|
|
202
202
|
<img src={src} alt={name} className="size-full object-cover" />
|
|
203
203
|
) : (
|
|
204
|
-
<span className="text-
|
|
204
|
+
<span className="text-menu font-semibold">{typeLabelOf(name)}</span>
|
|
205
205
|
)}
|
|
206
206
|
</div>
|
|
207
207
|
<div className="flex flex-col gap-[1px] min-w-0">
|
|
208
|
-
<Truncating text={name} className={
|
|
208
|
+
<Truncating text={name} className={NAME_CLASSES} />
|
|
209
209
|
<Truncating
|
|
210
210
|
text={(failed || warning ? note : state === 'uploading' ? (note ?? 'Uploading…') : (note ?? sizeText)) ?? ''}
|
|
211
211
|
hint={noteHint}
|
|
212
|
-
className={cn(
|
|
212
|
+
className={cn(NOTE_CLASSES, failed ? 'text-error-default' : warning ? 'text-warning-default' : 'text-text-secondary')}
|
|
213
213
|
/>
|
|
214
214
|
</div>
|
|
215
215
|
{/* On Base UI's Button, as InputChip's ✕ is (Katerina, 2026-09-14): IconButton is a 24px square
|
|
@@ -260,12 +260,12 @@ export function AttachmentCard({
|
|
|
260
260
|
if (state === 'unreadable') {
|
|
261
261
|
return (
|
|
262
262
|
<Card fill="inset" unreadable className={cn('flex items-center gap-2 w-[240px] p-1.5 opacity-70', className)} {...props}>
|
|
263
|
-
<div className={
|
|
263
|
+
<div className={TILE_CLASSES}>
|
|
264
264
|
<TypeIcon name={name} />
|
|
265
265
|
</div>
|
|
266
266
|
<div className="flex flex-col gap-[1px] min-w-0 text-left">
|
|
267
|
-
<Truncating text={name} className={
|
|
268
|
-
<span className={cn(
|
|
267
|
+
<Truncating text={name} className={NAME_CLASSES} />
|
|
268
|
+
<span className={cn(NOTE_CLASSES, 'text-text-secondary')}>{note ?? 'Could not be loaded'}</span>
|
|
269
269
|
</div>
|
|
270
270
|
</Card>
|
|
271
271
|
)
|
|
@@ -291,7 +291,7 @@ export function AttachmentCard({
|
|
|
291
291
|
<div className="block w-full">{picture}</div>
|
|
292
292
|
)}
|
|
293
293
|
<div className="flex items-center gap-1 pl-2 pr-1 py-1 min-w-0">
|
|
294
|
-
<Truncating text={name} className="flex-1 text-
|
|
294
|
+
<Truncating text={name} className="flex-1 text-caption text-text-primary truncate" wrapperClassName="flex-1" />
|
|
295
295
|
{download}
|
|
296
296
|
</div>
|
|
297
297
|
</Card>
|
|
@@ -300,12 +300,12 @@ export function AttachmentCard({
|
|
|
300
300
|
|
|
301
301
|
const body = (
|
|
302
302
|
<>
|
|
303
|
-
<div className={
|
|
303
|
+
<div className={TILE_CLASSES}>
|
|
304
304
|
<TypeIcon name={name} />
|
|
305
305
|
</div>
|
|
306
306
|
<div className="flex flex-col gap-[1px] min-w-0 text-left">
|
|
307
|
-
<Truncating text={name} className={
|
|
308
|
-
<span className={cn(
|
|
307
|
+
<Truncating text={name} className={NAME_CLASSES} />
|
|
308
|
+
<span className={cn(NOTE_CLASSES, 'text-text-secondary')}>{note ?? `${typeLabelOf(name)} · ${sizeText}`}</span>
|
|
309
309
|
</div>
|
|
310
310
|
</>
|
|
311
311
|
)
|
package/src/Avatar.mdx
CHANGED
|
@@ -30,11 +30,16 @@ import { Avatar } from '@estiva-app/ui'
|
|
|
30
30
|
<Avatar name={person.name} src={person.picture} size={24} />
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
- The caller resolves `src`.
|
|
34
|
-
initials — nothing
|
|
33
|
+
- The caller resolves `src`. While the picture loads the tile stays empty;
|
|
34
|
+
once it arrives it fills the tile; if it fails, the initials show — nothing
|
|
35
|
+
to handle. It is Base UI's `Avatar`, which watches the picture load.
|
|
35
36
|
- Initials come from a **name, never a key**: the first letter of the first
|
|
36
37
|
two words, skipping words that open with a symbol. An unnamed person is
|
|
37
38
|
the silhouette, not a `?` — a question mark reads as an error badge.
|
|
39
|
+
- The initials are centred on the capitals themselves — cap height to
|
|
40
|
+
baseline — not on the line of text around them (D27). A single face can
|
|
41
|
+
still sit up to half a pixel off: the screen rounds text to its pixel grid
|
|
42
|
+
wherever the tile lands.
|
|
38
43
|
- The colour behind initials is per person, chosen from the name, the same
|
|
39
44
|
everywhere they appear. It is a fallback palette, not a token — themes
|
|
40
45
|
have no say in it.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* A face's picture in its three moments, as the Avatar page claims them: the
|
|
4
|
+
* `<img>` is there from the first render and the tile shows nothing else while
|
|
5
|
+
* it loads; once it loads the initials are gone; if it fails, the initials.
|
|
6
|
+
*
|
|
7
|
+
* jsdom never loads an image, so the tests fire the `<img>`'s own events.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
10
|
+
import { cleanup, fireEvent, render } from '@testing-library/react'
|
|
11
|
+
import { Avatar } from './Avatar'
|
|
12
|
+
|
|
13
|
+
afterEach(cleanup)
|
|
14
|
+
|
|
15
|
+
const tile = (container: HTMLElement) => container.firstElementChild as HTMLElement
|
|
16
|
+
|
|
17
|
+
describe('Avatar picture', () => {
|
|
18
|
+
it('the <img> is in the tile from the first render, with its address', () => {
|
|
19
|
+
const { container } = render(<Avatar name="Ana Duarte" src="https://example.test/face.png" />)
|
|
20
|
+
const img = tile(container).querySelector('img')
|
|
21
|
+
expect(img?.getAttribute('src')).toBe('https://example.test/face.png')
|
|
22
|
+
// First in the tile, so it covers it; the initials wait below the hidden overflow.
|
|
23
|
+
expect(tile(container).firstElementChild).toBe(img)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('once the picture loads, the initials are gone', () => {
|
|
27
|
+
const { container } = render(<Avatar name="Ana Duarte" src="https://example.test/face.png" />)
|
|
28
|
+
fireEvent.load(tile(container).querySelector('img')!)
|
|
29
|
+
expect(tile(container).textContent).toBe('')
|
|
30
|
+
expect(tile(container).querySelector('img')?.hasAttribute('data-error')).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('if the picture fails, it is hidden and the initials show', () => {
|
|
34
|
+
const { container } = render(<Avatar name="Ana Duarte" src="https://example.test/face.png" />)
|
|
35
|
+
const img = tile(container).querySelector('img')!
|
|
36
|
+
fireEvent.error(img)
|
|
37
|
+
expect(img.hasAttribute('data-error')).toBe(true)
|
|
38
|
+
expect(img.className).toContain('data-[error]:hidden')
|
|
39
|
+
expect(tile(container).textContent).toBe('AD')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('no picture: the initials, and no <img>', () => {
|
|
43
|
+
const { container } = render(<Avatar name="Ana Duarte" />)
|
|
44
|
+
expect(tile(container).querySelector('img')).toBeNull()
|
|
45
|
+
expect(tile(container).textContent).toBe('AD')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('the initials are centred on their capitals, not their line box (D27)', () => {
|
|
49
|
+
const { container } = render(<Avatar name="Ana Duarte" />)
|
|
50
|
+
const initials = tile(container).querySelector('span')
|
|
51
|
+
expect(initials?.textContent).toBe('AD')
|
|
52
|
+
expect(initials?.className).toBe('[text-box:trim-both_cap_alphabetic]')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('no picture and no name: the silhouette', () => {
|
|
56
|
+
const { container } = render(<Avatar />)
|
|
57
|
+
expect(tile(container).querySelector('svg')).not.toBeNull()
|
|
58
|
+
expect(tile(container).textContent).toBe('')
|
|
59
|
+
})
|
|
60
|
+
})
|
package/src/Avatar.stories.tsx
CHANGED
|
@@ -37,7 +37,7 @@ export const Sizes: Story = {
|
|
|
37
37
|
{[16, 20, 22, 24, 28, 32, 36].map((size) => (
|
|
38
38
|
<div key={size} className="flex flex-col items-center gap-1">
|
|
39
39
|
<Avatar {...args} size={size} />
|
|
40
|
-
<span className="text-
|
|
40
|
+
<span className="text-caption text-text-muted">{size}</span>
|
|
41
41
|
</div>
|
|
42
42
|
))}
|
|
43
43
|
</div>
|
package/src/Avatar.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Avatar as BaseAvatar } from '@base-ui/react/avatar'
|
|
2
2
|
import { IconUserFilled } from '@tabler/icons-react'
|
|
3
3
|
import { cn } from './cn'
|
|
4
4
|
|
|
@@ -14,6 +14,10 @@ import { cn } from './cn'
|
|
|
14
14
|
* three-line wrapper that resolves the picture; Ship passes the relay's
|
|
15
15
|
* `kind:0` picture straight in.
|
|
16
16
|
*
|
|
17
|
+
* On Base UI's `Avatar` since stage 6 of the migration (2026-09-14): Base UI
|
|
18
|
+
* loads the picture and says when it has arrived or failed, and the initials
|
|
19
|
+
* or the silhouette are its fallback. The hand-kept "broken" state is gone.
|
|
20
|
+
*
|
|
17
21
|
* Initials come from a *name*, never from a key: a component that accepted a
|
|
18
22
|
* pubkey would eventually show one (Ship's ruling, 2026-08-24). An unnamed
|
|
19
23
|
* person is the silhouette — `?` on a bright gradient read as an error badge
|
|
@@ -44,7 +48,7 @@ export const initialsFor = (name: string) => {
|
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
export interface AvatarProps {
|
|
47
|
-
/** The picture URL, resolved by the caller.
|
|
51
|
+
/** The picture URL, resolved by the caller. Until it arrives, and if it fails, the initials show. */
|
|
48
52
|
src?: string
|
|
49
53
|
/** The person's name — where the initials and the colour come from. */
|
|
50
54
|
name?: string
|
|
@@ -68,11 +72,11 @@ export interface AvatarProps {
|
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
export function Avatar({ src, name, alt = '', size = 36, label: spoken, className }: AvatarProps) {
|
|
71
|
-
const [broken, setBroken] = useState(false)
|
|
72
75
|
const label = name || alt
|
|
73
|
-
const picture = src && !broken ? src : undefined
|
|
74
76
|
return (
|
|
75
|
-
<
|
|
77
|
+
<BaseAvatar.Root
|
|
78
|
+
// A div, as it was drawn: Base UI's default is a span.
|
|
79
|
+
render={<div />}
|
|
76
80
|
/* Named or silent, never half of either: with a `label` the tile is one
|
|
77
81
|
image with one name, and everything inside it is that image's pixels;
|
|
78
82
|
without one it is not in the accessibility tree at all. */
|
|
@@ -80,10 +84,25 @@ export function Avatar({ src, name, alt = '', size = 36, label: spoken, classNam
|
|
|
80
84
|
className={cn('rounded-sm overflow-hidden shrink-0 bg-bg-inset', className)}
|
|
81
85
|
style={{ width: size, height: size }}
|
|
82
86
|
>
|
|
83
|
-
{
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
{src && (
|
|
88
|
+
<BaseAvatar.Image
|
|
89
|
+
src={src}
|
|
90
|
+
alt=""
|
|
91
|
+
/*
|
|
92
|
+
`keepMounted`: the <img> is in the tile from the first render, as it
|
|
93
|
+
always was, so a picture still loading leaves the tile empty rather
|
|
94
|
+
than flashing initials and swapping them out, and an app's test that
|
|
95
|
+
finds the <img> still finds it. The fallback is rendered after it,
|
|
96
|
+
below the tile's hidden overflow, until the picture arrives (then it
|
|
97
|
+
unmounts) or fails (then the picture is hidden and it moves up).
|
|
98
|
+
*/
|
|
99
|
+
keepMounted
|
|
100
|
+
className="w-full h-full object-cover data-[error]:hidden"
|
|
101
|
+
/>
|
|
102
|
+
)}
|
|
103
|
+
{label ? (
|
|
104
|
+
<BaseAvatar.Fallback
|
|
105
|
+
render={<div />}
|
|
87
106
|
/*
|
|
88
107
|
`leading-none` is load-bearing (2026-09-03). Centring a flex child
|
|
89
108
|
centres its LINE BOX, and a line box reserves room under the
|
|
@@ -99,21 +118,38 @@ export function Avatar({ src, name, alt = '', size = 36, label: spoken, classNam
|
|
|
99
118
|
*/
|
|
100
119
|
className="w-full h-full flex items-center justify-center font-semibold leading-none"
|
|
101
120
|
style={{
|
|
121
|
+
/* eslint-disable no-restricted-syntax -- the per-person palette (the note at
|
|
122
|
+
the top): eight hues picked from the name, the one ink that reads on all
|
|
123
|
+
of them, and a size that follows `size`. None of it can be a token. */
|
|
102
124
|
// The one ink colour that reads on all eight hues, which are a
|
|
103
125
|
// palette rather than tokens (see the note at the top) — so its
|
|
104
126
|
// ink cannot be a token either.
|
|
105
127
|
color: '#08121c',
|
|
106
128
|
fontSize: Math.round(size * 0.36),
|
|
107
129
|
background: `linear-gradient(160deg, color-mix(in srgb, ${hueFor(label)} 92%, #fff) 0%, color-mix(in srgb, ${hueFor(label)} 70%, #0b0d11) 100%)`,
|
|
130
|
+
/* eslint-enable no-restricted-syntax */
|
|
108
131
|
}}
|
|
109
132
|
>
|
|
110
|
-
{
|
|
111
|
-
|
|
133
|
+
{/*
|
|
134
|
+
D27 (ruled 2026-09-08, built 2026-09-14): what is centred is the
|
|
135
|
+
capitals' own box, cap height to baseline, not the line box. The
|
|
136
|
+
line box's ascent and descent are rounded to whole pixels before
|
|
137
|
+
it is centred, which put the baseline up to 0.73px from where
|
|
138
|
+
centred capitals put it (at 24px: 14.5 against 15.23); trimmed, it
|
|
139
|
+
lands within 0.06px. On screen, averaged over eight sub-pixel
|
|
140
|
+
positions, the ink leaned 0.66px high at 24px, 0.44px at 28px and
|
|
141
|
+
0.27px low at 22px; now every size averages within 0.07px of the
|
|
142
|
+
middle. Any one face can still sit up to half a pixel off, because
|
|
143
|
+
the screen rounds text to its pixel grid wherever the tile falls.
|
|
144
|
+
A browser without `text-box` draws the old line box, as before.
|
|
145
|
+
*/}
|
|
146
|
+
<span className="[text-box:trim-both_cap_alphabetic]">{initialsFor(label)}</span>
|
|
147
|
+
</BaseAvatar.Fallback>
|
|
112
148
|
) : (
|
|
113
|
-
<div className="w-full h-full bg-accent-muted flex items-center justify-center text-text-muted">
|
|
149
|
+
<BaseAvatar.Fallback render={<div />} className="w-full h-full bg-accent-muted flex items-center justify-center text-text-muted">
|
|
114
150
|
<IconUserFilled size={Math.round(size * 0.5)} />
|
|
115
|
-
</
|
|
151
|
+
</BaseAvatar.Fallback>
|
|
116
152
|
)}
|
|
117
|
-
</
|
|
153
|
+
</BaseAvatar.Root>
|
|
118
154
|
)
|
|
119
155
|
}
|
package/src/AvatarGroup.tsx
CHANGED
|
@@ -55,6 +55,7 @@ export function AvatarGroup({ members, size = 24 }: AvatarGroupProps) {
|
|
|
55
55
|
<span
|
|
56
56
|
key={i}
|
|
57
57
|
className="relative flex rounded-sm"
|
|
58
|
+
/* eslint-disable-next-line no-restricted-syntax -- the ring's width is the `ring` prop, so it cannot be a class; its colour is the surface token */
|
|
58
59
|
style={{ marginRight: -overlap, boxShadow: `0 0 0 ${ring}px var(--bg-surface)` }}
|
|
59
60
|
>
|
|
60
61
|
{/* Each face says whose it is: a stack stands on its own, with no
|
package/src/Breadcrumb.tsx
CHANGED
|
@@ -69,7 +69,7 @@ export function Breadcrumb({ items, className }: BreadcrumbProps) {
|
|
|
69
69
|
}, [measure, items])
|
|
70
70
|
|
|
71
71
|
return (
|
|
72
|
-
<nav ref={navRef} aria-label="Breadcrumb" className={cn('flex min-w-0 items-center gap-1.5 text-
|
|
72
|
+
<nav ref={navRef} aria-label="Breadcrumb" className={cn('flex min-w-0 items-center gap-1.5 text-body-2', className)}>
|
|
73
73
|
{items.map((item, index) => {
|
|
74
74
|
const last = index === items.length - 1
|
|
75
75
|
// The mono size is an arbitrary value (the caption token) because this
|
|
@@ -81,7 +81,7 @@ export function Breadcrumb({ items, className }: BreadcrumbProps) {
|
|
|
81
81
|
// A mono crumb is a ref — the identity. It never gives up width to a
|
|
82
82
|
// long name beside it (the LongName story always claimed "the ref
|
|
83
83
|
// stays"; flexbox was squeezing it anyway until this line).
|
|
84
|
-
item.mono && 'shrink-0 font-mono text-
|
|
84
|
+
item.mono && 'shrink-0 font-mono text-caption',
|
|
85
85
|
tone,
|
|
86
86
|
)
|
|
87
87
|
const setLabelRef = (el: HTMLElement | null) => {
|
package/src/Checkbox.tsx
CHANGED
|
@@ -37,7 +37,7 @@ export interface CheckboxProps {
|
|
|
37
37
|
|
|
38
38
|
function squareClasses(checked: boolean, disabled: boolean, interactive: boolean, className?: string) {
|
|
39
39
|
return cn(
|
|
40
|
-
'inline-flex items-center justify-center size-4 shrink-0 rounded-
|
|
40
|
+
'inline-flex items-center justify-center size-4 shrink-0 rounded-sm border transition-colors',
|
|
41
41
|
checked ? 'bg-accent-primary border-accent-primary text-text-inverse' : 'bg-transparent border-border-strong hover:border-text-muted',
|
|
42
42
|
disabled && 'opacity-50 pointer-events-none',
|
|
43
43
|
interactive ? 'cursor-pointer' : 'pointer-events-none',
|
package/src/Chip.tsx
CHANGED
|
@@ -44,7 +44,7 @@ export function Chip({ type = 'neutral', label, leadingIcon, trailingIcon, class
|
|
|
44
44
|
// the screenshot diff caught every descender cut off. `clip` is the
|
|
45
45
|
// one overflow that leaves the other axis visible. A chip with no cap
|
|
46
46
|
// draws exactly as before.
|
|
47
|
-
<span className="min-w-0 overflow-x-clip text-ellipsis whitespace-nowrap text-chip signal:font-mono signal:text-
|
|
47
|
+
<span className="min-w-0 overflow-x-clip text-ellipsis whitespace-nowrap text-chip signal:font-mono signal:text-small signal:font-semibold signal:tracking-wide signal:tabular-nums">{label}</span>
|
|
48
48
|
)}
|
|
49
49
|
{trailingIcon && <span className="flex size-3 shrink-0 items-center justify-center">{trailingIcon}</span>}
|
|
50
50
|
</div>
|
|
@@ -82,7 +82,7 @@ export const WithHeaderContent: Story = {
|
|
|
82
82
|
<Button variant="primary">Save</Button>
|
|
83
83
|
</>
|
|
84
84
|
),
|
|
85
|
-
children: <p className="text-body-2
|
|
85
|
+
children: <p className="text-body-2 text-text-primary">The header is the caller's, and the ✕ is still the dialog's.</p>,
|
|
86
86
|
},
|
|
87
87
|
}
|
|
88
88
|
|
package/src/Divider.mdx
CHANGED
|
@@ -6,8 +6,9 @@ import * as DividerStories from './Divider.stories'
|
|
|
6
6
|
# Divider
|
|
7
7
|
|
|
8
8
|
A hairline in `border-subtle`. Horizontal it is inset 12px each side;
|
|
9
|
-
`orientation="vertical"` stands it up to its row's height, no inset. It
|
|
10
|
-
|
|
9
|
+
`orientation="vertical"` stands it up to its row's height, no inset. It is
|
|
10
|
+
Base UI's `Separator`, which gives it the `separator` role and its
|
|
11
|
+
orientation for assistive tech.
|
|
11
12
|
|
|
12
13
|
<Canvas of={DividerStories.Default} />
|
|
13
14
|
|
package/src/Divider.test.tsx
CHANGED
|
@@ -12,6 +12,21 @@ describe('Divider', () => {
|
|
|
12
12
|
expect(screen.getByRole('separator').getAttribute('aria-orientation')).toBe('horizontal')
|
|
13
13
|
})
|
|
14
14
|
|
|
15
|
+
it('is Base UI’s Separator: the orientation is also a data attribute, for both orientations', () => {
|
|
16
|
+
render(
|
|
17
|
+
<>
|
|
18
|
+
<Divider />
|
|
19
|
+
<Divider orientation="vertical" />
|
|
20
|
+
<Divider label="Today" />
|
|
21
|
+
</>,
|
|
22
|
+
)
|
|
23
|
+
const [horizontal, vertical, labelled] = screen.getAllByRole('separator')
|
|
24
|
+
expect(horizontal.getAttribute('data-orientation')).toBe('horizontal')
|
|
25
|
+
expect(vertical.getAttribute('aria-orientation')).toBe('vertical')
|
|
26
|
+
expect(vertical.getAttribute('data-orientation')).toBe('vertical')
|
|
27
|
+
expect(labelled.getAttribute('data-orientation')).toBe('horizontal')
|
|
28
|
+
})
|
|
29
|
+
|
|
15
30
|
it('with a label, the separator is named by it and the words are drawn once', () => {
|
|
16
31
|
render(<Divider label="New since you last read this" tone="warning" />)
|
|
17
32
|
const rule = screen.getByRole('separator', { name: 'New since you last read this' })
|
package/src/Divider.tsx
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import { Separator } from '@base-ui/react/separator'
|
|
1
2
|
import { cn } from './cn'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Peek's Divider (2026-08-28): a hairline in `border-subtle`, inset 12px each
|
|
5
6
|
* side. Plus what Ship added: `orientation="vertical"` — the same hairline
|
|
6
|
-
* standing up, stretching to its row's height, no inset
|
|
7
|
-
*
|
|
7
|
+
* standing up, stretching to its row's height, no inset.
|
|
8
|
+
*
|
|
9
|
+
* On Base UI's `Separator` since stage 6 of the migration (2026-09-14). It
|
|
10
|
+
* writes the `separator` role and `aria-orientation` this component used to
|
|
11
|
+
* write by hand, and adds `data-orientation`. Nothing it draws changed.
|
|
8
12
|
*/
|
|
9
13
|
export interface DividerProps {
|
|
10
14
|
orientation?: 'horizontal' | 'vertical'
|
|
@@ -29,22 +33,20 @@ export function Divider({ orientation = 'horizontal', label, tone = 'default', c
|
|
|
29
33
|
if (label && orientation === 'horizontal') {
|
|
30
34
|
const line = tone === 'warning' ? 'bg-warning-muted' : 'bg-border-subtle'
|
|
31
35
|
return (
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
aria-orientation="horizontal"
|
|
36
|
+
<Separator
|
|
37
|
+
orientation="horizontal"
|
|
35
38
|
aria-label={label}
|
|
36
39
|
className={cn('flex shrink-0 items-center gap-2 mx-3', className)}
|
|
37
40
|
>
|
|
38
41
|
<span aria-hidden="true" className={cn('h-px flex-1', line)} />
|
|
39
42
|
<span className={cn('shrink-0 text-caption', tone === 'warning' ? 'text-warning-default' : 'text-text-muted')}>{label}</span>
|
|
40
43
|
<span aria-hidden="true" className={cn('h-px flex-1', line)} />
|
|
41
|
-
</
|
|
44
|
+
</Separator>
|
|
42
45
|
)
|
|
43
46
|
}
|
|
44
47
|
return (
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
aria-orientation={orientation}
|
|
48
|
+
<Separator
|
|
49
|
+
orientation={orientation}
|
|
48
50
|
// shrink-0 on both orientations: a 1px flex child in an overflowing
|
|
49
51
|
// column shrinks to nothing, and a hairline that renders 0px tall is a
|
|
50
52
|
// hairline nobody can see — Peek's `/` menu had been drawing two of
|
|
@@ -10,7 +10,7 @@ const meta = {
|
|
|
10
10
|
placeholder: 'Add a title',
|
|
11
11
|
label: 'Title',
|
|
12
12
|
onCommit: () => true as boolean,
|
|
13
|
-
className: 'text-
|
|
13
|
+
className: 'text-body-1 font-medium',
|
|
14
14
|
},
|
|
15
15
|
argTypes: { onCommit: { control: false } },
|
|
16
16
|
decorators: [(Story) => <div className="w-80">{Story()}</div>],
|
|
@@ -44,7 +44,7 @@ export const Multiline: Story = {
|
|
|
44
44
|
placeholder: 'Add a description',
|
|
45
45
|
label: 'Description',
|
|
46
46
|
multiline: true,
|
|
47
|
-
className: 'text-
|
|
47
|
+
className: 'text-body-2',
|
|
48
48
|
},
|
|
49
49
|
}
|
|
50
50
|
|
package/src/EmptyState.mdx
CHANGED
|
@@ -18,8 +18,9 @@ alone and left-aligned when one section of a page is.
|
|
|
18
18
|
bubble for one that matches the subject.
|
|
19
19
|
- **`section`** — one section of a page is empty and the page has other
|
|
20
20
|
things on it: no replies under an item, a search with no results. The
|
|
21
|
-
line alone, left-aligned, no icon
|
|
22
|
-
|
|
21
|
+
line alone, left-aligned, no icon, in the quiet look — 12px caption in
|
|
22
|
+
the muted colour, like a Field's helper line. A section's emptiness is
|
|
23
|
+
one line among the page's content, not a stage of its own.
|
|
23
24
|
|
|
24
25
|
<Canvas of={EmptyStateStories.Section} />
|
|
25
26
|
|
|
@@ -17,15 +17,19 @@ type Story = StoryObj<typeof meta>
|
|
|
17
17
|
|
|
18
18
|
/** The `page` manner: the default icon over the caller's words, in the middle of its box both ways. */
|
|
19
19
|
export const Page: Story = { decorators: [inBox] }
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// axe color-contrast is off on the two section stories until PLAN.md stage 0.10 is ruled: the section
|
|
21
|
+
// line is muted caption text, 3.94:1 on --bg-base in signal (AA 4.5:1); 6.08:1 in ship.
|
|
22
|
+
const sectionContrastDeferred = { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } }
|
|
23
|
+
|
|
24
|
+
/** The `section` manner: the words alone, left-aligned, in the quiet caption — one line in a page that has other things on it. */
|
|
25
|
+
export const Section: Story = { args: { scope: 'section' }, decorators: [inBox], parameters: sectionContrastDeferred }
|
|
22
26
|
export const LongerMessage: Story = { args: { message: 'No items yet. Add one from any list.' }, decorators: [inBox] }
|
|
23
27
|
export const CustomIcon: Story = { args: { icon: <IconLock size={16} stroke={1.5} />, message: 'Nothing you can read here yet.' }, decorators: [inBox] }
|
|
24
28
|
|
|
25
29
|
/** One box, written once, holding the rows or the empty state: its padding places both, so the line starts where the first row does. */
|
|
26
30
|
export const InsideTheRowsBox: Story = {
|
|
27
31
|
args: { scope: 'section' },
|
|
28
|
-
parameters: { controls: { disable: true } },
|
|
32
|
+
parameters: { controls: { disable: true }, ...sectionContrastDeferred },
|
|
29
33
|
render: (args) => {
|
|
30
34
|
const box = 'flex h-[160px] w-[260px] flex-col gap-2 rounded-lg border border-border-default p-4'
|
|
31
35
|
return (
|
package/src/EmptyState.test.tsx
CHANGED
|
@@ -31,6 +31,21 @@ describe('EmptyState', () => {
|
|
|
31
31
|
expect(line.className).not.toContain('text-center')
|
|
32
32
|
})
|
|
33
33
|
|
|
34
|
+
it('a section is the quiet line: caption size, muted colour (Katerina, 2026-09-15)', () => {
|
|
35
|
+
render(<EmptyState scope="section" message="Nothing here yet." />)
|
|
36
|
+
const classes = screen.getByText('Nothing here yet.').className.split(' ')
|
|
37
|
+
expect(classes).toContain('text-caption')
|
|
38
|
+
expect(classes).toContain('text-text-muted')
|
|
39
|
+
expect(classes).not.toContain('text-body-2')
|
|
40
|
+
expect(classes).not.toContain('text-text-secondary')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('a caller’s placement class does not take the quiet look away', () => {
|
|
44
|
+
render(<EmptyState scope="section" className="py-8" message="Nothing here yet." />)
|
|
45
|
+
const classes = screen.getByText('Nothing here yet.').className.split(' ')
|
|
46
|
+
expect(classes).toEqual(expect.arrayContaining(['text-caption', 'text-text-muted', 'py-8']))
|
|
47
|
+
})
|
|
48
|
+
|
|
34
49
|
it('a section carries no space of its own, so the box it sits in decides where it starts', () => {
|
|
35
50
|
// Katerina, 2026-09-14: the box is written once and holds the rows or the empty
|
|
36
51
|
// state; the empty state is never padded. So it may carry no padding or margin.
|
package/src/EmptyState.tsx
CHANGED
|
@@ -15,6 +15,9 @@ import { cn } from './cn'
|
|
|
15
15
|
* - `section` — one section of a page is empty, and the page has other
|
|
16
16
|
* things on it: the line alone, left-aligned, no icon. A section's
|
|
17
17
|
* emptiness is a line among the page's content, not a stage of its own.
|
|
18
|
+
* It is the quiet line — caption size in the muted colour, as a Field's
|
|
19
|
+
* helper line is — so beside rows it reads as "nothing here", not as one
|
|
20
|
+
* more row (Katerina, 2026-09-15: "not that intense", "smaller font").
|
|
18
21
|
*
|
|
19
22
|
* The message is the caller's — a shared component has no words of its own
|
|
20
23
|
* for what is missing.
|
|
@@ -30,7 +33,7 @@ export interface EmptyStateProps {
|
|
|
30
33
|
|
|
31
34
|
export function EmptyState({ icon, message, scope = 'page', className }: EmptyStateProps) {
|
|
32
35
|
if (scope === 'section') {
|
|
33
|
-
return <p className={cn('text-
|
|
36
|
+
return <p className={cn('text-caption text-text-muted', className)}>{message}</p>
|
|
34
37
|
}
|
|
35
38
|
return (
|
|
36
39
|
<div className={cn('flex flex-1 flex-col items-center justify-center gap-2', className)}>
|
|
@@ -41,7 +41,7 @@ export const AllVariants: Story = {
|
|
|
41
41
|
<IconButton variant={variant} disabled aria-label="Settings">
|
|
42
42
|
<IconSettings className="size-4" stroke={1.5} />
|
|
43
43
|
</IconButton>
|
|
44
|
-
<span className="text-
|
|
44
|
+
<span className="text-caption text-text-muted">{variant}</span>
|
|
45
45
|
</div>
|
|
46
46
|
))}
|
|
47
47
|
</div>
|
package/src/Kbd.tsx
CHANGED
|
@@ -30,9 +30,9 @@ export function Kbd({ children, className }: KbdProps) {
|
|
|
30
30
|
return (
|
|
31
31
|
<kbd
|
|
32
32
|
className={cn(
|
|
33
|
-
'inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded-sm border border-border-strong bg-bg-inset px-1 py-px font-sans text-
|
|
34
|
-
'signal:border-b-2 signal:pt-[2px] signal:pb-px signal:bg-bg-wash signal:font-mono signal:text-
|
|
35
|
-
'ship:border-b-2 ship:pt-[2px] ship:pb-px ship:bg-bg-wash ship:font-mono ship:text-
|
|
33
|
+
'inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded-sm border border-border-strong bg-bg-inset px-1 py-px font-sans text-caption text-text-secondary',
|
|
34
|
+
'signal:border-b-2 signal:pt-[2px] signal:pb-px signal:bg-bg-wash signal:font-mono signal:text-small',
|
|
35
|
+
'ship:border-b-2 ship:pt-[2px] ship:pb-px ship:bg-bg-wash ship:font-mono ship:text-small',
|
|
36
36
|
className,
|
|
37
37
|
)}
|
|
38
38
|
>
|
package/src/Menu.stories.tsx
CHANGED
|
@@ -72,7 +72,7 @@ export const WithARow: Story = {
|
|
|
72
72
|
render: () => (
|
|
73
73
|
<MenuPanel className="w-64">
|
|
74
74
|
<MenuRow>
|
|
75
|
-
<span className="min-w-0 flex-1 truncate text-
|
|
75
|
+
<span className="min-w-0 flex-1 truncate text-body-2 text-text-primary">Item one</span>
|
|
76
76
|
<SectionLabel className="text-text-secondary">Label</SectionLabel>
|
|
77
77
|
</MenuRow>
|
|
78
78
|
<Divider className="my-1" />
|
|
@@ -134,7 +134,7 @@ export const OnACard: Story = {
|
|
|
134
134
|
parameters: { controls: { disable: true } },
|
|
135
135
|
render: () => (
|
|
136
136
|
<div className="flex w-[420px] items-start gap-3 rounded-lg border border-border-default p-3">
|
|
137
|
-
<span className="min-w-0 flex-1 text-
|
|
137
|
+
<span className="min-w-0 flex-1 text-body-2 text-text-primary">
|
|
138
138
|
A card. Press the control at the end of the row, then walk down the rows and out to the submenu.
|
|
139
139
|
</span>
|
|
140
140
|
<Menu
|
package/src/Menu.tsx
CHANGED
|
@@ -426,10 +426,10 @@ function MenuItemBody({ label, children, size = 'default', description, leading,
|
|
|
426
426
|
colour. Ship's copy said `text-sm`, which was never the ramp. */}
|
|
427
427
|
{children ?? (
|
|
428
428
|
<span className={cn('flex min-w-0 flex-1 flex-col', size === 'tall' && 'gap-[2px]')}>
|
|
429
|
-
<span className={cn('truncate text-
|
|
429
|
+
<span className={cn('truncate text-body-2', destructive ? 'text-error-default' : 'text-text-primary')}>
|
|
430
430
|
{label}
|
|
431
431
|
</span>
|
|
432
|
-
{description && <span className="truncate text-
|
|
432
|
+
{description && <span className="truncate text-caption text-text-secondary">{description}</span>}
|
|
433
433
|
</span>
|
|
434
434
|
)}
|
|
435
435
|
{(edge || hint) && (
|
|
@@ -538,7 +538,7 @@ export function EnterHint({ target }: { target?: string }) {
|
|
|
538
538
|
<span className="flex shrink-0 items-center gap-1.5 text-text-muted">
|
|
539
539
|
<Kbd>↩ Enter</Kbd>
|
|
540
540
|
{target && (
|
|
541
|
-
<span className="text-
|
|
541
|
+
<span className="text-menu signal:font-mono signal:text-small signal:tracking-wider">{target}</span>
|
|
542
542
|
)}
|
|
543
543
|
</span>
|
|
544
544
|
)
|
package/src/Popover.stories.tsx
CHANGED
|
@@ -167,7 +167,7 @@ export const FromASelection: Story = {
|
|
|
167
167
|
can, which is the real case; `-1` gives this one the same property
|
|
168
168
|
without adding a Tab stop. Without it focus is left on the document
|
|
169
169
|
body when the panel closes — measured. */}
|
|
170
|
-
<p ref={body} tabIndex={-1} onMouseUp={read} onKeyUp={readOnKey} className="text-
|
|
170
|
+
<p ref={body} tabIndex={-1} onMouseUp={read} onKeyUp={readOnKey} className="text-body-2 text-text-primary outline-none">
|
|
171
171
|
Select any part of this sentence with the pointer, and a panel appears
|
|
172
172
|
above the selection rather than beside a button — because a selection
|
|
173
173
|
is not a control and there is no trigger to hang from.
|