@estiva-app/ui 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Avatar.d.ts +13 -1
- package/dist/Avatar.d.ts.map +1 -1
- package/dist/AvatarGroup.d.ts.map +1 -1
- package/dist/Banner.d.ts +16 -1
- package/dist/Banner.d.ts.map +1 -1
- package/dist/Breadcrumb.d.ts +9 -0
- package/dist/Breadcrumb.d.ts.map +1 -1
- package/dist/Button.d.ts +12 -2
- package/dist/Button.d.ts.map +1 -1
- package/dist/Checkbox.d.ts +2 -0
- package/dist/Checkbox.d.ts.map +1 -1
- package/dist/Chip.d.ts +3 -2
- package/dist/Chip.d.ts.map +1 -1
- package/dist/ChipInput.d.ts +3 -1
- package/dist/ChipInput.d.ts.map +1 -1
- package/dist/ConfirmDialog.d.ts +5 -0
- package/dist/ConfirmDialog.d.ts.map +1 -1
- package/dist/DialogShell.d.ts +27 -3
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/EditableText.d.ts +6 -0
- package/dist/EditableText.d.ts.map +1 -1
- package/dist/Field.d.ts +42 -25
- package/dist/Field.d.ts.map +1 -1
- package/dist/IconButton.d.ts +3 -2
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/IdentityMenu.d.ts +25 -13
- package/dist/IdentityMenu.d.ts.map +1 -1
- package/dist/Menu.d.ts +65 -82
- package/dist/Menu.d.ts.map +1 -1
- package/dist/PersonTrigger.d.ts.map +1 -1
- package/dist/Popover.d.ts +86 -0
- package/dist/Popover.d.ts.map +1 -0
- package/dist/PreviewCard.d.ts +39 -0
- package/dist/PreviewCard.d.ts.map +1 -0
- package/dist/Reaction.d.ts.map +1 -1
- package/dist/ReactionPicker.d.ts +55 -0
- package/dist/ReactionPicker.d.ts.map +1 -0
- package/dist/SearchInput.d.ts +5 -0
- package/dist/SearchInput.d.ts.map +1 -1
- package/dist/Select.d.ts +26 -16
- package/dist/Select.d.ts.map +1 -1
- package/dist/Tabs.d.ts +11 -1
- package/dist/Tabs.d.ts.map +1 -1
- package/dist/TextInput.d.ts +7 -1
- package/dist/TextInput.d.ts.map +1 -1
- package/dist/Textarea.d.ts +8 -1
- package/dist/Textarea.d.ts.map +1 -1
- package/dist/Toolbar.d.ts +95 -0
- package/dist/Toolbar.d.ts.map +1 -0
- package/dist/Tooltip.d.ts +36 -10
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/cn.d.ts.map +1 -1
- package/dist/fit.d.ts +7 -72
- package/dist/fit.d.ts.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +882 -763
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/Avatar.mdx +17 -0
- package/src/Avatar.name.test.tsx +99 -0
- package/src/Avatar.tsx +25 -3
- package/src/AvatarGroup.tsx +4 -1
- package/src/Banner.mdx +10 -2
- package/src/Banner.stories.tsx +29 -0
- package/src/Banner.test.tsx +60 -0
- package/src/Banner.tsx +35 -3
- package/src/Breadcrumb.mdx +5 -2
- package/src/Breadcrumb.test.tsx +44 -0
- package/src/Breadcrumb.tsx +10 -2
- package/src/Button.compose.test.tsx +119 -0
- package/src/Button.mdx +7 -5
- package/src/Button.test.tsx +2 -1
- package/src/Button.tsx +21 -5
- package/src/Checkbox.tsx +3 -0
- package/src/Chip.tsx +3 -2
- package/src/ChipInput.tsx +4 -0
- package/src/ConfirmDialog.mdx +20 -1
- package/src/ConfirmDialog.test.tsx +109 -0
- package/src/ConfirmDialog.tsx +6 -0
- package/src/DialogShell.mdx +18 -4
- package/src/DialogShell.stories.tsx +56 -6
- package/src/DialogShell.test.tsx +184 -0
- package/src/DialogShell.tsx +108 -39
- package/src/EditableText.mdx +6 -1
- package/src/EditableText.test.tsx +156 -0
- package/src/EditableText.tsx +23 -5
- package/src/Field.mdx +23 -3
- package/src/Field.stories.tsx +35 -0
- package/src/Field.test.tsx +117 -27
- package/src/Field.tsx +86 -58
- package/src/IconButton.mdx +6 -2
- package/src/IconButton.test.tsx +5 -2
- package/src/IconButton.tsx +12 -6
- package/src/IdentityMenu.mdx +29 -6
- package/src/IdentityMenu.stories.tsx +41 -6
- package/src/IdentityMenu.tsx +74 -47
- package/src/Kbd.stories.tsx +3 -3
- package/src/Menu.mdx +88 -75
- package/src/Menu.stories.tsx +120 -52
- package/src/Menu.test.tsx +315 -0
- package/src/Menu.tsx +341 -251
- package/src/MenuItem.stories.tsx +135 -98
- package/src/NavItem.mdx +1 -1
- package/src/PersonTrigger.mdx +20 -14
- package/src/PersonTrigger.tsx +23 -1
- package/src/Popover.mdx +130 -0
- package/src/Popover.stories.tsx +222 -0
- package/src/Popover.test.tsx +188 -0
- package/src/Popover.tsx +153 -0
- package/src/PreviewCard.mdx +98 -0
- package/src/PreviewCard.stories.tsx +168 -0
- package/src/PreviewCard.test.tsx +83 -0
- package/src/PreviewCard.tsx +91 -0
- package/src/Rail.mdx +63 -0
- package/src/Rail.stories.tsx +81 -0
- package/src/Reaction.mdx +4 -2
- package/src/Reaction.tsx +4 -2
- package/src/ReactionPicker.mdx +85 -0
- package/src/ReactionPicker.stories.tsx +120 -0
- package/src/ReactionPicker.test.tsx +118 -0
- package/src/ReactionPicker.tsx +88 -0
- package/src/SearchInput.mdx +6 -2
- package/src/SearchInput.tsx +7 -1
- package/src/Select.mdx +16 -5
- package/src/Select.test.tsx +157 -0
- package/src/Select.tsx +89 -214
- package/src/Sidebar.mdx +6 -2
- package/src/Tabs.mdx +5 -0
- package/src/Tabs.test.tsx +15 -0
- package/src/Tabs.tsx +17 -2
- package/src/TextInput.mdx +8 -2
- package/src/TextInput.tsx +10 -7
- package/src/Textarea.mdx +6 -2
- package/src/Textarea.tsx +14 -9
- package/src/Toolbar.mdx +104 -0
- package/src/Toolbar.stories.tsx +142 -0
- package/src/Toolbar.test.tsx +198 -0
- package/src/Toolbar.tsx +158 -0
- package/src/Tooltip.mdx +43 -5
- package/src/Tooltip.stories.tsx +26 -0
- package/src/Tooltip.test.tsx +195 -0
- package/src/Tooltip.tsx +150 -48
- package/src/cn.ts +1 -1
- package/src/fit.test.ts +101 -0
- package/src/fit.ts +19 -63
- package/src/index.ts +6 -3
- package/stories/Choosing.mdx +8 -3
- package/tailwind-preset.js +7 -0
- package/src/Menu.fit.test.ts +0 -90
- package/src/Select.fit.test.ts +0 -101
package/src/Field.test.tsx
CHANGED
|
@@ -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
|
|
51
|
-
//
|
|
52
|
-
//
|
|
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('
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
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).
|
|
93
|
+
expect(input.id).toBe('chosen-by-the-caller')
|
|
89
94
|
})
|
|
90
95
|
|
|
91
|
-
it('
|
|
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"
|
|
117
|
+
<Field label="Title" required>
|
|
94
118
|
<TextInput defaultValue="" />
|
|
95
119
|
</Field>,
|
|
96
120
|
)
|
|
97
|
-
expect(screen.
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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.
|
|
115
|
-
|
|
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 {
|
|
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
|
|
45
|
-
* class
|
|
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
|
|
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
|
-
*
|
|
57
|
-
*
|
|
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
|
-
|
|
56
|
+
error?: string
|
|
60
57
|
children: ReactNode
|
|
61
58
|
}
|
|
62
59
|
|
|
63
|
-
export function Field({ label, required = false,
|
|
64
|
-
const
|
|
65
|
-
|
|
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
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
</
|
|
75
|
-
|
|
76
|
-
|
|
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
|
}
|
package/src/IconButton.mdx
CHANGED
|
@@ -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
|
|
47
|
-
|
|
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
|
|
package/src/IconButton.test.tsx
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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 () => {
|
package/src/IconButton.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
77
|
+
<TooltipTrigger label={label} shortcut={disabledReason ? undefined : tooltipShortcut} placement={tooltipPlacement}>
|
|
72
78
|
{button}
|
|
73
|
-
</
|
|
79
|
+
</TooltipTrigger>
|
|
74
80
|
)
|
|
75
81
|
}
|
|
76
82
|
return button
|
package/src/IdentityMenu.mdx
CHANGED
|
@@ -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**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
- The trigger's toggle, `aria-expanded`,
|
|
57
|
-
(the row is named by the person; only the bare
|
|
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 {
|
|
2
|
+
import { IdentityMenu, IdentityPanelSurface } from './IdentityMenu'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* 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:
|
|
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
|
|
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
|
+
}
|