@estiva-app/ui 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/stories/TokensPage.tsx +6 -2
- package/tailwind-preset.js +12 -0
- package/tokens.css +8 -0
- package/src/Menu.fit.test.ts +0 -90
- package/src/Select.fit.test.ts +0 -101
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconMessage2, IconMoodPlus } from '@tabler/icons-react'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { Popover } from './Popover'
|
|
5
|
+
import { Reaction } from './Reaction'
|
|
6
|
+
import { ReactionPicker, type ReactionOption } from './ReactionPicker'
|
|
7
|
+
import { Toolbar, ToolbarButton } from './Toolbar'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The reactions on offer, to choose one from. **A `Reaction` is the answer;
|
|
11
|
+
* this is the question.**
|
|
12
|
+
*
|
|
13
|
+
* Icon buttons holding emoji, on a `Toolbar` — so the strip carries the
|
|
14
|
+
* elevated box a floating control needs, and the whole row is one Tab stop.
|
|
15
|
+
*
|
|
16
|
+
* The vocabulary is the app's: which emoji it offers and what each one means.
|
|
17
|
+
* These canvases use a neutral set.
|
|
18
|
+
*/
|
|
19
|
+
const OPTIONS: ReactionOption[] = [
|
|
20
|
+
{ emoji: '👍', label: 'Agree' },
|
|
21
|
+
{ emoji: '🎉', label: 'Celebrate' },
|
|
22
|
+
{ emoji: '🙏', label: 'Thank you' },
|
|
23
|
+
{ emoji: '🚀', label: 'Ship it' },
|
|
24
|
+
{ emoji: '👀', label: 'Looking' },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
const meta = {
|
|
28
|
+
title: 'Primitives/ReactionPicker',
|
|
29
|
+
component: ReactionPicker,
|
|
30
|
+
args: { options: OPTIONS, onSelect: () => {} },
|
|
31
|
+
argTypes: { options: { control: false }, onSelect: { control: false } },
|
|
32
|
+
} satisfies Meta<typeof ReactionPicker>
|
|
33
|
+
|
|
34
|
+
export default meta
|
|
35
|
+
type Story = StoryObj<typeof meta>
|
|
36
|
+
|
|
37
|
+
/** The picker, as it floats: the strip and its box. */
|
|
38
|
+
export const Default: Story = {}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* **Where it comes from.** A card carries a `Toolbar` of actions; one of them
|
|
42
|
+
* opens the picker, and it opens **above** that control — what is being
|
|
43
|
+
* reacted to is underneath.
|
|
44
|
+
*
|
|
45
|
+
* The box here is the `Popover`'s, so the picker passes `surface={false}`:
|
|
46
|
+
* two boxes inside each other is the tell.
|
|
47
|
+
*/
|
|
48
|
+
export const FromATrigger: Story = {
|
|
49
|
+
parameters: { controls: { disable: true }, layout: 'centered' },
|
|
50
|
+
render: function FromTrigger() {
|
|
51
|
+
const [chosen, setChosen] = useState<string | null>(null)
|
|
52
|
+
return (
|
|
53
|
+
<div className="flex w-[420px] flex-col items-end gap-3">
|
|
54
|
+
<div className="w-full rounded-lg border border-border-default bg-bg-surface p-3 text-body-2 text-text-primary">
|
|
55
|
+
A card. Its actions sit at the corner, and the picker opens above them.
|
|
56
|
+
</div>
|
|
57
|
+
<Toolbar aria-label="Card actions">
|
|
58
|
+
<ToolbarButton aria-label="Reply" tooltip="Reply">
|
|
59
|
+
<IconMessage2 size={16} stroke={1.5} />
|
|
60
|
+
</ToolbarButton>
|
|
61
|
+
<Popover
|
|
62
|
+
side="top"
|
|
63
|
+
align="right"
|
|
64
|
+
ariaLabel="Reactions"
|
|
65
|
+
className="w-auto min-w-0 p-1"
|
|
66
|
+
trigger={
|
|
67
|
+
<ToolbarButton aria-label="React" tooltip="React">
|
|
68
|
+
<IconMoodPlus size={16} stroke={1.5} />
|
|
69
|
+
</ToolbarButton>
|
|
70
|
+
}
|
|
71
|
+
>
|
|
72
|
+
<ReactionPicker options={OPTIONS} surface={false} onSelect={setChosen} />
|
|
73
|
+
</Popover>
|
|
74
|
+
</Toolbar>
|
|
75
|
+
<span className="text-caption text-text-secondary">
|
|
76
|
+
{chosen ? `Chose ${chosen}` : 'Nothing chosen yet.'}
|
|
77
|
+
</span>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* **What it produces.** Picking puts a `Reaction` in the row on the card —
|
|
85
|
+
* the pill with the count and, when it is yours, the accent fill. That state
|
|
86
|
+
* belongs to `Reaction`; the picker only asks.
|
|
87
|
+
*/
|
|
88
|
+
export const AndWhatItProduces: Story = {
|
|
89
|
+
parameters: { controls: { disable: true } },
|
|
90
|
+
render: function Produces() {
|
|
91
|
+
const [mine, setMine] = useState<string[]>(['👍'])
|
|
92
|
+
return (
|
|
93
|
+
<div className="flex w-[420px] flex-col gap-4">
|
|
94
|
+
<ReactionPicker
|
|
95
|
+
options={OPTIONS}
|
|
96
|
+
onSelect={(emoji) => setMine((prev) => (prev.includes(emoji) ? prev.filter((e) => e !== emoji) : [...prev, emoji]))}
|
|
97
|
+
/>
|
|
98
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
99
|
+
{mine.length === 0 ? (
|
|
100
|
+
<span className="text-caption text-text-muted">Nothing here yet.</span>
|
|
101
|
+
) : (
|
|
102
|
+
mine.map((emoji) => {
|
|
103
|
+
const option = OPTIONS.find((o) => o.emoji === emoji)!
|
|
104
|
+
return (
|
|
105
|
+
<Reaction
|
|
106
|
+
key={emoji}
|
|
107
|
+
emoji={emoji}
|
|
108
|
+
count={1}
|
|
109
|
+
pressed
|
|
110
|
+
aria-label={`${option.label}, 1`}
|
|
111
|
+
onClick={() => setMine((prev) => prev.filter((e) => e !== emoji))}
|
|
112
|
+
/>
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
},
|
|
120
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the ReactionPicker page claims, pinned.
|
|
4
|
+
*
|
|
5
|
+
* The two that matter are the two Peek's version got wrong: the row is one Tab
|
|
6
|
+
* stop rather than one per emoji, and every control is named by what the
|
|
7
|
+
* reaction *means* rather than by the glyph.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
10
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
11
|
+
import userEvent from '@testing-library/user-event'
|
|
12
|
+
import { ReactionPicker, type ReactionOption } from './ReactionPicker'
|
|
13
|
+
|
|
14
|
+
afterEach(cleanup)
|
|
15
|
+
|
|
16
|
+
const OPTIONS: ReactionOption[] = [
|
|
17
|
+
{ emoji: '👍', label: 'Agree' },
|
|
18
|
+
{ emoji: '🎉', label: 'Celebrate' },
|
|
19
|
+
{ emoji: '🙏', label: 'Thank you' },
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
const focused = () => document.activeElement?.getAttribute('aria-label')
|
|
23
|
+
|
|
24
|
+
describe('ReactionPicker', () => {
|
|
25
|
+
it('is a named toolbar of the options it was given', () => {
|
|
26
|
+
render(<ReactionPicker options={OPTIONS} onSelect={() => {}} />)
|
|
27
|
+
expect(screen.getByRole('toolbar', { name: 'Reactions' })).toBeTruthy()
|
|
28
|
+
expect(screen.getAllByRole('button')).toHaveLength(3)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Named by the meaning, not the glyph — the same rule `Reaction` enforces,
|
|
33
|
+
* for the same reason: a glyph read aloud is noise and its spoken name
|
|
34
|
+
* differs per screen reader.
|
|
35
|
+
*/
|
|
36
|
+
it('names every control by what the reaction means', () => {
|
|
37
|
+
render(<ReactionPicker options={OPTIONS} onSelect={() => {}} />)
|
|
38
|
+
for (const option of OPTIONS) expect(screen.getByRole('button', { name: option.label })).toBeTruthy()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('the emoji itself is hidden from assistive tech', () => {
|
|
42
|
+
const { container } = render(<ReactionPicker options={OPTIONS} onSelect={() => {}} />)
|
|
43
|
+
const glyphs = [...container.querySelectorAll('span')].filter((s) => s.textContent === '👍')
|
|
44
|
+
expect(glyphs.length).toBeGreaterThan(0)
|
|
45
|
+
expect(glyphs.every((s) => s.getAttribute('aria-hidden') === 'true')).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/** The reason it is a Toolbar. Peek's five emoji were five Tab stops. */
|
|
49
|
+
it('is one Tab stop, not one per emoji', async () => {
|
|
50
|
+
const user = userEvent.setup()
|
|
51
|
+
render(
|
|
52
|
+
<>
|
|
53
|
+
<button type="button">Before</button>
|
|
54
|
+
<ReactionPicker options={OPTIONS} onSelect={() => {}} />
|
|
55
|
+
<button type="button">After</button>
|
|
56
|
+
</>,
|
|
57
|
+
)
|
|
58
|
+
screen.getByRole('button', { name: 'Before' }).focus()
|
|
59
|
+
await user.tab()
|
|
60
|
+
expect(focused()).toBe('Agree')
|
|
61
|
+
await user.tab()
|
|
62
|
+
expect(document.activeElement?.textContent).toBe('After')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('the arrow keys walk it', async () => {
|
|
66
|
+
const user = userEvent.setup()
|
|
67
|
+
render(<ReactionPicker options={OPTIONS} onSelect={() => {}} />)
|
|
68
|
+
screen.getByRole('button', { name: 'Agree' }).focus()
|
|
69
|
+
await user.keyboard('{ArrowRight}')
|
|
70
|
+
expect(focused()).toBe('Celebrate')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('choosing one reports the emoji, which is what the app stores', async () => {
|
|
74
|
+
const user = userEvent.setup()
|
|
75
|
+
const onSelect = vi.fn()
|
|
76
|
+
render(<ReactionPicker options={OPTIONS} onSelect={onSelect} />)
|
|
77
|
+
await user.click(screen.getByRole('button', { name: 'Celebrate' }))
|
|
78
|
+
expect(onSelect).toHaveBeenCalledWith('🎉')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('and from the keyboard too', async () => {
|
|
82
|
+
const user = userEvent.setup()
|
|
83
|
+
const onSelect = vi.fn()
|
|
84
|
+
render(<ReactionPicker options={OPTIONS} onSelect={onSelect} />)
|
|
85
|
+
screen.getByRole('button', { name: 'Agree' }).focus()
|
|
86
|
+
await user.keyboard('{Enter}')
|
|
87
|
+
expect(onSelect).toHaveBeenCalledWith('👍')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* It asks; it does not report. Which reactions are yours is `Reaction`'s
|
|
92
|
+
* state, in the row on the card — a picker that also carried it would be
|
|
93
|
+
* two components wearing one name (Katerina, 2026-09-08).
|
|
94
|
+
*/
|
|
95
|
+
it('says nothing about which are already yours', () => {
|
|
96
|
+
render(<ReactionPicker options={OPTIONS} onSelect={() => {}} />)
|
|
97
|
+
expect(screen.getAllByRole('button').every((b) => !b.hasAttribute('aria-pressed'))).toBe(true)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('takes a name of its own, for a page with more than one', () => {
|
|
101
|
+
render(<ReactionPicker options={OPTIONS} onSelect={() => {}} aria-label="React to this reply" />)
|
|
102
|
+
expect(screen.getByRole('toolbar', { name: 'React to this reply' })).toBeTruthy()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
/** It floats, so it carries the elevated box — the one `MenuPanel` draws. */
|
|
106
|
+
it('draws the box, and drops it on request', () => {
|
|
107
|
+
const withBox = render(<ReactionPicker options={OPTIONS} onSelect={() => {}} />)
|
|
108
|
+
const panel = withBox.container.firstElementChild as HTMLElement
|
|
109
|
+
expect(panel.className).toContain('bg-bg-elevated')
|
|
110
|
+
expect(panel.querySelector('[role="toolbar"]')).toBeTruthy()
|
|
111
|
+
withBox.unmount()
|
|
112
|
+
|
|
113
|
+
// Inside a Popover, which draws that box already: two boxes inside each
|
|
114
|
+
// other is the tell.
|
|
115
|
+
const bare = render(<ReactionPicker options={OPTIONS} onSelect={() => {}} surface={false} />)
|
|
116
|
+
expect((bare.container.firstElementChild as HTMLElement).getAttribute('role')).toBe('toolbar')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { cn } from './cn'
|
|
2
|
+
import { Toolbar, ToolbarButton } from './Toolbar'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The reactions on offer, to choose one from — Peek's `ReactionPicker`
|
|
6
|
+
* (2026-09-03), moved in at Katerina's request (2026-09-08).
|
|
7
|
+
*
|
|
8
|
+
* **`Reaction` is the answer; this is the question.** A `Reaction` is a pill
|
|
9
|
+
* that says an emoji, a count and whether it is yours. This is the row you
|
|
10
|
+
* pick from before any of that exists, and both apps need it: Peek draws one
|
|
11
|
+
* from a card's action strip (`ConversationQuickMenu`), and Ship draws the
|
|
12
|
+
* same row in `Reactions.tsx` from its own vocabulary.
|
|
13
|
+
*
|
|
14
|
+
* **It is a `Toolbar`** — icon buttons holding emoji, on the strip's own
|
|
15
|
+
* elevated box — so the whole row is one Tab stop and the arrow keys walk it.
|
|
16
|
+
* Peek's was five separate stops.
|
|
17
|
+
*
|
|
18
|
+
* ## What stays with the app
|
|
19
|
+
*
|
|
20
|
+
* **Which emoji, and what each one means.** Peek offers five and names them
|
|
21
|
+
* ("Makes sense", "Agree", "Thank you", "Let's go!", "Congrats"); Ship will
|
|
22
|
+
* offer its own. A vocabulary is product knowledge, so it arrives as
|
|
23
|
+
* `options` — and the name is not optional, because the emoji is decorative
|
|
24
|
+
* here for the same reason it is on `Reaction`: a glyph read aloud is noise,
|
|
25
|
+
* and its spoken name differs per screen reader.
|
|
26
|
+
*
|
|
27
|
+
* ## Picking, and nothing else
|
|
28
|
+
*
|
|
29
|
+
* **It does not show which reactions are yours.** That is `Reaction`'s job —
|
|
30
|
+
* the pill with the count and the accent fill — and the two live in different
|
|
31
|
+
* places: the reactions *made* sit in a row on the card, and this row is what
|
|
32
|
+
* opens over it when you go to add one. A picker that also reported state
|
|
33
|
+
* would be two components wearing one name (Katerina, 2026-09-08).
|
|
34
|
+
*/
|
|
35
|
+
export interface ReactionOption {
|
|
36
|
+
/** The emoji itself. Drawn decoratively — `label` names the control. */
|
|
37
|
+
emoji: string
|
|
38
|
+
/** What this reaction *means*: "Makes sense", "Congrats". Required, and it
|
|
39
|
+
* is what a screen reader and the tooltip both say. */
|
|
40
|
+
label: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ReactionPickerProps {
|
|
44
|
+
/** What is on offer, in the order it is drawn. The app's vocabulary. */
|
|
45
|
+
options: ReactionOption[]
|
|
46
|
+
onSelect: (emoji: string) => void
|
|
47
|
+
/** Names the row. Default "Reactions". */
|
|
48
|
+
'aria-label'?: string
|
|
49
|
+
/**
|
|
50
|
+
* The elevated box around the row, from `Toolbar`. **On by default**, which
|
|
51
|
+
* is how a picker floats over a card. Off inside a `Popover`, which draws
|
|
52
|
+
* that box already.
|
|
53
|
+
*/
|
|
54
|
+
surface?: boolean
|
|
55
|
+
/** The row's layout — its gap, its padding. */
|
|
56
|
+
className?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function ReactionPicker({ options, onSelect, 'aria-label': ariaLabel = 'Reactions', surface = true, className }: ReactionPickerProps) {
|
|
60
|
+
return (
|
|
61
|
+
<Toolbar aria-label={ariaLabel} surface={surface} className={cn('gap-0.5', className)}>
|
|
62
|
+
{options.map((option) => (
|
|
63
|
+
/*
|
|
64
|
+
A `ToolbarButton`, not a button in a tooltip wrapper. The wrapper
|
|
65
|
+
would become the toolbar's item and the button inside it would never
|
|
66
|
+
join the walk — and it is unnecessary since stage 4, because an
|
|
67
|
+
`IconButton` with a `tooltip` IS the trigger.
|
|
68
|
+
|
|
69
|
+
The tooltip names it for a pointer, `aria-label` for everything else,
|
|
70
|
+
and both say the meaning rather than the glyph.
|
|
71
|
+
*/
|
|
72
|
+
<ToolbarButton
|
|
73
|
+
key={option.emoji}
|
|
74
|
+
aria-label={option.label}
|
|
75
|
+
tooltip={option.label}
|
|
76
|
+
onClick={() => onSelect(option.emoji)}
|
|
77
|
+
/* Peek's geometry, verbatim: a 28px square rather than the
|
|
78
|
+
IconButton's 24, and the emoji at 18px — larger than a `Reaction`
|
|
79
|
+
pill's 16, because here the emoji is the whole control. */
|
|
80
|
+
className="size-7 text-[18px] leading-none"
|
|
81
|
+
>
|
|
82
|
+
{/* Decorative, as on `Reaction`: the control is named above. */}
|
|
83
|
+
<span aria-hidden="true">{option.emoji}</span>
|
|
84
|
+
</ToolbarButton>
|
|
85
|
+
))}
|
|
86
|
+
</Toolbar>
|
|
87
|
+
)
|
|
88
|
+
}
|
package/src/SearchInput.mdx
CHANGED
|
@@ -31,8 +31,12 @@ import { SearchInput } from '@estiva-app/ui'
|
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
- The default placeholder is "Search…" — override it with your own.
|
|
34
|
-
- Every native input prop passes through
|
|
35
|
-
|
|
34
|
+
- Every native input prop passes through. The field inside is Base UI's
|
|
35
|
+
`Input` since stage 3; the border, the focus-within rule and the `Kbd`
|
|
36
|
+
hint are this component's.
|
|
37
|
+
- **Give it an `aria-label` ("Search")** — the placeholder is not a name,
|
|
38
|
+
and this usually stands alone in a top bar rather than in a `Field`.
|
|
39
|
+
Inside one, the label names it and you add nothing.
|
|
36
40
|
|
|
37
41
|
## Props
|
|
38
42
|
|
package/src/SearchInput.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type InputHTMLAttributes } from 'react'
|
|
2
|
+
import { Input } from '@base-ui/react/input'
|
|
2
3
|
import { cn } from './cn'
|
|
3
4
|
import { Kbd } from './Kbd'
|
|
4
5
|
|
|
@@ -14,6 +15,11 @@ import { Kbd } from './Kbd'
|
|
|
14
15
|
* In Peek's top bar this is a launcher affordance rather than a live field:
|
|
15
16
|
* the input is `pointer-events-none` and clicking the surround opens the
|
|
16
17
|
* command launcher. The component is the same either way.
|
|
18
|
+
*
|
|
19
|
+
* On Base UI's `Input` since stage 3 of the migration (2026-09-07), so a
|
|
20
|
+
* search field inside a `Field` is labelled by it without the caller wiring
|
|
21
|
+
* an id. The surround stays ours: the border, the focus-within rule and the
|
|
22
|
+
* `Kbd` hint are this component's, not the input's.
|
|
17
23
|
*/
|
|
18
24
|
export interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className'> {
|
|
19
25
|
/** A keyboard hint drawn at the right edge, e.g. "Ctrl+K". */
|
|
@@ -31,7 +37,7 @@ export function SearchInput({ shortcut, className, placeholder = 'Search…', ..
|
|
|
31
37
|
className,
|
|
32
38
|
)}
|
|
33
39
|
>
|
|
34
|
-
<
|
|
40
|
+
<Input
|
|
35
41
|
className="flex-1 min-w-0 bg-transparent text-input-value text-text-primary placeholder:text-text-muted outline-none"
|
|
36
42
|
placeholder={placeholder}
|
|
37
43
|
{...props}
|
package/src/Select.mdx
CHANGED
|
@@ -50,21 +50,32 @@ import { Select } from '@estiva-app/ui'
|
|
|
50
50
|
|
|
51
51
|
- The trigger truncates long labels instead of stretching its row — give
|
|
52
52
|
it room with your layout, not with `white-space` tricks.
|
|
53
|
-
-
|
|
54
|
-
|
|
53
|
+
- **The list hangs under the trigger**, 4px below it, and flips above when
|
|
54
|
+
the room below is worse. Base UI would rather lay it *over* the trigger
|
|
55
|
+
with the chosen option covering the trigger's own text, the way macOS
|
|
56
|
+
does; that is off (Katerina, D24) because every Select in both apps opens
|
|
57
|
+
below one today.
|
|
58
|
+
- It caps at 288px, or at the room the screen actually has, whichever is
|
|
59
|
+
smaller — and scrolls inside that. Measured in a bottom-right corner:
|
|
60
|
+
the list flips above the trigger, slides left to stay on screen, and
|
|
61
|
+
stands at its full 288.
|
|
62
|
+
- **The trigger is a `combobox`, not a `button`.** That is the ARIA pattern
|
|
63
|
+
for this control and Base UI's doing; a test looking for it by role has
|
|
64
|
+
to ask for `combobox`.
|
|
55
65
|
|
|
56
66
|
## Keys
|
|
57
67
|
|
|
58
68
|
| Input | What happens |
|
|
59
69
|
|---|---|
|
|
60
70
|
| Enter / Space / click, closed | opens the list |
|
|
61
|
-
| ↑ / ↓ | move the
|
|
71
|
+
| ↑ / ↓ | move the highlight, kept scrolled into view |
|
|
62
72
|
| Home / End | jump to the first / last option |
|
|
63
|
-
|
|
|
73
|
+
| **typing a label's first letters** | **jumps to that option** — new, and nothing here implements it |
|
|
74
|
+
| Enter / Space, open | picks the highlighted option |
|
|
64
75
|
| Escape | closes, returns focus to the trigger |
|
|
65
76
|
| Tab | closes |
|
|
66
77
|
| click outside | closes |
|
|
67
|
-
| page scroll or resize |
|
|
78
|
+
| page scroll or resize | **the list follows its trigger**, and disappears if the trigger scrolls out of sight. It used to close, because a list fixed to where the trigger *had been* had no other way not to be left behind |
|
|
68
79
|
|
|
69
80
|
## Props
|
|
70
81
|
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the Select page claims, pinned.
|
|
4
|
+
*
|
|
5
|
+
* This component had no component test at all — only `Select.fit.test.ts`,
|
|
6
|
+
* eight assertions about the pure geometry it grew and then shared with the
|
|
7
|
+
* Menu shell. That geometry is Floating UI's now, so the file is deleted and
|
|
8
|
+
* its subject is measured in a browser instead (jsdom lays nothing out, so a
|
|
9
|
+
* jsdom test that claimed to check placement would be checking nothing).
|
|
10
|
+
* What is testable here is behaviour, and it never was.
|
|
11
|
+
*
|
|
12
|
+
* Ship asserts on this component in `web/src/components/ui/ui.test.tsx`; the
|
|
13
|
+
* shapes it uses are repeated here deliberately, so a break shows up in this
|
|
14
|
+
* repository rather than in Ship's adoption PR.
|
|
15
|
+
*/
|
|
16
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
17
|
+
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
|
18
|
+
import userEvent from '@testing-library/user-event'
|
|
19
|
+
import { useState } from 'react'
|
|
20
|
+
import { Select } from './Select'
|
|
21
|
+
|
|
22
|
+
afterEach(cleanup)
|
|
23
|
+
|
|
24
|
+
const STATUSES = [
|
|
25
|
+
{ value: 'todo', label: 'Todo' },
|
|
26
|
+
{ value: 'in_progress', label: 'In Progress' },
|
|
27
|
+
{ value: 'done', label: 'Done' },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
function Controlled({ initial = 'todo', ...rest }: { initial?: string } & Partial<React.ComponentProps<typeof Select>>) {
|
|
31
|
+
const [value, setValue] = useState(initial)
|
|
32
|
+
return <Select value={value} onChange={setValue} options={STATUSES} ariaLabel="Status" {...rest} />
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('Select', () => {
|
|
36
|
+
it('is a button naming itself, showing the chosen label', () => {
|
|
37
|
+
render(<Controlled initial="in_progress" />)
|
|
38
|
+
const trigger = screen.getByRole('combobox', { name: 'Status' })
|
|
39
|
+
expect(trigger.textContent).toContain('In Progress')
|
|
40
|
+
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('shows the placeholder when nothing matches', () => {
|
|
44
|
+
render(<Controlled initial="" placeholder="Choose" />)
|
|
45
|
+
expect(screen.getByRole('combobox', { name: 'Status' }).textContent).toContain('Choose')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('opens on click, lists every option, and marks the chosen one', async () => {
|
|
49
|
+
const user = userEvent.setup()
|
|
50
|
+
render(<Controlled initial="in_progress" />)
|
|
51
|
+
await user.click(screen.getByRole('combobox', { name: 'Status' }))
|
|
52
|
+
expect(await screen.findByRole('listbox')).toBeTruthy()
|
|
53
|
+
const options = screen.getAllByRole('option')
|
|
54
|
+
// The tick's icon contributes a fallback glyph to textContent, so read the
|
|
55
|
+
// labels rather than the whole row.
|
|
56
|
+
expect(options.map((o) => o.textContent?.slice(0, 11))).toEqual(['Todo', 'In Progress', 'Done'])
|
|
57
|
+
expect(options[1].getAttribute('data-selected')).not.toBeNull()
|
|
58
|
+
expect(screen.getByRole('combobox', { name: 'Status' }).getAttribute('aria-expanded')).toBe('true')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('opens from the keyboard, moves with arrows, picks with Enter, and gives focus back', async () => {
|
|
62
|
+
const user = userEvent.setup()
|
|
63
|
+
const onChange = vi.fn()
|
|
64
|
+
render(<Select value="todo" onChange={onChange} options={STATUSES} ariaLabel="Status" />)
|
|
65
|
+
const trigger = screen.getByRole('combobox', { name: 'Status' })
|
|
66
|
+
trigger.focus()
|
|
67
|
+
await user.keyboard('{ArrowDown}')
|
|
68
|
+
expect(await screen.findByRole('listbox')).toBeTruthy()
|
|
69
|
+
await user.keyboard('{ArrowDown}{Enter}')
|
|
70
|
+
expect(onChange).toHaveBeenCalledWith('in_progress')
|
|
71
|
+
expect(document.activeElement).toBe(trigger)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The list is unmounted from the page's point of view when it closes: Base UI
|
|
76
|
+
* leaves the popup element in the DOM but hides the positioner around it with
|
|
77
|
+
* the `hidden` attribute, so it is out of the accessibility tree and
|
|
78
|
+
* `queryByRole` cannot see it. Ship asserts exactly this — pinned here so the
|
|
79
|
+
* assertion is known to hold before Ship ever runs it.
|
|
80
|
+
*/
|
|
81
|
+
it('closes on Escape, and the listbox is no longer findable', async () => {
|
|
82
|
+
const user = userEvent.setup()
|
|
83
|
+
render(<Controlled />)
|
|
84
|
+
await user.click(screen.getByRole('combobox', { name: 'Status' }))
|
|
85
|
+
expect(await screen.findByRole('listbox')).toBeTruthy()
|
|
86
|
+
await user.keyboard('{Escape}')
|
|
87
|
+
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull())
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('closes on a press outside', async () => {
|
|
91
|
+
const user = userEvent.setup()
|
|
92
|
+
render(
|
|
93
|
+
<>
|
|
94
|
+
<Controlled />
|
|
95
|
+
<button type="button">Elsewhere</button>
|
|
96
|
+
</>,
|
|
97
|
+
)
|
|
98
|
+
await user.click(screen.getByRole('combobox', { name: 'Status' }))
|
|
99
|
+
expect(await screen.findByRole('listbox')).toBeTruthy()
|
|
100
|
+
await user.click(screen.getByRole('button', { name: 'Elsewhere' }))
|
|
101
|
+
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull())
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('picking an option reports it and closes', async () => {
|
|
105
|
+
const user = userEvent.setup()
|
|
106
|
+
const onChange = vi.fn()
|
|
107
|
+
render(<Select value="todo" onChange={onChange} options={STATUSES} ariaLabel="Status" />)
|
|
108
|
+
await user.click(screen.getByRole('combobox', { name: 'Status' }))
|
|
109
|
+
await user.click(await screen.findByRole('option', { name: /Done/ }))
|
|
110
|
+
expect(onChange).toHaveBeenCalledWith('done')
|
|
111
|
+
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull())
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
/** New at stage 4, and nothing here implements it: type the start of an
|
|
115
|
+
* option's label and the list jumps to it. */
|
|
116
|
+
it('typeahead moves the highlight to a matching option', async () => {
|
|
117
|
+
const user = userEvent.setup()
|
|
118
|
+
render(<Controlled />)
|
|
119
|
+
const trigger = screen.getByRole('combobox', { name: 'Status' })
|
|
120
|
+
trigger.focus()
|
|
121
|
+
await user.keyboard('{ArrowDown}')
|
|
122
|
+
expect(await screen.findByRole('listbox')).toBeTruthy()
|
|
123
|
+
await user.keyboard('don')
|
|
124
|
+
const highlighted = screen.getAllByRole('option').find((o) => o.getAttribute('data-highlighted') !== null)
|
|
125
|
+
expect(highlighted?.textContent).toContain('Done')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('Home and End jump to the first and last option', async () => {
|
|
129
|
+
const user = userEvent.setup()
|
|
130
|
+
render(<Controlled initial="in_progress" />)
|
|
131
|
+
screen.getByRole('combobox', { name: 'Status' }).focus()
|
|
132
|
+
await user.keyboard('{ArrowDown}')
|
|
133
|
+
expect(await screen.findByRole('listbox')).toBeTruthy()
|
|
134
|
+
const highlighted = () => screen.getAllByRole('option').find((o) => o.getAttribute('data-highlighted') !== null)?.textContent
|
|
135
|
+
await user.keyboard('{End}')
|
|
136
|
+
expect(highlighted()).toContain('Done')
|
|
137
|
+
await user.keyboard('{Home}')
|
|
138
|
+
expect(highlighted()).toContain('Todo')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('Tab closes the list', async () => {
|
|
142
|
+
const user = userEvent.setup()
|
|
143
|
+
render(<Controlled />)
|
|
144
|
+
await user.click(screen.getByRole('combobox', { name: 'Status' }))
|
|
145
|
+
expect(await screen.findByRole('listbox')).toBeTruthy()
|
|
146
|
+
await user.tab()
|
|
147
|
+
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull())
|
|
148
|
+
})
|
|
149
|
+
it('disabled: the trigger cannot be opened', async () => {
|
|
150
|
+
const user = userEvent.setup()
|
|
151
|
+
render(<Controlled disabled />)
|
|
152
|
+
const trigger = screen.getByRole('combobox', { name: 'Status' })
|
|
153
|
+
expect(trigger.hasAttribute('disabled') || trigger.getAttribute('aria-disabled') === 'true').toBe(true)
|
|
154
|
+
await user.click(trigger)
|
|
155
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
156
|
+
})
|
|
157
|
+
})
|