@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/package.json
CHANGED
package/src/Avatar.mdx
CHANGED
|
@@ -42,6 +42,23 @@ import { Avatar } from '@estiva-app/ui'
|
|
|
42
42
|
rounder geometry wraps the Avatar concentrically — it does not override
|
|
43
43
|
the Avatar's own radius.
|
|
44
44
|
|
|
45
|
+
### What it says out loud
|
|
46
|
+
|
|
47
|
+
**A face is silent by default**, and that is the important half: almost every
|
|
48
|
+
face in the suite sits beside the name it belongs to — in a `Person`, in a
|
|
49
|
+
`MenuItem`'s `leading`, in a row — and a picture that spoke there would say the
|
|
50
|
+
name twice. It used to. Measured 2026-09-08, before this was true: a `Person`
|
|
51
|
+
inside a button announced **"AD Ana Duarte"** (the initials read as text), and
|
|
52
|
+
with a picture **"Ana Duarte Ana Duarte"**.
|
|
53
|
+
|
|
54
|
+
**`label` names a face that stands on its own** — the compact `PersonTrigger`,
|
|
55
|
+
a stack of members — and the tile becomes one image with one name.
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
<Avatar name={person.name} /> {/* beside a name: silent */}
|
|
59
|
+
<Avatar name={person.name} label={person.name} /> {/* alone: says who it is */}
|
|
60
|
+
```
|
|
61
|
+
|
|
45
62
|
<Canvas of={AvatarStories.Palette} />
|
|
46
63
|
|
|
47
64
|
## Props
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What a face is called, everywhere one appears.
|
|
4
|
+
*
|
|
5
|
+
* These are the names an app's own tests compute — `getByRole('button', { name })`
|
|
6
|
+
* runs the same algorithm — and every one of them was wrong until 2026-09-08:
|
|
7
|
+
* a face read as its initials, and a face beside a name read the name twice.
|
|
8
|
+
* The initials are a drawing of a name, not text.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
11
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
12
|
+
import { Avatar } from './Avatar'
|
|
13
|
+
import { AvatarGroup } from './AvatarGroup'
|
|
14
|
+
import { MenuItem, MenuPanel } from './Menu'
|
|
15
|
+
import { Person } from './Person'
|
|
16
|
+
import { PersonTrigger } from './PersonTrigger'
|
|
17
|
+
|
|
18
|
+
afterEach(cleanup)
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `getByRole`'s `name` matches the *accessible* name exactly, computed by the
|
|
22
|
+
* same algorithm a browser uses — so a query that finds nothing is the failure
|
|
23
|
+
* being asserted, and the message names what it looked for. This is also how
|
|
24
|
+
* both apps query their own controls, which is what makes it the right test.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
describe('a face beside a name says nothing of its own', () => {
|
|
28
|
+
it('a button holding a Person is called the person, once', () => {
|
|
29
|
+
render(
|
|
30
|
+
<button type="button">
|
|
31
|
+
<Person name="Ana Duarte" />
|
|
32
|
+
</button>,
|
|
33
|
+
)
|
|
34
|
+
// "AD Ana Duarte" before: the initials were read as text.
|
|
35
|
+
expect(screen.getByRole('button', { name: 'Ana Duarte' })).toBeTruthy()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('and once when there is a picture rather than initials', () => {
|
|
39
|
+
render(
|
|
40
|
+
<button type="button">
|
|
41
|
+
<Person name="Ana Duarte" picture="/a.png" />
|
|
42
|
+
</button>,
|
|
43
|
+
)
|
|
44
|
+
// "Ana Duarte Ana Duarte" before: the alt text and the name were both read.
|
|
45
|
+
expect(screen.getByRole('button', { name: 'Ana Duarte' })).toBeTruthy()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('a menu row led by a face is called by its label', () => {
|
|
49
|
+
render(
|
|
50
|
+
<MenuPanel>
|
|
51
|
+
<MenuItem label="Ana Duarte" leading={<Avatar name="Ana Duarte" size={32} />} onClick={() => {}} />
|
|
52
|
+
</MenuPanel>,
|
|
53
|
+
)
|
|
54
|
+
expect(screen.getByRole('button', { name: 'Ana Duarte' })).toBeTruthy()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('a bare face is not in the accessibility tree at all', () => {
|
|
58
|
+
const { container } = render(<Avatar name="Ana Duarte" />)
|
|
59
|
+
expect(container.firstElementChild?.getAttribute('aria-hidden')).toBe('true')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('a face that stands on its own says whose it is', () => {
|
|
64
|
+
it('the compact PersonTrigger is called the person', () => {
|
|
65
|
+
render(<PersonTrigger name="Ana Duarte" compact />)
|
|
66
|
+
// "AD" before — the initials, and the only name the control had.
|
|
67
|
+
expect(screen.getByRole('button', { name: 'Ana Duarte' })).toBeTruthy()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('a caller may name the destination instead', () => {
|
|
71
|
+
render(<PersonTrigger name="Ana Duarte" compact aria-label="Account menu" />)
|
|
72
|
+
expect(screen.getByRole('button', { name: 'Account menu' })).toBeTruthy()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('an unnamed person falls back to what the caller calls them', () => {
|
|
76
|
+
render(<PersonTrigger fallback="Anonymous" compact />)
|
|
77
|
+
expect(screen.getByRole('button', { name: 'Anonymous' })).toBeTruthy()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('the row shape is called the person, without the initials in front', () => {
|
|
81
|
+
render(<PersonTrigger name="Ana Duarte" />)
|
|
82
|
+
expect(screen.getByRole('button', { name: 'Ana Duarte' })).toBeTruthy()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('a stack of faces names each of them', () => {
|
|
86
|
+
render(<AvatarGroup members={[{ name: 'Ana Duarte' }, { name: 'Ben Carter' }]} />)
|
|
87
|
+
// "AD" and "BC" before.
|
|
88
|
+
expect(screen.getAllByRole('img').map((el) => el.getAttribute('aria-label'))).toEqual(['Ana Duarte', 'Ben Carter'])
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('the compact trigger keeps a focus ring', () => {
|
|
93
|
+
/** `focus:outline-none` took the browser's ring away and put nothing back,
|
|
94
|
+
* so the account trigger had no visible focus at all. */
|
|
95
|
+
it('does not remove its own outline', () => {
|
|
96
|
+
render(<PersonTrigger name="Ana Duarte" compact />)
|
|
97
|
+
expect(screen.getByRole('button').className).not.toContain('outline-none')
|
|
98
|
+
})
|
|
99
|
+
})
|
package/src/Avatar.tsx
CHANGED
|
@@ -52,17 +52,36 @@ export interface AvatarProps {
|
|
|
52
52
|
alt?: string
|
|
53
53
|
/** Pixels. The scale: 16 · 24 · 32 · 36 (default). */
|
|
54
54
|
size?: number
|
|
55
|
+
/**
|
|
56
|
+
* **Say the person's name out loud**, for a face that stands on its own —
|
|
57
|
+
* `PersonTrigger`'s compact shape, a stack of members.
|
|
58
|
+
*
|
|
59
|
+
* A face is silent by default, because almost every face in the suite sits
|
|
60
|
+
* beside the name it belongs to: `Person`, a `MenuItem`'s `leading`, a row.
|
|
61
|
+
* A picture that spoke there would double the name — measured 2026-09-08,
|
|
62
|
+
* before this existed: a `Person` in a button announced **"AD Ana Duarte"**,
|
|
63
|
+
* and with a picture **"Ana Duarte Ana Duarte"**. The initials are a drawing
|
|
64
|
+
* of a name, not text, and a screen reader was reading them as text.
|
|
65
|
+
*/
|
|
66
|
+
label?: string
|
|
55
67
|
className?: string
|
|
56
68
|
}
|
|
57
69
|
|
|
58
|
-
export function Avatar({ src, name, alt = '', size = 36, className }: AvatarProps) {
|
|
70
|
+
export function Avatar({ src, name, alt = '', size = 36, label: spoken, className }: AvatarProps) {
|
|
59
71
|
const [broken, setBroken] = useState(false)
|
|
60
72
|
const label = name || alt
|
|
61
73
|
const picture = src && !broken ? src : undefined
|
|
62
74
|
return (
|
|
63
|
-
<div
|
|
75
|
+
<div
|
|
76
|
+
/* Named or silent, never half of either: with a `label` the tile is one
|
|
77
|
+
image with one name, and everything inside it is that image's pixels;
|
|
78
|
+
without one it is not in the accessibility tree at all. */
|
|
79
|
+
{...(spoken ? { role: 'img', 'aria-label': spoken } : { 'aria-hidden': true })}
|
|
80
|
+
className={cn('rounded-sm overflow-hidden shrink-0 bg-bg-inset', className)}
|
|
81
|
+
style={{ width: size, height: size }}
|
|
82
|
+
>
|
|
64
83
|
{picture ? (
|
|
65
|
-
<img src={picture} alt=
|
|
84
|
+
<img src={picture} alt="" className="w-full h-full object-cover" onError={() => setBroken(true)} />
|
|
66
85
|
) : label ? (
|
|
67
86
|
<div
|
|
68
87
|
/*
|
|
@@ -80,6 +99,9 @@ export function Avatar({ src, name, alt = '', size = 36, className }: AvatarProp
|
|
|
80
99
|
*/
|
|
81
100
|
className="w-full h-full flex items-center justify-center font-semibold leading-none"
|
|
82
101
|
style={{
|
|
102
|
+
// The one ink colour that reads on all eight hues, which are a
|
|
103
|
+
// palette rather than tokens (see the note at the top) — so its
|
|
104
|
+
// ink cannot be a token either.
|
|
83
105
|
color: '#08121c',
|
|
84
106
|
fontSize: Math.round(size * 0.36),
|
|
85
107
|
background: `linear-gradient(160deg, color-mix(in srgb, ${hueFor(label)} 92%, #fff) 0%, color-mix(in srgb, ${hueFor(label)} 70%, #0b0d11) 100%)`,
|
package/src/AvatarGroup.tsx
CHANGED
|
@@ -57,7 +57,10 @@ export function AvatarGroup({ members, size = 24 }: AvatarGroupProps) {
|
|
|
57
57
|
className="relative flex rounded-sm"
|
|
58
58
|
style={{ marginRight: -overlap, boxShadow: `0 0 0 ${ring}px var(--bg-surface)` }}
|
|
59
59
|
>
|
|
60
|
-
|
|
60
|
+
{/* Each face says whose it is: a stack stands on its own, with no
|
|
61
|
+
name beside it to borrow. It read as its initials before —
|
|
62
|
+
"AD BC CD" — which is the drawing, not the person. */}
|
|
63
|
+
<Avatar size={size} name={member.name} src={member.picture} label={member.name} />
|
|
61
64
|
</span>
|
|
62
65
|
))}
|
|
63
66
|
</div>
|
package/src/Banner.mdx
CHANGED
|
@@ -23,6 +23,10 @@ line, one of four tones, gone when there is nothing to say.
|
|
|
23
23
|
## When not
|
|
24
24
|
|
|
25
25
|
- Feedback about one action, briefly → **Toast**.
|
|
26
|
+
- Something that floats over the page and must **not** fade → still
|
|
27
|
+
**Toast**, with `durationMs: 0` and an action. A banner is the strip
|
|
28
|
+
under the header at the app's full width; a floating pill is not one, even
|
|
29
|
+
a permanent one (Katerina, D21).
|
|
26
30
|
- A standing property of one thing → **Chip** on that thing.
|
|
27
31
|
- A notice inside the content flow with its own action — that is a
|
|
28
32
|
different component (not in the package yet).
|
|
@@ -37,8 +41,12 @@ import { Banner } from '@estiva-app/ui'
|
|
|
37
41
|
|
|
38
42
|
- Render it only while there is something to say — an empty strip is not
|
|
39
43
|
a state.
|
|
40
|
-
- One line of plain text, `body-2`. If it needs a
|
|
41
|
-
|
|
44
|
+
- One line of plain text, `body-2`. If it needs a **second line or an
|
|
45
|
+
action of its own**, it has outgrown this component.
|
|
46
|
+
- `onDismiss` adds an ✕ at the right-hand end (Katerina, D21). Use it where
|
|
47
|
+
the reader decides the notice is done with, rather than the app. The row
|
|
48
|
+
is 40px rather than 36px with it, because the button is taller than the
|
|
49
|
+
line of text; without it, nothing about the strip changes.
|
|
42
50
|
|
|
43
51
|
## Props
|
|
44
52
|
|
package/src/Banner.stories.tsx
CHANGED
|
@@ -26,6 +26,14 @@ export const Info: Story = { args: { tone: 'info', children: 'A new version is a
|
|
|
26
26
|
|
|
27
27
|
export const Warning: Story = { args: { tone: 'warning', children: 'This workspace is read-only until sign-in.' } }
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* With a dismiss (D21). The row is 40px rather than 36px, because the button
|
|
31
|
+
* is taller than the line of text.
|
|
32
|
+
*/
|
|
33
|
+
export const Dismissible: Story = {
|
|
34
|
+
args: { tone: 'warning', children: 'This workspace is read-only until sign-in.', onDismiss: () => {} },
|
|
35
|
+
}
|
|
36
|
+
|
|
29
37
|
/** All four tones, stacked. */
|
|
30
38
|
export const AllTones: Story = {
|
|
31
39
|
parameters: { controls: { disable: true } },
|
|
@@ -38,3 +46,24 @@ export const AllTones: Story = {
|
|
|
38
46
|
</div>
|
|
39
47
|
),
|
|
40
48
|
}
|
|
49
|
+
|
|
50
|
+
/** The same four with a dismiss, so the two heights can be compared. */
|
|
51
|
+
export const AllTonesDismissible: Story = {
|
|
52
|
+
parameters: { controls: { disable: true } },
|
|
53
|
+
render: () => (
|
|
54
|
+
<div className="flex flex-col gap-2">
|
|
55
|
+
<Banner tone="ok" onDismiss={() => {}}>
|
|
56
|
+
Public key copied.
|
|
57
|
+
</Banner>
|
|
58
|
+
<Banner tone="info" onDismiss={() => {}}>
|
|
59
|
+
A new version is available. Reload when convenient.
|
|
60
|
+
</Banner>
|
|
61
|
+
<Banner tone="warning" onDismiss={() => {}}>
|
|
62
|
+
This workspace is read-only until sign-in.
|
|
63
|
+
</Banner>
|
|
64
|
+
<Banner tone="error" onDismiss={() => {}}>
|
|
65
|
+
The last read failed — reconnecting.
|
|
66
|
+
</Banner>
|
|
67
|
+
</div>
|
|
68
|
+
),
|
|
69
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What `Banner.mdx` claims about the dismiss added at stage 3 (D21).
|
|
4
|
+
*
|
|
5
|
+
* The point of the first test is the one that is easy to lose: a banner
|
|
6
|
+
* without `onDismiss` must render exactly what it always did. Every banner in
|
|
7
|
+
* both apps is that one, and a prop that quietly changed their DOM would be a
|
|
8
|
+
* change nobody asked for.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
11
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
12
|
+
import userEvent from '@testing-library/user-event'
|
|
13
|
+
import { Banner } from './Banner'
|
|
14
|
+
|
|
15
|
+
afterEach(cleanup)
|
|
16
|
+
|
|
17
|
+
describe('Banner', () => {
|
|
18
|
+
it('is one element with the text in it when there is no dismiss', () => {
|
|
19
|
+
const { container } = render(<Banner tone="info">Two people are editing this.</Banner>)
|
|
20
|
+
const root = container.firstElementChild!
|
|
21
|
+
expect(root.children).toHaveLength(0)
|
|
22
|
+
expect(root.textContent).toBe('Two people are editing this.')
|
|
23
|
+
expect(screen.queryByRole('button')).toBeNull()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('offers a dismiss when asked, and reports it', async () => {
|
|
27
|
+
const onDismiss = vi.fn()
|
|
28
|
+
const user = userEvent.setup()
|
|
29
|
+
render(
|
|
30
|
+
<Banner tone="warning" onDismiss={onDismiss}>
|
|
31
|
+
The relay is not answering.
|
|
32
|
+
</Banner>,
|
|
33
|
+
)
|
|
34
|
+
await user.click(screen.getByRole('button', { name: 'Dismiss' }))
|
|
35
|
+
expect(onDismiss).toHaveBeenCalledTimes(1)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('lets the caller name the dismiss', () => {
|
|
39
|
+
render(
|
|
40
|
+
<Banner tone="ok" onDismiss={() => {}} dismissLabel="Hide this notice">
|
|
41
|
+
Saved.
|
|
42
|
+
</Banner>,
|
|
43
|
+
)
|
|
44
|
+
expect(screen.getByRole('button', { name: 'Hide this notice' })).toBeTruthy()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('announces an error and stays polite otherwise', () => {
|
|
48
|
+
const { rerender, container } = render(<Banner tone="error">Could not save.</Banner>)
|
|
49
|
+
expect(container.firstElementChild!.getAttribute('role')).toBe('alert')
|
|
50
|
+
rerender(<Banner tone="warning">Could not save.</Banner>)
|
|
51
|
+
expect(container.firstElementChild!.getAttribute('role')).toBe('status')
|
|
52
|
+
// and the same either side of the dismiss
|
|
53
|
+
rerender(
|
|
54
|
+
<Banner tone="error" onDismiss={() => {}}>
|
|
55
|
+
Could not save.
|
|
56
|
+
</Banner>,
|
|
57
|
+
)
|
|
58
|
+
expect(container.firstElementChild!.getAttribute('role')).toBe('alert')
|
|
59
|
+
})
|
|
60
|
+
})
|
package/src/Banner.tsx
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { IconX } from '@tabler/icons-react'
|
|
1
2
|
import { cn } from './cn'
|
|
3
|
+
import { IconButton } from './IconButton'
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* The strip under the header where the app says something happened —
|
|
@@ -10,12 +12,27 @@ import { cn } from './cn'
|
|
|
10
12
|
* rendered, so 14px is what it has always been and what she kept.
|
|
11
13
|
* An `error` announces itself (`role="alert"`); the other tones are
|
|
12
14
|
* polite (`role="status"`).
|
|
15
|
+
*
|
|
16
|
+
* `onDismiss` adds an `✕` at the right-hand end (Katerina, D21,
|
|
17
|
+
* 2026-09-07). Without it the strip is exactly what it was — a banner an
|
|
18
|
+
* app removes by not rendering it. With it the row is 40px rather than
|
|
19
|
+
* 36px, because the button is taller than the line of text.
|
|
20
|
+
*
|
|
21
|
+
* **A banner is not a toast.** This is the strip under the header, at the
|
|
22
|
+
* app's full width, for something that stays true. Transient feedback is
|
|
23
|
+
* `Toast`, and something that floats over the page and must not fade is a
|
|
24
|
+
* `Toast` with `durationMs: 0` — which is what Peek's "topic deleted"
|
|
25
|
+
* notice is (D21), not this.
|
|
13
26
|
*/
|
|
14
27
|
export type BannerTone = 'ok' | 'error' | 'info' | 'warning'
|
|
15
28
|
|
|
16
29
|
export interface BannerProps {
|
|
17
30
|
tone: BannerTone
|
|
18
31
|
children: string
|
|
32
|
+
/** Adds an `✕` at the right-hand end. Absent: the app removes the banner. */
|
|
33
|
+
onDismiss?: () => void
|
|
34
|
+
/** The dismiss button's accessible name. Defaults to "Dismiss". */
|
|
35
|
+
dismissLabel?: string
|
|
19
36
|
className?: string
|
|
20
37
|
}
|
|
21
38
|
|
|
@@ -26,10 +43,25 @@ const TONE_STYLES: Record<BannerTone, string> = {
|
|
|
26
43
|
warning: 'bg-warning-muted text-warning-default',
|
|
27
44
|
}
|
|
28
45
|
|
|
29
|
-
export function Banner({ tone, children, className }: BannerProps) {
|
|
46
|
+
export function Banner({ tone, children, onDismiss, dismissLabel = 'Dismiss', className }: BannerProps) {
|
|
47
|
+
const role = tone === 'error' ? 'alert' : 'status'
|
|
48
|
+
// No dismiss, no row: every banner that predates the prop keeps the DOM and
|
|
49
|
+
// the height it had.
|
|
50
|
+
if (!onDismiss) {
|
|
51
|
+
return (
|
|
52
|
+
<div role={role} className={cn('px-4 py-2 text-body-2', TONE_STYLES[tone], className)}>
|
|
53
|
+
{children}
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
30
57
|
return (
|
|
31
|
-
<div role={
|
|
32
|
-
{children}
|
|
58
|
+
<div role={role} className={cn('flex items-center gap-3 px-4 py-2 text-body-2', TONE_STYLES[tone], className)}>
|
|
59
|
+
<span className="min-w-0 flex-1">{children}</span>
|
|
60
|
+
{/* `text-current` so the ✕ takes the tone's colour rather than the
|
|
61
|
+
muted grey an IconButton wears on a neutral surface. */}
|
|
62
|
+
<IconButton aria-label={dismissLabel} onClick={onDismiss} className="-mr-1 shrink-0 text-current hover:text-current">
|
|
63
|
+
<IconX size={16} stroke={1.5} />
|
|
64
|
+
</IconButton>
|
|
33
65
|
</div>
|
|
34
66
|
)
|
|
35
67
|
}
|
package/src/Breadcrumb.mdx
CHANGED
|
@@ -35,13 +35,16 @@ import { Breadcrumb } from '@estiva-app/ui'
|
|
|
35
35
|
|
|
36
36
|
<Breadcrumb
|
|
37
37
|
items={[
|
|
38
|
-
{ label: 'Documents', href: '
|
|
39
|
-
{ label: 'Quarterly plan', href: '
|
|
38
|
+
{ label: 'Documents', href: '/documents' },
|
|
39
|
+
{ label: 'Quarterly plan', href: '/documents/12' },
|
|
40
40
|
{ label: 'DOC-12', mono: true },
|
|
41
41
|
]}
|
|
42
42
|
/>
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
- A crumb is a plain anchor. In a router app pass `onClick` per crumb and
|
|
46
|
+
navigate in place there — NavItem's rule — and keep the `href` a real
|
|
47
|
+
address, so the link can still be copied or opened in a new tab.
|
|
45
48
|
- Truncation is re-measured as the trail resizes; the tooltip appears and
|
|
46
49
|
disappears with the room the trail actually has. Give the trail a
|
|
47
50
|
`min-w-0` container so it can truncate at all.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the Breadcrumb page claims, pinned: a crumb with an href is a link
|
|
4
|
+
* (even the last one), the last crumb without one is where you are, the
|
|
5
|
+
* separators are never read aloud — and a router app can take the click,
|
|
6
|
+
* which is the one prop the trail lacked (ADOPTION S15, 2026-09-08).
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
9
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
10
|
+
import userEvent from '@testing-library/user-event'
|
|
11
|
+
import { Breadcrumb } from './Breadcrumb'
|
|
12
|
+
|
|
13
|
+
afterEach(cleanup)
|
|
14
|
+
|
|
15
|
+
describe('Breadcrumb', () => {
|
|
16
|
+
it('a crumb with an href is a link, and the last one without is where you are', () => {
|
|
17
|
+
render(<Breadcrumb items={[{ label: 'Documents', href: '/documents' }, { label: 'DOC-12', mono: true }]} />)
|
|
18
|
+
expect(screen.getByRole('link', { name: 'Documents' }).getAttribute('href')).toBe('/documents')
|
|
19
|
+
const here = screen.getByText('DOC-12')
|
|
20
|
+
expect(here.tagName).toBe('SPAN')
|
|
21
|
+
expect(here.getAttribute('aria-current')).toBe('page')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('a last crumb with an href is still a link — a page may end its trail on somewhere to go', () => {
|
|
25
|
+
render(<Breadcrumb items={[{ label: 'Documents', href: '/documents' }, { label: 'Quarterly plan', href: '/documents/12' }]} />)
|
|
26
|
+
const last = screen.getByRole('link', { name: 'Quarterly plan' })
|
|
27
|
+
expect(last.getAttribute('href')).toBe('/documents/12')
|
|
28
|
+
expect(last.getAttribute('aria-current')).toBeNull()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('a router app takes the click through onClick, and the href stays a real address', async () => {
|
|
32
|
+
const onClick = vi.fn((event: { preventDefault: () => void }) => event.preventDefault())
|
|
33
|
+
render(<Breadcrumb items={[{ label: 'Documents', href: '/documents', onClick }, { label: 'DOC-12' }]} />)
|
|
34
|
+
await userEvent.click(screen.getByRole('link', { name: 'Documents' }))
|
|
35
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
36
|
+
expect(screen.getByRole('link', { name: 'Documents' }).getAttribute('href')).toBe('/documents')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('the separators are drawn but never read aloud', () => {
|
|
40
|
+
render(<Breadcrumb items={[{ label: 'Documents', href: '/documents' }, { label: 'DOC-12' }]} />)
|
|
41
|
+
expect(screen.getByRole('navigation', { name: 'Breadcrumb' }).textContent).toContain('/')
|
|
42
|
+
expect(screen.queryByText('/', { ignore: '[aria-hidden="true"]' })).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
})
|
package/src/Breadcrumb.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Fragment, useCallback, useLayoutEffect, useRef, useState } from 'react'
|
|
1
|
+
import { Fragment, useCallback, useLayoutEffect, useRef, useState, type MouseEventHandler } from 'react'
|
|
2
2
|
import { cn } from './cn'
|
|
3
3
|
import { WithTooltip } from './Tooltip'
|
|
4
4
|
|
|
@@ -15,10 +15,18 @@ import { WithTooltip } from './Tooltip'
|
|
|
15
15
|
* (Katerina, 2026-09-01); one that fits shows nothing extra. Truncation is
|
|
16
16
|
* re-measured when the trail resizes, so the tooltip appears and disappears
|
|
17
17
|
* with the room the trail actually has.
|
|
18
|
+
*
|
|
19
|
+
* A crumb with an `href` is a plain anchor, so in a router app a click on it
|
|
20
|
+
* reloads the page. Such an app passes `onClick` per crumb and navigates in
|
|
21
|
+
* place there — NavItem's rule — and the `href` stays a real address so the
|
|
22
|
+
* link can still be copied or opened in a new tab. Ship's breadcrumbs were
|
|
23
|
+
* the last links reloading the whole app (ADOPTION S15, 2026-09-08).
|
|
18
24
|
*/
|
|
19
25
|
export interface Crumb {
|
|
20
26
|
label: string
|
|
21
27
|
href?: string
|
|
28
|
+
/** A router app intercepts the click here. Called only on a crumb with an `href`. */
|
|
29
|
+
onClick?: MouseEventHandler<HTMLAnchorElement>
|
|
22
30
|
/** Set the item in the mono face — a ref, an id. */
|
|
23
31
|
mono?: boolean
|
|
24
32
|
/** Quieter — a label that is not a place. */
|
|
@@ -73,7 +81,7 @@ export function Breadcrumb({ items, className }: BreadcrumbProps) {
|
|
|
73
81
|
labelRefs.current[index] = el
|
|
74
82
|
}
|
|
75
83
|
const crumb = item.href ? (
|
|
76
|
-
<a ref={setLabelRef} href={item.href} className={cn(text, 'hover:text-text-primary')}>
|
|
84
|
+
<a ref={setLabelRef} href={item.href} onClick={item.onClick} className={cn(text, 'hover:text-text-primary')}>
|
|
77
85
|
{item.label}
|
|
78
86
|
</a>
|
|
79
87
|
) : (
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The buttons carry a ref, and a Base UI part can therefore *be* one.
|
|
4
|
+
*
|
|
5
|
+
* `Button` and `IconButton` typed their props as `ButtonHTMLAttributes`, which
|
|
6
|
+
* has no `ref`. React 19 hands `ref` to a function component as an ordinary
|
|
7
|
+
* prop, so it was already riding in on the spread and reaching the element —
|
|
8
|
+
* the type was the only thing stopping a caller. These tests pin both halves:
|
|
9
|
+
* that the ref lands, and that composition through `render` works, which is
|
|
10
|
+
* the shape all of stage 4 is built from (`Menu.Trigger`, `Dialog.Close`).
|
|
11
|
+
*/
|
|
12
|
+
import { createRef } from 'react'
|
|
13
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
14
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
15
|
+
import userEvent from '@testing-library/user-event'
|
|
16
|
+
import { Dialog } from '@base-ui/react/dialog'
|
|
17
|
+
import { IconSquareRounded } from '@tabler/icons-react'
|
|
18
|
+
import { Button } from './Button'
|
|
19
|
+
import { IconButton } from './IconButton'
|
|
20
|
+
|
|
21
|
+
afterEach(cleanup)
|
|
22
|
+
|
|
23
|
+
describe('the buttons take a ref', () => {
|
|
24
|
+
it('Button: the ref is the button element', () => {
|
|
25
|
+
const ref = createRef<HTMLButtonElement>()
|
|
26
|
+
render(<Button ref={ref}>Label</Button>)
|
|
27
|
+
expect(ref.current).toBe(screen.getByRole('button', { name: 'Label' }))
|
|
28
|
+
expect(ref.current?.tagName).toBe('BUTTON')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('IconButton: the ref is the button element', () => {
|
|
32
|
+
const ref = createRef<HTMLButtonElement>()
|
|
33
|
+
render(
|
|
34
|
+
<IconButton ref={ref} aria-label="Item">
|
|
35
|
+
<IconSquareRounded size={16} stroke={1.5} />
|
|
36
|
+
</IconButton>,
|
|
37
|
+
)
|
|
38
|
+
expect(ref.current).toBe(screen.getByRole('button', { name: 'Item' }))
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('a Base UI part can be one of our buttons', () => {
|
|
43
|
+
it('Dialog.Close renders as an IconButton and closes the dialog', async () => {
|
|
44
|
+
// This is the loose end stage 3 left: `DialogShell`'s ✕ called `onClose`
|
|
45
|
+
// by hand because `Close` as a render target needs exactly this.
|
|
46
|
+
const user = userEvent.setup()
|
|
47
|
+
const onOpenChange = vi.fn()
|
|
48
|
+
render(
|
|
49
|
+
<Dialog.Root open onOpenChange={onOpenChange}>
|
|
50
|
+
<Dialog.Portal>
|
|
51
|
+
<Dialog.Popup aria-label="A dialog">
|
|
52
|
+
<Dialog.Close
|
|
53
|
+
render={
|
|
54
|
+
<IconButton aria-label="Close">
|
|
55
|
+
<IconSquareRounded size={16} stroke={1.5} />
|
|
56
|
+
</IconButton>
|
|
57
|
+
}
|
|
58
|
+
/>
|
|
59
|
+
</Dialog.Popup>
|
|
60
|
+
</Dialog.Portal>
|
|
61
|
+
</Dialog.Root>,
|
|
62
|
+
)
|
|
63
|
+
const close = screen.getByRole('button', { name: 'Close' })
|
|
64
|
+
// Still our button: the class list survives composition.
|
|
65
|
+
expect(close.className).toContain('rounded-lg')
|
|
66
|
+
await user.click(close)
|
|
67
|
+
expect(onOpenChange).toHaveBeenCalledTimes(1)
|
|
68
|
+
expect(onOpenChange.mock.calls[0][0]).toBe(false)
|
|
69
|
+
// Base UI says *why* it closed, which is what a hand-wired onClick cannot.
|
|
70
|
+
expect((onOpenChange.mock.calls[0][1] as { reason?: string })?.reason).toBe('close-press')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('Dialog.Close renders as an IconButton THAT CARRIES A TOOLTIP', async () => {
|
|
74
|
+
/*
|
|
75
|
+
* The half stage 3 could not close. With the hand-written tooltip an
|
|
76
|
+
* IconButton with a `tooltip` returned `WithTooltip`'s wrapper `<div>` as
|
|
77
|
+
* its root, so `Close` composed onto the wrapper and the ✕ was never the
|
|
78
|
+
* part. On Base UI's Tooltip the trigger IS the button, so it is.
|
|
79
|
+
*/
|
|
80
|
+
const user = userEvent.setup()
|
|
81
|
+
const onOpenChange = vi.fn()
|
|
82
|
+
render(
|
|
83
|
+
<Dialog.Root open onOpenChange={onOpenChange}>
|
|
84
|
+
<Dialog.Portal>
|
|
85
|
+
<Dialog.Popup aria-label="A dialog">
|
|
86
|
+
<Dialog.Close
|
|
87
|
+
render={
|
|
88
|
+
<IconButton aria-label="Close" tooltip="Close">
|
|
89
|
+
<IconSquareRounded size={16} stroke={1.5} />
|
|
90
|
+
</IconButton>
|
|
91
|
+
}
|
|
92
|
+
/>
|
|
93
|
+
</Dialog.Popup>
|
|
94
|
+
</Dialog.Portal>
|
|
95
|
+
</Dialog.Root>,
|
|
96
|
+
)
|
|
97
|
+
const close = screen.getByRole('button', { name: 'Close' })
|
|
98
|
+
expect(close.tagName).toBe('BUTTON')
|
|
99
|
+
expect(close.className).toContain('rounded-lg')
|
|
100
|
+
await user.click(close)
|
|
101
|
+
expect(onOpenChange).toHaveBeenCalledTimes(1)
|
|
102
|
+
expect((onOpenChange.mock.calls[0][1] as { reason?: string })?.reason).toBe('close-press')
|
|
103
|
+
})
|
|
104
|
+
it('Dialog.Trigger renders as a Button and opens the dialog', async () => {
|
|
105
|
+
const user = userEvent.setup()
|
|
106
|
+
render(
|
|
107
|
+
<Dialog.Root>
|
|
108
|
+
<Dialog.Trigger render={<Button variant="primary">Open</Button>} />
|
|
109
|
+
<Dialog.Portal>
|
|
110
|
+
<Dialog.Popup aria-label="A dialog">Body</Dialog.Popup>
|
|
111
|
+
</Dialog.Portal>
|
|
112
|
+
</Dialog.Root>,
|
|
113
|
+
)
|
|
114
|
+
const trigger = screen.getByRole('button', { name: 'Open' })
|
|
115
|
+
expect(trigger.className).toContain('bg-accent-primary')
|
|
116
|
+
await user.click(trigger)
|
|
117
|
+
expect(await screen.findByRole('dialog', { name: 'A dialog' })).toBeTruthy()
|
|
118
|
+
})
|
|
119
|
+
})
|
package/src/Button.mdx
CHANGED
|
@@ -53,11 +53,13 @@ import { IconPlus } from '@tabler/icons-react'
|
|
|
53
53
|
passes through; the visible label is the accessible name, so no
|
|
54
54
|
`aria-label` is needed.
|
|
55
55
|
- `disabledReason="Sign in first"` disables it, keeps it reachable by
|
|
56
|
-
Tab, and shows the reason as a tooltip
|
|
57
|
-
|
|
58
|
-
reason shows on
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
Tab, and shows the reason as a tooltip. Say why, every time; a bare
|
|
57
|
+
`disabled` is for the moment between a click and its answer. **The
|
|
58
|
+
reason shows on keyboard focus as well as on hover**, which is what
|
|
59
|
+
makes it worth writing: a reason only a mouse can read is not offered
|
|
60
|
+
to everyone. The button itself is the tooltip's trigger, so it wears no
|
|
61
|
+
wrapper — what a caller gets is the `<button>`, and a `Menu.Trigger` or
|
|
62
|
+
a `Dialog.Close` can be it.
|
|
61
63
|
|
|
62
64
|
## Keys
|
|
63
65
|
|
package/src/Button.test.tsx
CHANGED
|
@@ -72,7 +72,8 @@ describe('Button', () => {
|
|
|
72
72
|
expect(button.hasAttribute('disabled')).toBe(false)
|
|
73
73
|
expect(screen.queryByRole('tooltip')).toBeNull()
|
|
74
74
|
await user.hover(button)
|
|
75
|
-
|
|
75
|
+
// 300ms before it opens (D23) - it used to be instant.
|
|
76
|
+
expect((await screen.findByRole('tooltip')).textContent).toBe('Sign in first')
|
|
76
77
|
await user.unhover(button)
|
|
77
78
|
expect(screen.queryByRole('tooltip')).toBeNull()
|
|
78
79
|
await user.click(button)
|