@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.
Files changed (151) hide show
  1. package/dist/Avatar.d.ts +13 -1
  2. package/dist/Avatar.d.ts.map +1 -1
  3. package/dist/AvatarGroup.d.ts.map +1 -1
  4. package/dist/Banner.d.ts +16 -1
  5. package/dist/Banner.d.ts.map +1 -1
  6. package/dist/Breadcrumb.d.ts +9 -0
  7. package/dist/Breadcrumb.d.ts.map +1 -1
  8. package/dist/Button.d.ts +12 -2
  9. package/dist/Button.d.ts.map +1 -1
  10. package/dist/Checkbox.d.ts +2 -0
  11. package/dist/Checkbox.d.ts.map +1 -1
  12. package/dist/Chip.d.ts +3 -2
  13. package/dist/Chip.d.ts.map +1 -1
  14. package/dist/ChipInput.d.ts +3 -1
  15. package/dist/ChipInput.d.ts.map +1 -1
  16. package/dist/ConfirmDialog.d.ts +5 -0
  17. package/dist/ConfirmDialog.d.ts.map +1 -1
  18. package/dist/DialogShell.d.ts +27 -3
  19. package/dist/DialogShell.d.ts.map +1 -1
  20. package/dist/EditableText.d.ts +6 -0
  21. package/dist/EditableText.d.ts.map +1 -1
  22. package/dist/Field.d.ts +42 -25
  23. package/dist/Field.d.ts.map +1 -1
  24. package/dist/IconButton.d.ts +3 -2
  25. package/dist/IconButton.d.ts.map +1 -1
  26. package/dist/IdentityMenu.d.ts +25 -13
  27. package/dist/IdentityMenu.d.ts.map +1 -1
  28. package/dist/Menu.d.ts +65 -82
  29. package/dist/Menu.d.ts.map +1 -1
  30. package/dist/PersonTrigger.d.ts.map +1 -1
  31. package/dist/Popover.d.ts +86 -0
  32. package/dist/Popover.d.ts.map +1 -0
  33. package/dist/PreviewCard.d.ts +39 -0
  34. package/dist/PreviewCard.d.ts.map +1 -0
  35. package/dist/Reaction.d.ts.map +1 -1
  36. package/dist/ReactionPicker.d.ts +55 -0
  37. package/dist/ReactionPicker.d.ts.map +1 -0
  38. package/dist/SearchInput.d.ts +5 -0
  39. package/dist/SearchInput.d.ts.map +1 -1
  40. package/dist/Select.d.ts +26 -16
  41. package/dist/Select.d.ts.map +1 -1
  42. package/dist/Tabs.d.ts +11 -1
  43. package/dist/Tabs.d.ts.map +1 -1
  44. package/dist/TextInput.d.ts +7 -1
  45. package/dist/TextInput.d.ts.map +1 -1
  46. package/dist/Textarea.d.ts +8 -1
  47. package/dist/Textarea.d.ts.map +1 -1
  48. package/dist/Toolbar.d.ts +95 -0
  49. package/dist/Toolbar.d.ts.map +1 -0
  50. package/dist/Tooltip.d.ts +36 -10
  51. package/dist/Tooltip.d.ts.map +1 -1
  52. package/dist/cn.d.ts.map +1 -1
  53. package/dist/fit.d.ts +7 -72
  54. package/dist/fit.d.ts.map +1 -1
  55. package/dist/index.d.ts +6 -3
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +882 -763
  58. package/dist/index.js.map +4 -4
  59. package/package.json +1 -1
  60. package/src/Avatar.mdx +17 -0
  61. package/src/Avatar.name.test.tsx +99 -0
  62. package/src/Avatar.tsx +25 -3
  63. package/src/AvatarGroup.tsx +4 -1
  64. package/src/Banner.mdx +10 -2
  65. package/src/Banner.stories.tsx +29 -0
  66. package/src/Banner.test.tsx +60 -0
  67. package/src/Banner.tsx +35 -3
  68. package/src/Breadcrumb.mdx +5 -2
  69. package/src/Breadcrumb.test.tsx +44 -0
  70. package/src/Breadcrumb.tsx +10 -2
  71. package/src/Button.compose.test.tsx +119 -0
  72. package/src/Button.mdx +7 -5
  73. package/src/Button.test.tsx +2 -1
  74. package/src/Button.tsx +21 -5
  75. package/src/Checkbox.tsx +3 -0
  76. package/src/Chip.tsx +3 -2
  77. package/src/ChipInput.tsx +4 -0
  78. package/src/ConfirmDialog.mdx +20 -1
  79. package/src/ConfirmDialog.test.tsx +109 -0
  80. package/src/ConfirmDialog.tsx +6 -0
  81. package/src/DialogShell.mdx +18 -4
  82. package/src/DialogShell.stories.tsx +56 -6
  83. package/src/DialogShell.test.tsx +184 -0
  84. package/src/DialogShell.tsx +108 -39
  85. package/src/EditableText.mdx +6 -1
  86. package/src/EditableText.test.tsx +156 -0
  87. package/src/EditableText.tsx +23 -5
  88. package/src/Field.mdx +23 -3
  89. package/src/Field.stories.tsx +35 -0
  90. package/src/Field.test.tsx +117 -27
  91. package/src/Field.tsx +86 -58
  92. package/src/IconButton.mdx +6 -2
  93. package/src/IconButton.test.tsx +5 -2
  94. package/src/IconButton.tsx +12 -6
  95. package/src/IdentityMenu.mdx +29 -6
  96. package/src/IdentityMenu.stories.tsx +41 -6
  97. package/src/IdentityMenu.tsx +74 -47
  98. package/src/Kbd.stories.tsx +3 -3
  99. package/src/Menu.mdx +88 -75
  100. package/src/Menu.stories.tsx +120 -52
  101. package/src/Menu.test.tsx +315 -0
  102. package/src/Menu.tsx +341 -251
  103. package/src/MenuItem.stories.tsx +135 -98
  104. package/src/NavItem.mdx +1 -1
  105. package/src/PersonTrigger.mdx +20 -14
  106. package/src/PersonTrigger.tsx +23 -1
  107. package/src/Popover.mdx +130 -0
  108. package/src/Popover.stories.tsx +222 -0
  109. package/src/Popover.test.tsx +188 -0
  110. package/src/Popover.tsx +153 -0
  111. package/src/PreviewCard.mdx +98 -0
  112. package/src/PreviewCard.stories.tsx +168 -0
  113. package/src/PreviewCard.test.tsx +83 -0
  114. package/src/PreviewCard.tsx +91 -0
  115. package/src/Rail.mdx +63 -0
  116. package/src/Rail.stories.tsx +81 -0
  117. package/src/Reaction.mdx +4 -2
  118. package/src/Reaction.tsx +4 -2
  119. package/src/ReactionPicker.mdx +85 -0
  120. package/src/ReactionPicker.stories.tsx +120 -0
  121. package/src/ReactionPicker.test.tsx +118 -0
  122. package/src/ReactionPicker.tsx +88 -0
  123. package/src/SearchInput.mdx +6 -2
  124. package/src/SearchInput.tsx +7 -1
  125. package/src/Select.mdx +16 -5
  126. package/src/Select.test.tsx +157 -0
  127. package/src/Select.tsx +89 -214
  128. package/src/Sidebar.mdx +6 -2
  129. package/src/Tabs.mdx +5 -0
  130. package/src/Tabs.test.tsx +15 -0
  131. package/src/Tabs.tsx +17 -2
  132. package/src/TextInput.mdx +8 -2
  133. package/src/TextInput.tsx +10 -7
  134. package/src/Textarea.mdx +6 -2
  135. package/src/Textarea.tsx +14 -9
  136. package/src/Toolbar.mdx +104 -0
  137. package/src/Toolbar.stories.tsx +142 -0
  138. package/src/Toolbar.test.tsx +198 -0
  139. package/src/Toolbar.tsx +158 -0
  140. package/src/Tooltip.mdx +43 -5
  141. package/src/Tooltip.stories.tsx +26 -0
  142. package/src/Tooltip.test.tsx +195 -0
  143. package/src/Tooltip.tsx +150 -48
  144. package/src/cn.ts +1 -1
  145. package/src/fit.test.ts +101 -0
  146. package/src/fit.ts +19 -63
  147. package/src/index.ts +6 -3
  148. package/stories/Choosing.mdx +8 -3
  149. package/tailwind-preset.js +7 -0
  150. package/src/Menu.fit.test.ts +0 -90
  151. 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
+ }
@@ -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; give it an `aria-label`
35
- ("Search") since the placeholder is not a name.
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
 
@@ -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
- <input
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
- - Its geometry is pure and tested (`fitMenu`): 288px cap, 120px floor
54
- scrollable beats invisible.
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 active option, kept scrolled into view |
71
+ | ↑ / ↓ | move the highlight, kept scrolled into view |
62
72
  | Home / End | jump to the first / last option |
63
- | Enter / Space, open | picks the active option |
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 | closes (the list is fixed where the trigger was) the list's **own** scroll never dismisses it |
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
+ })