@estiva-app/ui 0.10.0 → 0.10.1
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/Field.d.ts.map +1 -1
- package/dist/Menu.d.ts.map +1 -1
- package/dist/Popover.d.ts.map +1 -1
- package/dist/index.js +11 -5
- package/dist/index.js.map +3 -3
- package/dist/triggerDisabled.d.ts +10 -0
- package/dist/triggerDisabled.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/Field.test.tsx +36 -8
- package/src/Field.tsx +26 -19
- package/src/Menu.tsx +6 -0
- package/src/Popover.tsx +4 -1
- package/src/Toolbar.test.tsx +55 -0
- package/src/triggerDisabled.ts +13 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Whether the element a `Menu` or `Popover` was handed as its trigger is
|
|
4
|
+
* disabled — by `disabled`, or by a `disabledReason` (which disables the
|
|
5
|
+
* button and keeps it reachable). Base UI's trigger parts keep a disabled
|
|
6
|
+
* state of their own and write it over the rendered button's, so they have to
|
|
7
|
+
* be told (Finding 39, 2026-09-08).
|
|
8
|
+
*/
|
|
9
|
+
export declare function triggerDisabled(trigger: ReactElement): boolean;
|
|
10
|
+
//# sourceMappingURL=triggerDisabled.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"triggerDisabled.d.ts","sourceRoot":"","sources":["../src/triggerDisabled.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,OAAO,CAAA;AAEzC;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAG9D"}
|
package/package.json
CHANGED
package/src/Field.test.tsx
CHANGED
|
@@ -192,16 +192,44 @@ describe('Field', () => {
|
|
|
192
192
|
expect(document.getElementById(describedBy!)?.textContent).toBe('That is not a Folder link.')
|
|
193
193
|
})
|
|
194
194
|
|
|
195
|
-
it('
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
195
|
+
it('keeps the same control, and its focus, when an error appears and clears', () => {
|
|
196
|
+
// Finding 38 (Ship's adoption, 2026-09-08): the control used to be wrapped
|
|
197
|
+
// only when there was a line under it, so React re-created it the moment
|
|
198
|
+
// an error appeared or cleared — and a person typing into a field whose
|
|
199
|
+
// error clears on input lost focus after the first keystroke. The control
|
|
200
|
+
// sits in the same place now whatever is under it.
|
|
201
|
+
const { rerender } = render(
|
|
202
|
+
<Field label="Folder link">
|
|
203
|
+
<TextInput defaultValue="" />
|
|
204
|
+
</Field>,
|
|
205
|
+
)
|
|
206
|
+
const input = screen.getByLabelText('Folder link')
|
|
207
|
+
input.focus()
|
|
208
|
+
rerender(
|
|
209
|
+
<Field label="Folder link" error="That is not a Folder link.">
|
|
210
|
+
<TextInput defaultValue="" />
|
|
211
|
+
</Field>,
|
|
212
|
+
)
|
|
213
|
+
expect(screen.getByLabelText('Folder link')).toBe(input)
|
|
214
|
+
expect(document.activeElement).toBe(input)
|
|
215
|
+
rerender(
|
|
216
|
+
<Field label="Folder link">
|
|
217
|
+
<TextInput defaultValue="" />
|
|
218
|
+
</Field>,
|
|
219
|
+
)
|
|
220
|
+
expect(screen.getByLabelText('Folder link')).toBe(input)
|
|
221
|
+
expect(document.activeElement).toBe(input)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('keeps the required asterisk out of the accessible name', () => {
|
|
225
|
+
// Ship's test asked for a combobox named "Project" and found "Project*"
|
|
226
|
+
// (Finding 38). The asterisk is a picture; `aria-required` is the word.
|
|
227
|
+
render(
|
|
228
|
+
<Field label="Title" required>
|
|
200
229
|
<TextInput defaultValue="" />
|
|
201
230
|
</Field>,
|
|
202
231
|
)
|
|
203
|
-
|
|
204
|
-
expect(
|
|
205
|
-
expect(root.children[1].tagName).toBe('INPUT')
|
|
232
|
+
expect(screen.getByRole('textbox', { name: 'Title' })).toBeTruthy()
|
|
233
|
+
expect(screen.getByText('*').getAttribute('aria-hidden')).toBe('true')
|
|
206
234
|
})
|
|
207
235
|
})
|
package/src/Field.tsx
CHANGED
|
@@ -58,7 +58,7 @@ export interface FieldProps {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
export function Field({ label, required = false, helper, error, children }: FieldProps) {
|
|
61
|
-
|
|
61
|
+
|
|
62
62
|
/*
|
|
63
63
|
The asterisk is a picture of `required`; this is the word for it. Base UI's
|
|
64
64
|
`Field` has no `required` to propagate, so the control is marked here —
|
|
@@ -80,27 +80,34 @@ export function Field({ label, required = false, helper, error, children }: Fiel
|
|
|
80
80
|
`cn.test.ts` pins that. */}
|
|
81
81
|
<BaseField.Label className={cn('text-input-label text-text-primary', required && 'flex items-center')}>
|
|
82
82
|
{label}
|
|
83
|
-
{
|
|
83
|
+
{/* A picture, not a word: `aria-required` on the control says it, and
|
|
84
|
+
without this the control was named "Title*" (Finding 38). */}
|
|
85
|
+
{required && (
|
|
86
|
+
<span aria-hidden="true" className="text-error-default ml-0.5">
|
|
87
|
+
*
|
|
88
|
+
</span>
|
|
89
|
+
)}
|
|
84
90
|
</BaseField.Label>
|
|
85
91
|
{/*
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
92
|
+
The control sits in the same place whether or not there is a line
|
|
93
|
+
under it. It used to be wrapped only when there was one, and React
|
|
94
|
+
then re-created the control the moment an error appeared or cleared —
|
|
95
|
+
so a person typing into a field whose error clears on input lost
|
|
96
|
+
focus after the first keystroke (found by Ship's adoption, 2026-09-08,
|
|
97
|
+
Finding 38). A one-child flex column draws exactly as the bare control
|
|
98
|
+
did; the 6px stack Ship and Peek both wrote by hand appears only with a
|
|
99
|
+
line.
|
|
89
100
|
*/}
|
|
90
|
-
|
|
91
|
-
control
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
<BaseField.Description className="text-caption text-text-muted">{helper}</BaseField.Description>
|
|
101
|
-
)}
|
|
102
|
-
</div>
|
|
103
|
-
)}
|
|
101
|
+
<div className="flex flex-col gap-1.5">
|
|
102
|
+
{control}
|
|
103
|
+
{error != null ? (
|
|
104
|
+
<BaseField.Error match className="text-caption text-error-default">
|
|
105
|
+
{error}
|
|
106
|
+
</BaseField.Error>
|
|
107
|
+
) : helper != null ? (
|
|
108
|
+
<BaseField.Description className="text-caption text-text-muted">{helper}</BaseField.Description>
|
|
109
|
+
) : null}
|
|
110
|
+
</div>
|
|
104
111
|
</BaseField.Root>
|
|
105
112
|
)
|
|
106
113
|
}
|
package/src/Menu.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { createContext, useContext, type ComponentPropsWithRef, type ReactElemen
|
|
|
2
2
|
import { IconChevronRight } from '@tabler/icons-react'
|
|
3
3
|
import { Menu as BaseMenu } from '@base-ui/react/menu'
|
|
4
4
|
import { cn } from './cn'
|
|
5
|
+
import { triggerDisabled } from './triggerDisabled'
|
|
5
6
|
import { Kbd } from './Kbd'
|
|
6
7
|
import { SectionLabel } from './SectionLabel'
|
|
7
8
|
|
|
@@ -173,6 +174,11 @@ export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpe
|
|
|
173
174
|
>
|
|
174
175
|
<BaseMenu.Trigger
|
|
175
176
|
render={trigger}
|
|
177
|
+
/* The part writes its own disabled state over the button's, so a
|
|
178
|
+
disabled trigger came out `aria-disabled="false"` and opened
|
|
179
|
+
(Finding 39, Ship's adoption, 2026-09-08). It is told what the
|
|
180
|
+
button already knows. */
|
|
181
|
+
disabled={triggerDisabled(trigger)}
|
|
176
182
|
openOnHover={openOnHover}
|
|
177
183
|
delay={HOVER_OPEN_DELAY}
|
|
178
184
|
closeDelay={HOVER_CLOSE_DELAY}
|
package/src/Popover.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useMemo, type ReactElement, type ReactNode, type RefObject } from 'react'
|
|
2
2
|
import { Popover as BasePopover } from '@base-ui/react/popover'
|
|
3
3
|
import { cn } from './cn'
|
|
4
|
+
import { triggerDisabled } from './triggerDisabled'
|
|
4
5
|
import { MenuPanel } from './Menu'
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -108,7 +109,9 @@ export function Popover({ trigger, anchor, align = 'left', side = 'bottom', open
|
|
|
108
109
|
take the page away from the person using it. */
|
|
109
110
|
modal={false}
|
|
110
111
|
>
|
|
111
|
-
{
|
|
112
|
+
{/* Told the button's disabled state, or it writes `aria-disabled="false"`
|
|
113
|
+
over it and opens anyway (Finding 39). */}
|
|
114
|
+
{trigger && <BasePopover.Trigger render={trigger} disabled={triggerDisabled(trigger)} />}
|
|
112
115
|
<BasePopover.Portal>
|
|
113
116
|
<BasePopover.Positioner
|
|
114
117
|
anchor={anchorTarget}
|
package/src/Toolbar.test.tsx
CHANGED
|
@@ -8,6 +8,8 @@ import { cleanup, render, screen } from '@testing-library/react'
|
|
|
8
8
|
import userEvent from '@testing-library/user-event'
|
|
9
9
|
import { IconButton } from './IconButton'
|
|
10
10
|
import { Toolbar, ToolbarButton, ToolbarInput, ToolbarSeparator } from './Toolbar'
|
|
11
|
+
import { Menu, MenuItem } from './Menu'
|
|
12
|
+
import { Popover } from './Popover'
|
|
11
13
|
|
|
12
14
|
afterEach(cleanup)
|
|
13
15
|
|
|
@@ -196,3 +198,56 @@ describe('a loose row, for comparison', () => {
|
|
|
196
198
|
expect(walk).toEqual(['One', 'Two', 'Three', 'After'])
|
|
197
199
|
})
|
|
198
200
|
})
|
|
201
|
+
|
|
202
|
+
describe('a ToolbarButton with a reason, as a trigger', () => {
|
|
203
|
+
/*
|
|
204
|
+
Found by Ship's adoption (2026-09-08, Finding 39): the message's tools put
|
|
205
|
+
"React" (a Popover trigger) and "…" (a Menu trigger) on one strip, and with
|
|
206
|
+
a `disabledReason` both came out `aria-disabled="false"`. The part that
|
|
207
|
+
renders the trigger writes its own disabled state over the button's.
|
|
208
|
+
*/
|
|
209
|
+
it('stays disabled, reachable, and closed as a Popover trigger', async () => {
|
|
210
|
+
const user = userEvent.setup()
|
|
211
|
+
render(
|
|
212
|
+
<Toolbar aria-label="Tools">
|
|
213
|
+
<Popover
|
|
214
|
+
ariaLabel="Reactions"
|
|
215
|
+
trigger={
|
|
216
|
+
<ToolbarButton aria-label="React" disabledReason="Sign in to react.">
|
|
217
|
+
{dot}
|
|
218
|
+
</ToolbarButton>
|
|
219
|
+
}
|
|
220
|
+
>
|
|
221
|
+
<p>the picker</p>
|
|
222
|
+
</Popover>
|
|
223
|
+
</Toolbar>,
|
|
224
|
+
)
|
|
225
|
+
const button = screen.getByRole('button', { name: 'React' })
|
|
226
|
+
expect(button.getAttribute('aria-disabled')).toBe('true')
|
|
227
|
+
expect(button.getAttribute('tabindex')).toBe('0')
|
|
228
|
+
await user.click(button)
|
|
229
|
+
expect(screen.queryByRole('dialog')).toBeNull()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('stays disabled, reachable, and closed as a Menu trigger', async () => {
|
|
233
|
+
const user = userEvent.setup()
|
|
234
|
+
render(
|
|
235
|
+
<Toolbar aria-label="Tools">
|
|
236
|
+
<Menu
|
|
237
|
+
trigger={
|
|
238
|
+
<ToolbarButton aria-label="More" disabledReason="Sign in first.">
|
|
239
|
+
{dot}
|
|
240
|
+
</ToolbarButton>
|
|
241
|
+
}
|
|
242
|
+
>
|
|
243
|
+
<MenuItem label="Delete" onClick={() => {}} />
|
|
244
|
+
</Menu>
|
|
245
|
+
</Toolbar>,
|
|
246
|
+
)
|
|
247
|
+
const button = screen.getByRole('button', { name: 'More' })
|
|
248
|
+
expect(button.getAttribute('aria-disabled')).toBe('true')
|
|
249
|
+
expect(button.getAttribute('tabindex')).toBe('0')
|
|
250
|
+
await user.click(button)
|
|
251
|
+
expect(screen.queryByRole('menu')).toBeNull()
|
|
252
|
+
})
|
|
253
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ReactElement } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether the element a `Menu` or `Popover` was handed as its trigger is
|
|
5
|
+
* disabled — by `disabled`, or by a `disabledReason` (which disables the
|
|
6
|
+
* button and keeps it reachable). Base UI's trigger parts keep a disabled
|
|
7
|
+
* state of their own and write it over the rendered button's, so they have to
|
|
8
|
+
* be told (Finding 39, 2026-09-08).
|
|
9
|
+
*/
|
|
10
|
+
export function triggerDisabled(trigger: ReactElement): boolean {
|
|
11
|
+
const props = trigger.props as { disabled?: boolean; disabledReason?: string }
|
|
12
|
+
return !!(props.disabled || props.disabledReason)
|
|
13
|
+
}
|