@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.
Files changed (153) 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/stories/TokensPage.tsx +6 -2
  150. package/tailwind-preset.js +12 -0
  151. package/tokens.css +8 -0
  152. package/src/Menu.fit.test.ts +0 -90
  153. package/src/Select.fit.test.ts +0 -101
@@ -14,12 +14,20 @@
14
14
  * associated to that label". That message is the bug stated precisely, and the
15
15
  * consumer worked around it by querying by role. The guard belongs here, where
16
16
  * the next primitive added under Field will meet it.
17
+ *
18
+ * **Stage 3 (2026-09-07) moved the association to Base UI's `Field`**, so the
19
+ * context and the `useFieldControlId` opt-in are gone. The guard is the same
20
+ * and matters more, because the mechanism is no longer ours to read.
17
21
  */
18
22
  import { afterEach, describe, expect, it } from 'vitest'
19
23
  import { cleanup, render, screen } from '@testing-library/react'
20
24
  import { Field } from './Field'
21
25
  import { TextInput } from './TextInput'
22
26
  import { Textarea } from './Textarea'
27
+ import { Select } from './Select'
28
+ import { SearchInput } from './SearchInput'
29
+ import { ChipInput } from './ChipInput'
30
+ import { Checkbox } from './Checkbox'
23
31
 
24
32
  // Testing Library registers its own cleanup only when vitest runs with
25
33
  // `globals: true`, and this package does not — so the first two tests passed,
@@ -47,9 +55,9 @@ describe('Field', () => {
47
55
  })
48
56
 
49
57
  it('points the label at the control it wraps, not at some other field', () => {
50
- // Two Fields on one form is the case a single hardcoded id gets wrong, and
51
- // `useId` is what makes it right. Asserted because "it works with one
52
- // field" is the version of this that ships broken.
58
+ // Two Fields on one form is the case a single hardcoded id gets wrong.
59
+ // Asserted because "it works with one field" is the version of this that
60
+ // ships broken.
53
61
  const { container } = render(
54
62
  <form>
55
63
  <Field label="Title">
@@ -68,16 +76,13 @@ describe('Field', () => {
68
76
  expect(screen.getByLabelText('Description')).toBe(container.querySelector(`#${CSS.escape(ids[1]!)}`))
69
77
  })
70
78
 
71
- it('wins over an id the caller put on the control, rather than breaking the pair', () => {
72
- // The first version of this asserted the opposite that a control keeps
73
- // its own id inside a Field and it failed with this ticket's own error
74
- // message, because the label went on pointing at the generated id. Two
75
- // halves of one association cannot be set from two places.
76
- //
77
- // So the Field wins, and `htmlFor` below is how a caller chooses. Silently
78
- // overriding an id is a smaller surprise than silently unlabelling a
79
- // control, and only one of the two is invisible until somebody uses a
80
- // screen reader.
79
+ it('keeps the pair when the caller names the control itself', () => {
80
+ // This reverses on Base UI, and the new behaviour is the better one. Ours
81
+ // made the *Field* win the control's own id was overridden because two
82
+ // halves of one association cannot be set from two places and only the
83
+ // Field could set both. Base UI sets both from the control's id instead,
84
+ // so a caller who needs a particular id keeps it and the label follows.
85
+ // What the test guards is unchanged: the pair holds either way.
81
86
  render(
82
87
  <Field label="Title">
83
88
  <TextInput id="chosen-by-the-caller" defaultValue="" />
@@ -85,33 +90,118 @@ describe('Field', () => {
85
90
  )
86
91
  const input = screen.getByLabelText('Title')
87
92
  expect(input.tagName).toBe('INPUT')
88
- expect(input.id).not.toBe('chosen-by-the-caller')
93
+ expect(input.id).toBe('chosen-by-the-caller')
89
94
  })
90
95
 
91
- it('lets the Field be told the id instead, for the same reason', () => {
96
+ it('gives a control outside a Field an id of its own, and nothing else', () => {
97
+ // Also reversed, and worth stating rather than deleting. Ours left a lone
98
+ // control untouched; Base UI's `Input` always generates an id, because it
99
+ // cannot know whether a `Field` will describe it. The id is inert — no
100
+ // label, no `aria-describedby` points at it — so nothing reads differently;
101
+ // an app that asserted on the absence of `id` is the only thing this
102
+ // reaches, and neither app does.
103
+ const { container } = render(<TextInput defaultValue="" />)
104
+ const input = container.querySelector('input')!
105
+ expect(input.getAttribute('id')).toBeTruthy()
106
+ expect(input.getAttribute('aria-labelledby')).toBeNull()
107
+ expect(input.getAttribute('aria-describedby')).toBeNull()
108
+ })
109
+
110
+ /**
111
+ * The asterisk is a picture of `required`; `aria-required` is the word for
112
+ * it. Drawing the mark and telling nobody was all this prop did until
113
+ * 2026-09-08 — measured, the control carried neither attribute.
114
+ */
115
+ it('marks a required field, in the mark and in the control', () => {
92
116
  render(
93
- <Field label="Title" htmlFor="named-outside">
117
+ <Field label="Title" required>
94
118
  <TextInput defaultValue="" />
95
119
  </Field>,
96
120
  )
97
- expect(screen.getByLabelText('Title').id).toBe('named-outside')
121
+ expect(screen.getByText('*')).toBeTruthy()
122
+ const control = screen.getByLabelText(/Title/)
123
+ expect(control.tagName).toBe('INPUT')
124
+ expect(control.getAttribute('aria-required')).toBe('true')
98
125
  })
99
126
 
100
- it('leaves a control outside a Field alone', () => {
101
- // `useFieldControlId` returns undefined outside a provider, so nothing
102
- // acquires a stray id — a control with an id it did not ask for is its own
103
- // small bug.
104
- const { container } = render(<TextInput defaultValue="" />)
105
- expect(container.querySelector('input')?.getAttribute('id')).toBeNull()
127
+ it('leaves a control that already says so alone', () => {
128
+ render(
129
+ <Field label="Title" required>
130
+ <TextInput defaultValue="" aria-required={false} />
131
+ </Field>,
132
+ )
133
+ expect(screen.getByLabelText(/Title/).getAttribute('aria-required')).toBe('false')
106
134
  })
107
135
 
108
- it('still marks a required field, which was the only thing it did before', () => {
109
- render(
136
+ /**
137
+ * Every control the package offers, because a promise that holds for two of
138
+ * them is not a promise. `Select`, `ChipInput` and `Checkbox` take fixed
139
+ * prop lists rather than spreading what they are given, so each had to be
140
+ * told — measured 2026-09-08: all three dropped it silently.
141
+ */
142
+ it.each([
143
+ ['TextInput', <TextInput key="t" defaultValue="" />, 'input'],
144
+ ['Textarea', <Textarea key="a" defaultValue="" />, 'textarea'],
145
+ ['SearchInput', <SearchInput key="s" />, 'input'],
146
+ ['Select', <Select key="e" value="a" onChange={() => {}} options={[{ value: 'a', label: 'A' }]} />, '[role="combobox"]'],
147
+ ['ChipInput', <ChipInput key="c" value={[]} onChange={() => {}} options={[]} />, 'input'],
148
+ ['Checkbox', <Checkbox key="k" checked={false} onChange={() => {}} />, '[role="checkbox"]'],
149
+ ])('marks a required %s', (_name, control, selector) => {
150
+ const { container } = render(
110
151
  <Field label="Title" required>
152
+ {control}
153
+ </Field>,
154
+ )
155
+ expect(container.querySelector(selector)?.getAttribute('aria-required')).toBe('true')
156
+ })
157
+
158
+ it('does not mark a field that is not required', () => {
159
+ render(
160
+ <Field label="Title">
111
161
  <TextInput defaultValue="" />
112
162
  </Field>,
113
163
  )
114
- expect(screen.getByText('*')).toBeTruthy()
115
- expect(screen.getByLabelText(/Title/).tagName).toBe('INPUT')
164
+ expect(screen.getByLabelText(/Title/).hasAttribute('aria-required')).toBe(false)
165
+ })
166
+
167
+ it('announces the helper line, which the hand-built spans never did', () => {
168
+ render(
169
+ <Field label="Folder link" helper="Leave empty and the project gets a Folder of its own.">
170
+ <TextInput defaultValue="" />
171
+ </Field>,
172
+ )
173
+ const input = screen.getByLabelText('Folder link')
174
+ const describedBy = input.getAttribute('aria-describedby')
175
+ expect(describedBy).toBeTruthy()
176
+ expect(document.getElementById(describedBy!)?.textContent).toBe('Leave empty and the project gets a Folder of its own.')
177
+ })
178
+
179
+ it('replaces the helper with the error, and marks the control invalid', () => {
180
+ // Both at once asks the reader to work out which one is live, and the two
181
+ // callers this prop came from both wrote `error ?? helper`. `aria-invalid`
182
+ // is Base UI's now — Ship passed it by hand beside every one of these.
183
+ render(
184
+ <Field label="Folder link" helper="Leave empty and the project gets a Folder of its own." error="That is not a Folder link.">
185
+ <TextInput defaultValue="" />
186
+ </Field>,
187
+ )
188
+ const input = screen.getByLabelText('Folder link')
189
+ expect(input.getAttribute('aria-invalid')).toBe('true')
190
+ expect(screen.queryByText('Leave empty and the project gets a Folder of its own.')).toBeNull()
191
+ const describedBy = input.getAttribute('aria-describedby')
192
+ expect(document.getElementById(describedBy!)?.textContent).toBe('That is not a Folder link.')
193
+ })
194
+
195
+ it('renders no line, and no wrapper, when there is neither', () => {
196
+ // The port must not move a pixel for the callers that predate these props,
197
+ // so the control stays a direct child of the Field exactly as before.
198
+ const { container } = render(
199
+ <Field label="Title">
200
+ <TextInput defaultValue="" />
201
+ </Field>,
202
+ )
203
+ const root = container.firstElementChild!
204
+ expect(root.children).toHaveLength(2)
205
+ expect(root.children[1].tagName).toBe('INPUT')
116
206
  })
117
207
  })
package/src/Field.tsx CHANGED
@@ -1,78 +1,106 @@
1
- import { createContext, useContext, useId, type ReactNode } from 'react'
2
-
3
- /**
4
- * The id of the control this Field labels.
5
- *
6
- * **A control has to opt in by calling `useFieldControlId`.** The automatic
7
- * alternative is nesting the control inside the `<label>`, which associates
8
- * anything by construction and needs no cooperation — and it is not used here,
9
- * because a control that is *both* nested in a label and named by its `htmlFor`
10
- * can receive two activations from one click. That is a real hazard for a
11
- * checkbox and a latent one for everything else, and this library has a
12
- * `Checkbox`.
13
- *
14
- * So: one explicit mechanism, and a test that pins it for every primitive that
15
- * uses it (`Field.test.tsx`). A new primitive that renders a labelable element
16
- * calls this hook and spreads the result; one that does not is unlabelled, and
17
- * the test is where that gets noticed.
18
- */
19
- const FieldControlIdContext = createContext<string | undefined>(undefined)
20
-
21
- /**
22
- * The id a surrounding `Field` wants this control to have, falling back to the
23
- * caller's own outside one.
24
- *
25
- * **Inside a Field, the Field wins**, which is the opposite of what I wrote
26
- * first and the test caught within the minute. Letting a control's own `id`
27
- * take precedence leaves the label's `htmlFor` pointing at the id the Field
28
- * generated and the control answering to a different one — which is this
29
- * ticket's defect exactly, reproduced by the fix for it, and it fails with the
30
- * same message: *"Found a label with the text of: Title, however no form
31
- * control was found associated to that label."*
32
- *
33
- * A caller who needs to choose the id names it on the Field (`htmlFor`), which
34
- * is the one place that can set both halves. Outside a Field there is nothing
35
- * to disagree with, so the caller's id is used.
36
- */
37
- export function useFieldControlId(ownId?: string): string | undefined {
38
- const fromField = useContext(FieldControlIdContext)
39
- return fromField ?? ownId
40
- }
1
+ import { cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react'
2
+ import { Field as BaseField } from '@base-ui/react/field'
3
+ import { cn } from './cn'
41
4
 
42
5
  /**
43
6
  * Peek's Field (2026-08-28): a label over a control, 8px apart, with a red
44
- * asterisk when required. The label is the `input-label` type token as a plain
45
- * class, never merged.
7
+ * asterisk when required. The label is the `input-label` type token, merged
8
+ * with `cn()` like every other class list here. On Base UI's `Field` since
9
+ * stage 3 of the migration (2026-09-07).
46
10
  *
47
- * The label names its control (SHA-17). It did not, and the control was a
11
+ * The label names its control. It did not once, and the control was a
48
12
  * *sibling* of the label with no `htmlFor`, so there was neither an explicit
49
13
  * nor an implicit association: a screen reader announced an unlabelled edit
50
- * box and clicking the label focused nothing.
14
+ * box and clicking the label focused nothing (SHA-17). That fix used to be
15
+ * ours — a context carrying a generated id, which every control had to opt
16
+ * into by calling `useFieldControlId`. **Base UI does it now**, for its own
17
+ * `Input`, `Checkbox`, `Select` and anything rendered through `Field.Control`,
18
+ * so a new control is labelled by construction rather than by remembering.
19
+ *
20
+ * `helper` and `error` are the line under the control, which Ship built by
21
+ * hand in two dialogs (`COMPONENTS-SHIP.md` F14) and Peek in four
22
+ * (`COMPONENTS-PEEK.md` F14), always the same two class lists. **An error
23
+ * replaces the helper rather than joining it** — that is what those callers
24
+ * did (`pairError ?? 'Leave empty and…'`), and a field that says both at once
25
+ * is asking the reader to work out which one is live.
26
+ *
27
+ * The line is announced: Base UI wires `aria-describedby` for the helper and
28
+ * `aria-invalid` + the error's id for the error, which the hand-built spans
29
+ * never did.
30
+ *
31
+ * `required` is announced too, since 2026-09-08. It drew the asterisk and
32
+ * nothing else — measured, the control carried neither `required` nor
33
+ * `aria-required`, so the one thing the mark means never reached anybody who
34
+ * could not see it. Base UI's `Field` has no `required` of its own, so this
35
+ * puts `aria-required` on the control itself.
51
36
  */
52
37
  export interface FieldProps {
53
38
  label: string
39
+ /**
40
+ * Draws the asterisk **and** marks the control `aria-required`, so the mark
41
+ * means something to a reader who cannot see it. It reaches a single control
42
+ * element; a `children` of several elements keeps the asterisk and owes its
43
+ * own `aria-required`.
44
+ */
54
45
  required?: boolean
55
46
  /**
56
- * Override the generated id. Only needed when something outside has to name
57
- * the control an `aria-describedby` elsewhere, or a form library.
47
+ * A hint under the control what the format is, what happens if it is left
48
+ * empty. `caption`, muted. Hidden while `error` is set.
49
+ */
50
+ helper?: string
51
+ /**
52
+ * What is wrong, in the same place as the helper and in the error colour.
53
+ * Setting it also marks the control invalid, so the caller no longer passes
54
+ * `aria-invalid` itself.
58
55
  */
59
- htmlFor?: string
56
+ error?: string
60
57
  children: ReactNode
61
58
  }
62
59
 
63
- export function Field({ label, required = false, htmlFor, children }: FieldProps) {
64
- const generated = useId()
65
- const id = htmlFor ?? generated
60
+ export function Field({ label, required = false, helper, error, children }: FieldProps) {
61
+ const line = error ?? helper
62
+ /*
63
+ The asterisk is a picture of `required`; this is the word for it. Base UI's
64
+ `Field` has no `required` to propagate, so the control is marked here —
65
+ `aria-required` rather than the native attribute, because the native one
66
+ also switches on the browser's own validation bubble, which no field in
67
+ either app uses. A control that already says so keeps what it says.
68
+ */
69
+ const control =
70
+ required && isValidElement(children)
71
+ ? cloneElement(children as ReactElement<{ 'aria-required'?: boolean | 'true' | 'false' }>, {
72
+ 'aria-required': (children as ReactElement<{ 'aria-required'?: boolean | 'true' | 'false' }>).props['aria-required'] ?? true,
73
+ })
74
+ : children
66
75
  return (
67
- <div className="flex flex-col gap-2">
68
- <label
69
- htmlFor={id}
70
- className={`text-input-label text-text-primary${required ? ' flex items-center' : ''}`}
71
- >
76
+ <BaseField.Root invalid={!!error} className="flex flex-col gap-2">
77
+ {/* `cn`, like everywhere else. It was a template literal, with a comment
78
+ saying the type token must never be merged — which stopped being true
79
+ when `cn()` was taught the ramp: `input-label` is in it, and
80
+ `cn.test.ts` pins that. */}
81
+ <BaseField.Label className={cn('text-input-label text-text-primary', required && 'flex items-center')}>
72
82
  {label}
73
83
  {required && <span className="text-error-default ml-0.5">*</span>}
74
- </label>
75
- <FieldControlIdContext.Provider value={id}>{children}</FieldControlIdContext.Provider>
76
- </div>
84
+ </BaseField.Label>
85
+ {/*
86
+ No line, no wrapper: every caller that predates `helper` and `error`
87
+ keeps the exact DOM it had, so the port cannot move a pixel. With a
88
+ line, this is the 6px stack Ship and Peek were both writing by hand.
89
+ */}
90
+ {line == null ? (
91
+ control
92
+ ) : (
93
+ <div className="flex flex-col gap-1.5">
94
+ {control}
95
+ {error != null ? (
96
+ <BaseField.Error match className="text-caption text-error-default">
97
+ {error}
98
+ </BaseField.Error>
99
+ ) : (
100
+ <BaseField.Description className="text-caption text-text-muted">{helper}</BaseField.Description>
101
+ )}
102
+ </div>
103
+ )}
104
+ </BaseField.Root>
77
105
  )
78
106
  }
@@ -43,8 +43,12 @@ import { IconPencil } from '@tabler/icons-react'
43
43
  - `type` defaults to `"button"`; it is a native `<button>` on Base UI's
44
44
  Button, and every native prop passes through.
45
45
  - `disabledReason="Read only"` disables it, keeps it reachable by Tab,
46
- and shows the reason in place of the tooltip (on hover for now; on
47
- keyboard focus too once Tooltip moves onto Base UI, stage 4).
46
+ and shows the reason in place of the tooltip — **on keyboard focus as
47
+ well as on hover**.
48
+ - `tooltip` costs no wrapper: the button is the tooltip's trigger, so
49
+ this component's root is the `<button>` whether it carries one or not,
50
+ and both sit at the same height. A `Dialog.Close` or a `Menu.Trigger`
51
+ can be an IconButton that has a tooltip.
48
52
 
49
53
  ## Keys
50
54
 
@@ -37,7 +37,8 @@ describe('IconButton', () => {
37
37
  )
38
38
  expect(screen.queryByRole('tooltip')).toBeNull()
39
39
  await user.hover(screen.getByRole('button', { name: 'Edit' }))
40
- expect(screen.getByRole('tooltip').textContent).toBe('EditE')
40
+ // 300ms before it opens (D23) - it used to be instant.
41
+ expect((await screen.findByRole('tooltip')).textContent).toBe('EditE')
41
42
  })
42
43
 
43
44
  it('disabledReason: disabled, reachable by Tab, the reason replaces the tooltip', async () => {
@@ -55,8 +56,10 @@ describe('IconButton', () => {
55
56
  expect(onClick).not.toHaveBeenCalled()
56
57
  await user.tab()
57
58
  expect(document.activeElement).toBe(button)
59
+ // Already open, in fact: the Tab above focused it, and focus opens
60
+ // with no delay at all.
58
61
  await user.hover(button)
59
- expect(screen.getByRole('tooltip').textContent).toBe('Read only')
62
+ expect((await screen.findByRole('tooltip')).textContent).toBe('Read only')
60
63
  })
61
64
 
62
65
  it('Space and Enter are the action, and nothing while disabled with a reason', async () => {
@@ -1,7 +1,7 @@
1
- import type { ButtonHTMLAttributes, ReactNode } from 'react'
1
+ import type { ComponentPropsWithRef, ReactNode } from 'react'
2
2
  import { Button as BaseButton } from '@base-ui/react/button'
3
3
  import { cn } from './cn'
4
- import { WithTooltip } from './Tooltip'
4
+ import { TooltipTrigger } from './Tooltip'
5
5
 
6
6
  /**
7
7
  * Peek's IconButton (2026-08-28), verbatim: a square 4px-padded button
@@ -15,7 +15,8 @@ import { WithTooltip } from './Tooltip'
15
15
  */
16
16
  export type IconButtonVariant = 'muted' | 'outlined' | 'primary'
17
17
 
18
- export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
18
+ /** `ComponentPropsWithRef` so a `ref` reaches the element — see Button. */
19
+ export interface IconButtonProps extends ComponentPropsWithRef<'button'> {
19
20
  variant?: IconButtonVariant
20
21
  tooltip?: string
21
22
  /** A key hint drawn as the `Kbd` chip inside the tooltip — for a button
@@ -55,7 +56,12 @@ export function IconButton({
55
56
  state.disabled && variant === 'primary' && 'bg-bg-disabled text-text-disabled',
56
57
  state.disabled && variant === 'muted' && 'text-text-disabled',
57
58
  state.disabled && variant === 'outlined' && 'border border-border-default text-text-disabled',
58
- state.disabled && 'pointer-events-none cursor-not-allowed',
59
+ // `pointer-events-none` only where the button is truly out of reach:
60
+ // with a `disabledReason` the button IS the tooltip's trigger, and a
61
+ // trigger the pointer cannot land on never opens one. Base UI already
62
+ // swallows the click.
63
+ state.disabled && !disabledReason && 'pointer-events-none',
64
+ state.disabled && 'cursor-not-allowed',
59
65
  className,
60
66
  )
61
67
  }
@@ -68,9 +74,9 @@ export function IconButton({
68
74
  const label = disabledReason ?? tooltip
69
75
  if (label) {
70
76
  return (
71
- <WithTooltip label={label} shortcut={disabledReason ? undefined : tooltipShortcut} placement={tooltipPlacement}>
77
+ <TooltipTrigger label={label} shortcut={disabledReason ? undefined : tooltipShortcut} placement={tooltipPlacement}>
72
78
  {button}
73
- </WithTooltip>
79
+ </TooltipTrigger>
74
80
  )
75
81
  }
76
82
  return button
@@ -50,12 +50,35 @@ import { IdentityMenu } from '@estiva-app/ui'
50
50
  receive `close`, so a row can shut the menu before opening what it
51
51
  opens.
52
52
  - Items are text only — no icons in this menu, by ruling.
53
- - **IdentityPanel** is exported alone for a surface that wants the panel
54
- without the trigger; the stories render it, so the designed artifact is
55
- what you review.
56
- - The trigger's toggle, `aria-expanded`, and the accessible-name rules
57
- (the row is named by the person; only the bare compact face takes an
58
- `aria-label`) are wired inside — bring nothing.
53
+ - **IdentityPanel** draws the same rows as a plain surface, without the menu
54
+ around them for a docs canvas, or a surface that wants the panel and not
55
+ the trigger.
56
+ - The trigger's toggle, its `aria-expanded`, the placement, the focus return
57
+ and the accessible-name rules (the row is named by the person; only the bare
58
+ compact face takes an `aria-label`) are all wired inside — bring nothing.
59
+ - **The panel hangs from the face, not from the wrapper around it** (stage 4).
60
+ It used to hang from the wrapper, whose box belongs to the app's layout: in
61
+ a flex row with the default `align-items: stretch` the wrapper takes the
62
+ row's full height, and the panel hung from the bottom of *that* — measured
63
+ 360px below the face, with a scrollbar it should not have had.
64
+
65
+ ## Keys
66
+
67
+ Since stage 4 the panel is a Base UI `Menu`, and **the arrow keys walk the
68
+ actions only** (Katerina, D22): they step over the identity block, the
69
+ workspace line and the notes, because those are not things you can do.
70
+
71
+ | Input | What happens |
72
+ |---|---|
73
+ | click / Enter / Space on the face | opens the menu |
74
+ | ↑ / ↓ | move between the actions — Edit your profile, Copy public key, Sign out, and any row the app adds — and wrap |
75
+ | Enter on an action | runs it, and closes the menu |
76
+ | Escape | closes, and gives focus back to the face |
77
+ | a press on the face while open | closes it |
78
+
79
+ The identity and workspace sections are `Menu.Group`s labelled by their
80
+ headings, so a screen reader announces them as named groups rather than as
81
+ menu items that are not items.
59
82
 
60
83
  ## Props
61
84
 
@@ -1,15 +1,25 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
- import { IdentityPanel } from './IdentityMenu'
2
+ import { IdentityMenu, IdentityPanelSurface } from './IdentityMenu'
3
3
 
4
4
  /**
5
- * The menu itself, standing in flow (`static` beats the anchoring, as the
6
- * Menu stories do it) — no trigger, no bar chrome: the trigger's shapes are
5
+ * The panel itself no trigger, no bar chrome: the trigger's shapes are
7
6
  * PersonTrigger's own stories. In an app, `IdentityMenu` bundles that
8
7
  * trigger with this panel.
8
+ *
9
+ * Drawn here on a `MenuPanel`, the menu's surface without its behaviour: a
10
+ * real menu portals and places itself against a trigger, so it cannot stand
11
+ * in a docs page, and the canvas must still show the artifact (Katerina,
12
+ * D25). The rows are the same ones the app gets.
13
+ *
14
+ * Since stage 4 the live panel is a Base UI `Menu`: **the arrow keys walk
15
+ * the actions and step over the identity block, the workspace line and the
16
+ * notes** (Katerina, D22). Those sections are `Menu.Group`s with the heading
17
+ * as their label, so they are announced as named groups rather than as menu
18
+ * items that are not items.
9
19
  */
10
20
  const meta = {
11
21
  title: 'Navigation/IdentityMenu',
12
- component: IdentityPanel,
22
+ component: IdentityPanelSurface,
13
23
  args: {
14
24
  me: {},
15
25
  signedIn: false,
@@ -17,10 +27,9 @@ const meta = {
17
27
  onCopyKey: () => {},
18
28
  onSignOut: () => {},
19
29
  onClose: () => {},
20
- className: 'static',
21
30
  },
22
31
  argTypes: { onClose: { control: false }, className: { control: false } },
23
- } satisfies Meta<typeof IdentityPanel>
32
+ } satisfies Meta<typeof IdentityPanelSurface>
24
33
 
25
34
  export default meta
26
35
  type Story = StoryObj<typeof meta>
@@ -54,3 +63,29 @@ export const MinimalApp: Story = {
54
63
  onCopyKey: undefined,
55
64
  },
56
65
  }
66
+
67
+ /**
68
+ * The whole thing, live: the trigger in the top bar and the menu it opens.
69
+ * Click the face, then try the keyboard — ↑ and ↓ walk **the actions only**,
70
+ * stepping over the identity block, the workspace line and the notes (D22);
71
+ * Escape closes it and gives focus back to the trigger.
72
+ */
73
+ export const FromItsTrigger: Story = {
74
+ parameters: { controls: { disable: true }, layout: 'padded' },
75
+ render: () => (
76
+ /* A top bar, and room under it for the panel: 288 x ~380. `items-center`
77
+ is how both apps lay their bar out. */
78
+ <div className="h-[560px] w-full">
79
+ <div className="flex h-12 items-center justify-end border-b border-border-subtle px-4">
80
+ <IdentityMenu
81
+ me={{ name: 'Ana Duarte', email: 'ana@example.com' }}
82
+ signedIn
83
+ idBase="https://id.estiva.app"
84
+ relayUrl="http://localhost:3000"
85
+ onCopyKey={() => {}}
86
+ onSignOut={() => {}}
87
+ />
88
+ </div>
89
+ </div>
90
+ ),
91
+ }