@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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('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">
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
- const root = container.firstElementChild!
204
- expect(root.children).toHaveLength(2)
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
- const line = error ?? helper
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
- {required && <span className="text-error-default ml-0.5">*</span>}
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
- 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.
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
- {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
- )}
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
- {trigger && <BasePopover.Trigger render={trigger} />}
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}
@@ -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
+ }