@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.
Files changed (151) 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/tailwind-preset.js +7 -0
  150. package/src/Menu.fit.test.ts +0 -90
  151. package/src/Select.fit.test.ts +0 -101
@@ -1,5 +1,6 @@
1
- import { useEffect, type ReactNode } from 'react'
2
- import { createPortal } from 'react-dom'
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. Plus what Ship added: Escape closes it, and the card
12
- * says what it is to assistive tech.
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
- useEffect(() => {
33
- const onKey = (event: KeyboardEvent) => {
34
- if (event.key === 'Escape') onClose()
35
- }
36
- document.addEventListener('keydown', onKey)
37
- return () => document.removeEventListener('keydown', onKey)
38
- }, [onClose])
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
- return createPortal(
41
- <>
42
- {/* Backdrop */}
43
- <div className="fixed inset-0 z-40 bg-scrim" onClick={onClose} />
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
- {/* Dialog */}
46
- <div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
47
- <div
48
- role="dialog"
49
- aria-modal="true"
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
- {/* Body */}
63
- <div className={cn('pl-5 pr-4 py-4', footer != null && 'border-b border-border-subtle', bodyClassName)}>{children}</div>
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
- {/* Footer */}
66
- {footer != null && <div className="h-12 flex items-center justify-end gap-2 pl-5 pr-4 shrink-0">{footer}</div>}
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
- </div>
69
- </>,
70
- document.body,
138
+ </Parts.Portal>
139
+ </Parts.Root>
71
140
  )
72
141
  }
@@ -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
+ })
@@ -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
- <textarea
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
- <input
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
- title="Click to edit"
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 is visual, not wired: also give the control its accessible name
35
- (`aria-label`, or an `id`/`htmlFor` pair of your own) so the two agree.
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
 
@@ -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
+ }