@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/DialogShell.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { useRef, type ReactNode } from 'react'
|
|
2
|
+
import { Dialog } from '@base-ui/react/dialog'
|
|
3
|
+
import { AlertDialog } from '@base-ui/react/alert-dialog'
|
|
3
4
|
import { IconX } from '@tabler/icons-react'
|
|
4
5
|
import { cn } from './cn'
|
|
5
6
|
import { IconButton } from './IconButton'
|
|
@@ -8,8 +9,25 @@ import { IconButton } from './IconButton'
|
|
|
8
9
|
* Peek's DialogShell (2026-08-28), verbatim: the portal, the backdrop, the
|
|
9
10
|
* 502px card and its chrome — a 48px header with the title and a close
|
|
10
11
|
* button, a body, a 48px footer for the buttons. A dialog is just what goes
|
|
11
|
-
* in the three slots.
|
|
12
|
-
*
|
|
12
|
+
* in the three slots. On Base UI's `Dialog` since stage 3 of the migration
|
|
13
|
+
* (2026-09-07).
|
|
14
|
+
*
|
|
15
|
+
* **What that fixed, and this page used to say it did not:** focus is trapped
|
|
16
|
+
* inside the card, so Tab cannot walk out into the page behind it, and it
|
|
17
|
+
* returns to whatever opened the dialog when it closes. Both were missing and
|
|
18
|
+
* both were written down as missing. The portal, the outside press and the
|
|
19
|
+
* Escape key are Base UI's too — the `keydown` listener this file kept on
|
|
20
|
+
* `document` is gone, and with it the bug where two open dialogs both closed
|
|
21
|
+
* on one Escape.
|
|
22
|
+
*
|
|
23
|
+
* The card is mounted only while it is open, as before: this component has no
|
|
24
|
+
* `open` prop, and a caller renders it or does not. So `open` is `true` and
|
|
25
|
+
* `onOpenChange` reports the ways Base UI closes it — Escape, a press outside,
|
|
26
|
+
* the close button — through the one `onClose` the callers already pass.
|
|
27
|
+
*
|
|
28
|
+
* The DOM is the same shape it was, deliberately: a backdrop, then a
|
|
29
|
+
* full-screen flex layer that centres the card. Base UI positions nothing for
|
|
30
|
+
* a dialog, so keeping the layer is what keeps the pixels.
|
|
13
31
|
*/
|
|
14
32
|
export interface DialogShellProps {
|
|
15
33
|
/** Labels the dialog for assistive tech, and renders as the header text
|
|
@@ -26,47 +44,98 @@ export interface DialogShellProps {
|
|
|
26
44
|
/** Extra classes on the body (e.g. `flex flex-col gap-6`, or a max height with `overflow-y-auto`). */
|
|
27
45
|
bodyClassName?: string
|
|
28
46
|
width?: number
|
|
47
|
+
/**
|
|
48
|
+
* A question that has to be answered rather than clicked away: a press on
|
|
49
|
+
* the backdrop no longer closes it, and it announces itself as an alert.
|
|
50
|
+
* Escape and the close button still work. `ConfirmDialog` is this
|
|
51
|
+
* (Katerina, D20, 2026-09-07); a form or a roster is not.
|
|
52
|
+
*/
|
|
53
|
+
alert?: boolean
|
|
29
54
|
}
|
|
30
55
|
|
|
31
|
-
export function DialogShell({ title, onClose, headerContent, footer, children, bodyClassName, width = 502 }: DialogShellProps) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
56
|
+
export function DialogShell({ title, onClose, headerContent, footer, children, bodyClassName, width = 502, alert = false }: DialogShellProps) {
|
|
57
|
+
// The two families are the same parts with different dismiss rules, so the
|
|
58
|
+
// chrome below is written once. AlertDialog re-exports Dialog's Backdrop,
|
|
59
|
+
// Popup, Portal and Title types, which is why this substitutes cleanly.
|
|
60
|
+
const Parts = alert ? AlertDialog : Dialog
|
|
61
|
+
const popupRef = useRef<HTMLDivElement>(null)
|
|
62
|
+
return (
|
|
63
|
+
<Parts.Root
|
|
64
|
+
open
|
|
65
|
+
onOpenChange={(open) => {
|
|
66
|
+
if (!open) onClose()
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
<Parts.Portal>
|
|
70
|
+
{/* Backdrop */}
|
|
71
|
+
<Parts.Backdrop className="fixed inset-0 z-40 bg-scrim" />
|
|
39
72
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
73
|
+
{/* Dialog */}
|
|
74
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
|
|
75
|
+
<Parts.Popup
|
|
76
|
+
ref={popupRef}
|
|
77
|
+
/*
|
|
78
|
+
Focus the card, not the first control in it.
|
|
44
79
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
aria-label={title}
|
|
51
|
-
className="bg-bg-elevated border border-border-subtle rounded-lg shadow-lg pointer-events-auto flex flex-col overflow-hidden"
|
|
52
|
-
style={{ width }}
|
|
53
|
-
>
|
|
54
|
-
{/* Header */}
|
|
55
|
-
<div className="h-12 flex items-center justify-between pl-5 pr-4 border-b border-border-subtle shrink-0">
|
|
56
|
-
{headerContent ?? <span className="text-h4 text-text-primary">{title}</span>}
|
|
57
|
-
<IconButton tooltip="Close" aria-label="Close" onClick={onClose}>
|
|
58
|
-
<IconX size={16} stroke={1.5} />
|
|
59
|
-
</IconButton>
|
|
60
|
-
</div>
|
|
80
|
+
Base UI's default is the first tabbable element, which here is the
|
|
81
|
+
✕ in the header — so every dialog opened with a visible ring on
|
|
82
|
+
its close button, which the screenshot diff caught as a 28px
|
|
83
|
+
square nobody had asked for. Focusing the card arms the trap and
|
|
84
|
+
lets a screen reader read the dialog, with no control lit up.
|
|
61
85
|
|
|
62
|
-
|
|
63
|
-
|
|
86
|
+
A field with `autoFocus` still wins: React focuses it while the
|
|
87
|
+
popup mounts, and Base UI does not move focus that has already
|
|
88
|
+
landed inside. Both dialogs in Ship rely on that.
|
|
89
|
+
*/
|
|
90
|
+
initialFocus={popupRef}
|
|
91
|
+
/* The header names it when the header is the title. When
|
|
92
|
+
`headerContent` replaces that text there is nothing to point at,
|
|
93
|
+
so the name is spelled instead — as this component always did. */
|
|
94
|
+
aria-label={headerContent != null ? title : undefined}
|
|
95
|
+
/* `outline-none` because the card is a programmatic focus target,
|
|
96
|
+
not something a keyboard user tabs to: without it Chrome rings
|
|
97
|
+
the whole 502px card on open, which the diff caught the moment
|
|
98
|
+
`initialFocus` moved off the ✕. The controls inside keep their
|
|
99
|
+
own focus styling. */
|
|
100
|
+
className="bg-bg-elevated border border-border-subtle rounded-lg shadow-lg pointer-events-auto flex flex-col overflow-hidden outline-none"
|
|
101
|
+
style={{ width }}
|
|
102
|
+
>
|
|
103
|
+
{/* Header */}
|
|
104
|
+
<div className="h-12 flex items-center justify-between pl-5 pr-4 border-b border-border-subtle shrink-0">
|
|
105
|
+
{headerContent ?? (
|
|
106
|
+
<Parts.Title className="text-h4 text-text-primary" render={<span />}>
|
|
107
|
+
{title}
|
|
108
|
+
</Parts.Title>
|
|
109
|
+
)}
|
|
110
|
+
{/*
|
|
111
|
+
The ✕ IS the `Close` part now, rather than a button that calls
|
|
112
|
+
`onClose` beside one (stage 4, 2026-09-07). Stage 3 could not do
|
|
113
|
+
this: an `IconButton` carrying a `tooltip` returned the tooltip
|
|
114
|
+
wrapper `<div>` as its root, so the part composed onto the
|
|
115
|
+
wrapper and not the button. Porting Tooltip removed the wrapper
|
|
116
|
+
— the trigger is the button itself — and the composition works.
|
|
64
117
|
|
|
65
|
-
|
|
66
|
-
|
|
118
|
+
What it buys: the dialog closes through its own state machine,
|
|
119
|
+
so the ✕, Escape and the outside press are one path with one
|
|
120
|
+
reason attached, instead of one of the three going around.
|
|
121
|
+
*/}
|
|
122
|
+
<Parts.Close
|
|
123
|
+
render={
|
|
124
|
+
<IconButton tooltip="Close" aria-label="Close">
|
|
125
|
+
<IconX size={16} stroke={1.5} />
|
|
126
|
+
</IconButton>
|
|
127
|
+
}
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{/* Body */}
|
|
132
|
+
<div className={cn('pl-5 pr-4 py-4', footer != null && 'border-b border-border-subtle', bodyClassName)}>{children}</div>
|
|
133
|
+
|
|
134
|
+
{/* Footer */}
|
|
135
|
+
{footer != null && <div className="h-12 flex items-center justify-end gap-2 pl-5 pr-4 shrink-0">{footer}</div>}
|
|
136
|
+
</Parts.Popup>
|
|
67
137
|
</div>
|
|
68
|
-
</
|
|
69
|
-
|
|
70
|
-
document.body,
|
|
138
|
+
</Parts.Portal>
|
|
139
|
+
</Parts.Root>
|
|
71
140
|
)
|
|
72
141
|
}
|
package/src/EditableText.mdx
CHANGED
|
@@ -40,7 +40,12 @@ import { EditableText } from '@estiva-app/ui'
|
|
|
40
40
|
- `onCommit` resolves `true` to close; `false` (or a throw) keeps the
|
|
41
41
|
field open — you have already told the person why. An unchanged value is
|
|
42
42
|
not committed at all.
|
|
43
|
-
- `label` is required — it is the field's accessible name.
|
|
43
|
+
- `label` is required — it is the field's accessible name. This one is
|
|
44
|
+
spelled rather than taken from a `Field`: the component is a line of text
|
|
45
|
+
that becomes a field, and it is used where there is no label to read.
|
|
46
|
+
- The editing state is Base UI's `Input` since stage 3, or `Field.Control`
|
|
47
|
+
as a `<textarea>` when `multiline`. **Everything below is ours**, because
|
|
48
|
+
Base UI has no inline-edit part and no opinion about what an edit means.
|
|
44
49
|
- `display` shows something other than what is edited (a value carrying
|
|
45
50
|
raw references, rendered richer when read) without losing the original
|
|
46
51
|
on commit.
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the EditableText page claims, pinned.
|
|
4
|
+
*
|
|
5
|
+
* It had no test file, and it is the component in the package with the most
|
|
6
|
+
* rules of its own: Base UI supplies the field and nothing else, so *every*
|
|
7
|
+
* sentence on its page — Enter commits, Escape cancels, blur commits, an
|
|
8
|
+
* unchanged value is not committed, a refused commit keeps the text — is this
|
|
9
|
+
* file's code and was held by nothing.
|
|
10
|
+
*/
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
12
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
13
|
+
import userEvent from '@testing-library/user-event'
|
|
14
|
+
import { EditableText } from './EditableText'
|
|
15
|
+
|
|
16
|
+
afterEach(cleanup)
|
|
17
|
+
|
|
18
|
+
const read = () => screen.getByRole('button', { name: 'Edit title' })
|
|
19
|
+
const field = () => screen.getByRole('textbox', { name: 'Title' })
|
|
20
|
+
|
|
21
|
+
describe('EditableText: reading', () => {
|
|
22
|
+
it('reads as a button named for what it edits', () => {
|
|
23
|
+
render(<EditableText value="Item one" placeholder="Untitled" label="Title" onCommit={() => true} />)
|
|
24
|
+
expect(read().textContent).toBe('Item one')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('shows the placeholder when there is nothing yet', () => {
|
|
28
|
+
render(<EditableText value="" placeholder="Untitled" label="Title" onCommit={() => true} />)
|
|
29
|
+
expect(read().textContent).toBe('Untitled')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('`display` changes what is shown, never what is edited', async () => {
|
|
33
|
+
const user = userEvent.setup()
|
|
34
|
+
render(<EditableText value="Item one [ref]" display="Item one" placeholder="Untitled" label="Title" onCommit={() => true} />)
|
|
35
|
+
expect(read().textContent).toBe('Item one')
|
|
36
|
+
await user.click(read())
|
|
37
|
+
expect((field() as HTMLInputElement).value).toBe('Item one [ref]')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('read-only shows the value and offers no way in', () => {
|
|
41
|
+
render(<EditableText readOnly value="Item one" placeholder="Untitled" label="Title" onCommit={() => true} />)
|
|
42
|
+
expect(screen.queryByRole('button')).toBeNull()
|
|
43
|
+
expect(screen.getByText('Item one')).toBeTruthy()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('EditableText: editing', () => {
|
|
48
|
+
it('a click opens the field with the value in it, selected', async () => {
|
|
49
|
+
const user = userEvent.setup()
|
|
50
|
+
render(<EditableText value="Item one" placeholder="Untitled" label="Title" onCommit={() => true} />)
|
|
51
|
+
await user.click(read())
|
|
52
|
+
const input = field() as HTMLInputElement
|
|
53
|
+
expect(input.value).toBe('Item one')
|
|
54
|
+
// Selected, so typing replaces rather than appends — what a rename is.
|
|
55
|
+
expect([input.selectionStart, input.selectionEnd]).toEqual([0, 'Item one'.length])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('Enter commits the trimmed value and closes', async () => {
|
|
59
|
+
const user = userEvent.setup()
|
|
60
|
+
const onCommit = vi.fn(() => true)
|
|
61
|
+
render(<EditableText value="Item one" placeholder="Untitled" label="Title" onCommit={onCommit} />)
|
|
62
|
+
await user.click(read())
|
|
63
|
+
await user.keyboard('{Control>}a{/Control} Item two {Enter}')
|
|
64
|
+
expect(onCommit).toHaveBeenCalledWith('Item two')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('Escape restores the value and closes without committing', async () => {
|
|
68
|
+
const user = userEvent.setup()
|
|
69
|
+
const onCommit = vi.fn(() => true)
|
|
70
|
+
render(<EditableText value="Item one" placeholder="Untitled" label="Title" onCommit={onCommit} />)
|
|
71
|
+
await user.click(read())
|
|
72
|
+
await user.keyboard('{Control>}a{/Control}Item two{Escape}')
|
|
73
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
74
|
+
expect(read().textContent).toBe('Item one')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('blur commits, because leaving a field is not cancelling', async () => {
|
|
78
|
+
const user = userEvent.setup()
|
|
79
|
+
const onCommit = vi.fn(() => true)
|
|
80
|
+
render(
|
|
81
|
+
<>
|
|
82
|
+
<EditableText value="Item one" placeholder="Untitled" label="Title" onCommit={onCommit} />
|
|
83
|
+
<button type="button">Elsewhere</button>
|
|
84
|
+
</>,
|
|
85
|
+
)
|
|
86
|
+
await user.click(read())
|
|
87
|
+
await user.keyboard('{Control>}a{/Control}Item two')
|
|
88
|
+
await user.click(screen.getByRole('button', { name: 'Elsewhere' }))
|
|
89
|
+
expect(onCommit).toHaveBeenCalledWith('Item two')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
/** Nothing changed, so there is nothing to save — and a caller that writes
|
|
93
|
+
* on every commit would otherwise write on every glance. */
|
|
94
|
+
it('an unchanged value is not committed', async () => {
|
|
95
|
+
const user = userEvent.setup()
|
|
96
|
+
const onCommit = vi.fn(() => true)
|
|
97
|
+
render(<EditableText value="Item one" placeholder="Untitled" label="Title" onCommit={onCommit} />)
|
|
98
|
+
await user.click(read())
|
|
99
|
+
await user.keyboard('{Enter}')
|
|
100
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
101
|
+
expect(read()).toBeTruthy()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
/** The caller has already said why, in its own banner. Closing the field
|
|
105
|
+
* here would throw the edit away on top of that. */
|
|
106
|
+
it('a refused commit keeps the field open with the text still in it', async () => {
|
|
107
|
+
const user = userEvent.setup()
|
|
108
|
+
render(<EditableText value="Item one" placeholder="Untitled" label="Title" onCommit={() => false} />)
|
|
109
|
+
await user.click(read())
|
|
110
|
+
await user.keyboard('{Control>}a{/Control}Item two{Enter}')
|
|
111
|
+
expect((field() as HTMLInputElement).value).toBe('Item two')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('a commit that throws does the same', async () => {
|
|
115
|
+
const user = userEvent.setup()
|
|
116
|
+
render(
|
|
117
|
+
<EditableText
|
|
118
|
+
value="Item one"
|
|
119
|
+
placeholder="Untitled"
|
|
120
|
+
label="Title"
|
|
121
|
+
onCommit={() => {
|
|
122
|
+
throw new Error('the caller has shown why')
|
|
123
|
+
}}
|
|
124
|
+
/>,
|
|
125
|
+
)
|
|
126
|
+
await user.click(read())
|
|
127
|
+
await user.keyboard('{Control>}a{/Control}Item two{Enter}')
|
|
128
|
+
expect((field() as HTMLInputElement).value).toBe('Item two')
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('EditableText: multiline', () => {
|
|
133
|
+
it('edits in a textarea, and Shift+Enter is a new line rather than a commit', async () => {
|
|
134
|
+
const user = userEvent.setup()
|
|
135
|
+
const onCommit = vi.fn(() => true)
|
|
136
|
+
render(<EditableText multiline value="Line one" placeholder="Untitled" label="Title" onCommit={onCommit} />)
|
|
137
|
+
await user.click(read())
|
|
138
|
+
const area = screen.getByRole('textbox', { name: 'Title' }) as HTMLTextAreaElement
|
|
139
|
+
expect(area.tagName).toBe('TEXTAREA')
|
|
140
|
+
await user.keyboard('{Shift>}{Enter}{/Shift}Line two')
|
|
141
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
142
|
+
// The value opens SELECTED, here as everywhere, so the first keystroke
|
|
143
|
+
// replaces it — the newline included. Worth knowing for a multiline field:
|
|
144
|
+
// one key on an opened description replaces the whole body.
|
|
145
|
+
expect(area.value).toBe('\nLine two')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('and plain Enter still commits', async () => {
|
|
149
|
+
const user = userEvent.setup()
|
|
150
|
+
const onCommit = vi.fn(() => true)
|
|
151
|
+
render(<EditableText multiline value="Line one" placeholder="Untitled" label="Title" onCommit={onCommit} />)
|
|
152
|
+
await user.click(read())
|
|
153
|
+
await user.keyboard('{Control>}a{/Control}Line two{Enter}')
|
|
154
|
+
expect(onCommit).toHaveBeenCalledWith('Line two')
|
|
155
|
+
})
|
|
156
|
+
})
|
package/src/EditableText.tsx
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from 'react'
|
|
2
|
+
import { Field as BaseField } from '@base-ui/react/field'
|
|
3
|
+
import { Input } from '@base-ui/react/input'
|
|
2
4
|
import { cn } from './cn'
|
|
3
5
|
|
|
4
6
|
/**
|
|
@@ -6,6 +8,12 @@ import { cn } from './cn'
|
|
|
6
8
|
* own comment always called it a package candidate: nothing here knows what
|
|
7
9
|
* is being edited.
|
|
8
10
|
*
|
|
11
|
+
* The editing state is Base UI's `Input` — or `Field.Control` as a
|
|
12
|
+
* `<textarea>` when multiline — since stage 3 of the migration (2026-09-07).
|
|
13
|
+
* The read state, the draft, and every rule about committing stay here:
|
|
14
|
+
* Base UI has no opinion about what an edit means, and this component is
|
|
15
|
+
* nothing but that opinion.
|
|
16
|
+
*
|
|
9
17
|
* Reads as text until clicked; then it is a field. Enter commits (Shift+Enter
|
|
10
18
|
* is a new line when multiline), Escape cancels, blur commits. A commit that
|
|
11
19
|
* fails keeps the field open with the text in it, so an edit is never
|
|
@@ -114,8 +122,13 @@ export function EditableText({ value, display, displayNode, placeholder, onCommi
|
|
|
114
122
|
|
|
115
123
|
if (readOnly) {
|
|
116
124
|
return (
|
|
125
|
+
/*
|
|
126
|
+
No `aria-label` here. It sat on a bare `<div>`, which has no role, and
|
|
127
|
+
ARIA does not let an author name a generic element — so the name was
|
|
128
|
+
written and never read. Read-only, this is a line of text: whatever
|
|
129
|
+
names the region around it (a `Field`, a `Property`) names this too.
|
|
130
|
+
*/
|
|
117
131
|
<div
|
|
118
|
-
aria-label={label}
|
|
119
132
|
className={cn('w-full px-2 py-1', multiline && !displayNode && 'whitespace-pre-wrap', (display ?? value) ? 'text-text-primary' : 'text-text-muted', className)}
|
|
120
133
|
>
|
|
121
134
|
{(display ?? value) ? (displayNode ?? (display ?? value)) : placeholder}
|
|
@@ -125,11 +138,11 @@ export function EditableText({ value, display, displayNode, placeholder, onCommi
|
|
|
125
138
|
|
|
126
139
|
if (editing) {
|
|
127
140
|
return multiline ? (
|
|
128
|
-
<
|
|
141
|
+
<BaseField.Control
|
|
129
142
|
ref={fieldRef as RefObject<HTMLTextAreaElement>}
|
|
143
|
+
render={<textarea rows={4} />}
|
|
130
144
|
aria-label={label}
|
|
131
145
|
value={draft}
|
|
132
|
-
rows={4}
|
|
133
146
|
disabled={busy}
|
|
134
147
|
onChange={(event) => setDraft(event.target.value)}
|
|
135
148
|
onKeyDown={onKeyDown}
|
|
@@ -137,7 +150,7 @@ export function EditableText({ value, display, displayNode, placeholder, onCommi
|
|
|
137
150
|
className={fieldClass}
|
|
138
151
|
/>
|
|
139
152
|
) : (
|
|
140
|
-
<
|
|
153
|
+
<Input
|
|
141
154
|
ref={fieldRef as RefObject<HTMLInputElement>}
|
|
142
155
|
aria-label={label}
|
|
143
156
|
value={draft}
|
|
@@ -154,7 +167,12 @@ export function EditableText({ value, display, displayNode, placeholder, onCommi
|
|
|
154
167
|
<button
|
|
155
168
|
type="button"
|
|
156
169
|
onClick={open}
|
|
157
|
-
|
|
170
|
+
/*
|
|
171
|
+
No `title`. It was the one native browser tooltip left in the package —
|
|
172
|
+
its own timing, its own look, no theme — saying "Click to edit" beside
|
|
173
|
+
an `aria-label` that already says it. The hover border is the
|
|
174
|
+
affordance, and anything that needs a designed hint uses `WithTooltip`.
|
|
175
|
+
*/
|
|
158
176
|
aria-label={`Edit ${label.toLowerCase()}`}
|
|
159
177
|
className={cn(
|
|
160
178
|
'w-full rounded-md border border-transparent px-2 py-1 text-left transition-colors hover:border-border-default',
|
package/src/Field.mdx
CHANGED
|
@@ -26,13 +26,33 @@ token; `required` marks it with the error-coloured asterisk.
|
|
|
26
26
|
```tsx
|
|
27
27
|
import { Field, TextInput } from '@estiva-app/ui'
|
|
28
28
|
|
|
29
|
-
<Field label="Name" required>
|
|
29
|
+
<Field label="Name" required helper="One line, and it can be changed later.">
|
|
30
30
|
<TextInput value={name} onChange={(e) => setName(e.target.value)} />
|
|
31
31
|
</Field>
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
The label
|
|
35
|
-
|
|
34
|
+
- **The label names the control. Add nothing.** No `aria-label`, no
|
|
35
|
+
`id`/`htmlFor` pair — this page asked for both until stage 3 and the
|
|
36
|
+
advice was already stale. Base UI's `Field` associates the pair, for its
|
|
37
|
+
own `Input` and `Checkbox`, for `TextInput`, `Textarea` and
|
|
38
|
+
`SearchInput`, and for anything else rendered through `Field.Control`. A
|
|
39
|
+
control you write yourself is the one case that still has to opt in, by
|
|
40
|
+
being one of those.
|
|
41
|
+
- An `id` you set on the control is kept, and the label follows it. That
|
|
42
|
+
reversed at stage 3: the Field used to win and override it.
|
|
43
|
+
- `helper` is the line under the control — the format, or what happens if
|
|
44
|
+
it is left empty. `error` **replaces** it and marks the control invalid,
|
|
45
|
+
so you no longer pass `aria-invalid` yourself. Both are announced;
|
|
46
|
+
Ship and Peek wrote this line by hand six times between them, and none of
|
|
47
|
+
those was.
|
|
48
|
+
- With neither, the control stays a direct child of the field, so nothing
|
|
49
|
+
that predates the two props moved a pixel.
|
|
50
|
+
- **`required` marks the control as well as the label.** The asterisk is a
|
|
51
|
+
picture of the requirement; `aria-required` on the control is the word for
|
|
52
|
+
it, and the control had neither until 2026-09-08 — measured. Base UI's
|
|
53
|
+
`Field` has no `required` of its own, so the Field puts it there. A control
|
|
54
|
+
that says so already is left alone, and a `children` of several elements
|
|
55
|
+
keeps the asterisk and owes its own.
|
|
36
56
|
|
|
37
57
|
## Props
|
|
38
58
|
|
package/src/Field.stories.tsx
CHANGED
|
@@ -20,3 +20,38 @@ export const Required: Story = { args: { label: 'Title', required: true, childre
|
|
|
20
20
|
export const WithTextarea: Story = {
|
|
21
21
|
args: { label: 'Resolution message (optional)', children: <Textarea placeholder="Summarise the outcome…" className="h-[109px]" /> },
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
/** A hint under the control: what the format is, or what happens if it is left empty. */
|
|
25
|
+
export const WithHelper: Story = {
|
|
26
|
+
// axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
|
|
27
|
+
// the helper is muted caption text, 3.93:1 on --bg-base in signal (AA 4.5:1).
|
|
28
|
+
parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
|
|
29
|
+
args: {
|
|
30
|
+
label: 'Label',
|
|
31
|
+
helper: 'Leave this empty and one is made for you.',
|
|
32
|
+
children: <TextInput placeholder="Placeholder" />,
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The error takes the helper's place rather than joining it, and marks the control invalid. */
|
|
37
|
+
export const WithError: Story = {
|
|
38
|
+
args: {
|
|
39
|
+
label: 'Label',
|
|
40
|
+
helper: 'Leave this empty and one is made for you.',
|
|
41
|
+
error: 'That is not a valid value.',
|
|
42
|
+
children: <TextInput defaultValue="Not a valid value" />,
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Required, with a helper — the three parts of a field at once. */
|
|
47
|
+
export const RequiredWithHelper: Story = {
|
|
48
|
+
// axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
|
|
49
|
+
// the helper is muted caption text, 3.93:1 on --bg-base in signal (AA 4.5:1).
|
|
50
|
+
parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
|
|
51
|
+
args: {
|
|
52
|
+
label: 'Label',
|
|
53
|
+
required: true,
|
|
54
|
+
helper: 'One line, and it can be changed later.',
|
|
55
|
+
children: <TextInput placeholder="Placeholder" />,
|
|
56
|
+
},
|
|
57
|
+
}
|