@estiva-app/ui 0.12.1 → 0.12.2

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 (48) hide show
  1. package/dist/CollapsibleSection.d.ts +3 -1
  2. package/dist/CollapsibleSection.d.ts.map +1 -1
  3. package/dist/DialogShell.d.ts +14 -2
  4. package/dist/DialogShell.d.ts.map +1 -1
  5. package/dist/Field.d.ts +36 -0
  6. package/dist/Field.d.ts.map +1 -1
  7. package/dist/Popover.d.ts +11 -2
  8. package/dist/Popover.d.ts.map +1 -1
  9. package/dist/ScrollArea.d.ts +14 -2
  10. package/dist/ScrollArea.d.ts.map +1 -1
  11. package/dist/SectionHeader.d.ts +10 -1
  12. package/dist/SectionHeader.d.ts.map +1 -1
  13. package/dist/Toast.d.ts +17 -7
  14. package/dist/Toast.d.ts.map +1 -1
  15. package/dist/cn.d.ts.map +1 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +51 -14
  19. package/dist/index.js.map +2 -2
  20. package/package.json +1 -1
  21. package/src/CollapsibleSection.tsx +4 -2
  22. package/src/DialogShell.mdx +9 -3
  23. package/src/DialogShell.stories.tsx +29 -0
  24. package/src/DialogShell.test.tsx +35 -0
  25. package/src/DialogShell.tsx +31 -4
  26. package/src/Field.mdx +3 -2
  27. package/src/Field.tsx +57 -2
  28. package/src/FieldLine.mdx +57 -0
  29. package/src/FieldLine.stories.tsx +80 -0
  30. package/src/FieldLine.test.tsx +56 -0
  31. package/src/Popover.mdx +3 -1
  32. package/src/Popover.stories.tsx +15 -0
  33. package/src/Popover.test.tsx +20 -0
  34. package/src/Popover.tsx +12 -3
  35. package/src/ScrollArea.mdx +5 -0
  36. package/src/ScrollArea.test.tsx +27 -1
  37. package/src/ScrollArea.tsx +16 -2
  38. package/src/SectionHeader.mdx +4 -0
  39. package/src/SectionHeader.stories.tsx +10 -1
  40. package/src/SectionHeader.test.tsx +25 -0
  41. package/src/SectionHeader.tsx +14 -1
  42. package/src/Toast.mdx +8 -2
  43. package/src/Toast.stories.tsx +13 -3
  44. package/src/Toast.tsx +44 -11
  45. package/src/cn.ts +1 -1
  46. package/src/index.ts +1 -1
  47. package/stories/Choosing.mdx +2 -0
  48. package/tailwind-preset.js +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
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",
@@ -31,6 +31,8 @@ export interface CollapsibleSectionProps {
31
31
  onOpenChange?: (open: boolean) => void
32
32
  /** Remember open or closed in this browser, under this key. The app prefixes it. */
33
33
  storageKey?: string
34
+ /** Beside the title and always visible, before the actions — a count. `SectionHeader`'s. */
35
+ trailing?: ReactNode
34
36
  /** Beside the title, revealed on hover or focus — `SectionHeader`'s. */
35
37
  actions?: SectionAction[]
36
38
  showActions?: 'hover' | 'always'
@@ -64,7 +66,7 @@ function writeStored(key: string | undefined, open: boolean) {
64
66
  }
65
67
  }
66
68
 
67
- export function CollapsibleSection({ title, defaultOpen = true, open: openProp, onOpenChange, storageKey, actions, showActions, children, className, contentClassName }: CollapsibleSectionProps) {
69
+ export function CollapsibleSection({ title, defaultOpen = true, open: openProp, onOpenChange, storageKey, trailing, actions, showActions, children, className, contentClassName }: CollapsibleSectionProps) {
68
70
  const [openState, setOpenState] = useState(() => readStored(storageKey) ?? defaultOpen)
69
71
  const open = openProp ?? openState
70
72
  const setOpen = (next: boolean) => {
@@ -74,7 +76,7 @@ export function CollapsibleSection({ title, defaultOpen = true, open: openProp,
74
76
  }
75
77
  return (
76
78
  <Collapsible.Root open={open} onOpenChange={setOpen} className={cn('flex flex-col', className)}>
77
- <SectionHeader title={title} chevron isExpanded={open} actions={actions} showActions={showActions} className="shrink-0" render={<Collapsible.Trigger />} />
79
+ <SectionHeader title={title} chevron isExpanded={open} trailing={trailing} actions={actions} showActions={showActions} className="shrink-0" render={<Collapsible.Trigger />} />
78
80
  {/* The slide: Base UI measures the panel and writes its height to a
79
81
  variable — `auto` again once the slide ends, so rows that arrive
80
82
  later are not clipped — and the panel is 0 high on its opening frame
@@ -51,9 +51,15 @@ import { DialogShell, Button } from '@estiva-app/ui'
51
51
  ```
52
52
 
53
53
  - Render it only while open — mounting is opening.
54
- - `bodyClassName` shapes the body (`flex flex-col gap-6`, or a max height
55
- with `overflow-y-auto` for long lists). Rows in a height-capped flex
56
- body need `shrink-0`, or overflow crushes them.
54
+ - `bodyClassName` shapes the body its layout and any padding override
55
+ (`flex flex-col gap-6`, `p-0 py-2`).
56
+ - **`bodyMaxHeight` makes the body scroll in the package's bar**: give it
57
+ the cap (`max-h-[400px]`, `max-h-[70vh]`) and the body becomes a
58
+ `ScrollArea`, so a long roster keeps its text column when it overflows.
59
+ Without it the body grows to its content, as it always did. A
60
+ `bodyClassName` carrying `overflow-y-auto` and no cap never scrolled
61
+ anything — the box grew — and is what this replaces. Rows in a capped
62
+ flex body still need `shrink-0`, or overflow crushes them.
57
63
  - The card names itself to assistive tech (`role="dialog"`, labelled by
58
64
  its header — or by `title` when `headerContent` replaces that text).
59
65
  - **Focus is trapped and returned.** Tab cannot leave the card, and closing
@@ -145,3 +145,32 @@ export const OpenAndClose: Story = {
145
145
  )
146
146
  },
147
147
  }
148
+
149
+ /**
150
+ * **A body that scrolls in the package's bar.** `bodyMaxHeight` is the cap;
151
+ * without it the body grows to its content, as every dialog written before
152
+ * 0.12.2 does. The bar is drawn over the padding rather than beside it, so the
153
+ * text column keeps its width when the content overflows.
154
+ */
155
+ export const ScrollingBody: Story = {
156
+ args: {
157
+ title: 'A long list',
158
+ bodyMaxHeight: 'max-h-[240px]',
159
+ bodyClassName: 'flex flex-col gap-2',
160
+ footer: (
161
+ <>
162
+ <Button variant="muted">Cancel</Button>
163
+ <Button variant="primary">Confirm</Button>
164
+ </>
165
+ ),
166
+ children: (
167
+ <>
168
+ {Array.from({ length: 16 }, (_, i) => (
169
+ <span key={i} className="text-body-2 text-text-primary">
170
+ Item {i + 1}
171
+ </span>
172
+ ))}
173
+ </>
174
+ ),
175
+ },
176
+ }
@@ -182,3 +182,38 @@ describe('ConfirmDialog', () => {
182
182
  expect(screen.getByRole('alertdialog')).toBeTruthy()
183
183
  })
184
184
  })
185
+
186
+ /**
187
+ * `bodyMaxHeight` makes the body scroll in the package's bar (0.12.2,
188
+ * ADOPTION B19). Peek's five dialogs passed `overflow-y-auto` on the body, so
189
+ * a long roster drew the native bar D40 removed everywhere else — and two of
190
+ * the five passed it with no cap at all, which never scrolled anything.
191
+ */
192
+ describe('DialogShell, a body that scrolls', () => {
193
+ it('leaves the body alone without a cap, and wraps it in a scroll region with one', () => {
194
+ const { container, unmount } = render(
195
+ <DialogShell title="T" onClose={() => {}} bodyClassName="flex flex-col gap-6">
196
+ <p>Inside</p>
197
+ </DialogShell>,
198
+ )
199
+ const plain = screen.getByText('Inside').parentElement!
200
+ expect(plain.className).toContain('flex flex-col gap-6')
201
+ expect(plain.className).toContain('pl-5')
202
+ unmount()
203
+
204
+ render(
205
+ <DialogShell title="T" onClose={() => {}} bodyMaxHeight="max-h-[240px]" bodyClassName="flex flex-col gap-6">
206
+ <p>Inside</p>
207
+ </DialogShell>,
208
+ )
209
+ const content = screen.getByText('Inside').parentElement!
210
+ // The padding and the layout stay on the box the children are in…
211
+ expect(content.className).toContain('flex flex-col gap-6')
212
+ expect(content.className).toContain('pl-5')
213
+ // …and the cap is on the scrolling box above it, never on the region.
214
+ const viewport = content.closest('[class*="max-h-"]')
215
+ expect(viewport).not.toBe(null)
216
+ expect(viewport).not.toBe(content)
217
+ expect(viewport!.className).toContain('overflow')
218
+ })
219
+ })
@@ -4,6 +4,7 @@ import { AlertDialog } from '@base-ui/react/alert-dialog'
4
4
  import { IconX } from '@tabler/icons-react'
5
5
  import { cn } from './cn'
6
6
  import { IconButton } from './IconButton'
7
+ import { ScrollArea } from './ScrollArea'
7
8
 
8
9
  /**
9
10
  * Peek's DialogShell (2026-08-28), verbatim: the portal, the backdrop, the
@@ -41,8 +42,20 @@ export interface DialogShellProps {
41
42
  * (a roster that simply ends). */
42
43
  footer?: ReactNode
43
44
  children: ReactNode
44
- /** Extra classes on the body (e.g. `flex flex-col gap-6`, or a max height with `overflow-y-auto`). */
45
+ /** Extra classes on the body: its layout and any padding override (`flex flex-col gap-6`, `p-0 py-2`). */
45
46
  bodyClassName?: string
47
+ /**
48
+ * How tall the body may get before it scrolls, as the cap class —
49
+ * `max-h-[400px]`, `max-h-[70vh]`. Setting it makes the body a `ScrollArea`,
50
+ * so a long roster or a tall form scrolls in the package's bar rather than
51
+ * the browser's (D40, ADOPTION B19).
52
+ *
53
+ * Without it the body is what it always was and grows to its content, so no
54
+ * dialog written before 0.12.2 moves a pixel. A `bodyClassName` carrying
55
+ * `overflow-y-auto` and no cap never scrolled anything — the box grew — and
56
+ * is the case this replaces.
57
+ */
58
+ bodyMaxHeight?: string
46
59
  width?: number
47
60
  /**
48
61
  * A question that has to be answered rather than clicked away: a press on
@@ -53,7 +66,7 @@ export interface DialogShellProps {
53
66
  alert?: boolean
54
67
  }
55
68
 
56
- export function DialogShell({ title, onClose, headerContent, footer, children, bodyClassName, width = 502, alert = false }: DialogShellProps) {
69
+ export function DialogShell({ title, onClose, headerContent, footer, children, bodyClassName, bodyMaxHeight, width = 502, alert = false }: DialogShellProps) {
57
70
  // The two families are the same parts with different dismiss rules, so the
58
71
  // chrome below is written once. AlertDialog re-exports Dialog's Backdrop,
59
72
  // Popup, Portal and Title types, which is why this substitutes cleanly.
@@ -128,8 +141,22 @@ export function DialogShell({ title, onClose, headerContent, footer, children, b
128
141
  />
129
142
  </div>
130
143
 
131
- {/* Body */}
132
- <div className={cn('pl-5 pr-4 py-4', footer != null && 'border-b border-border-subtle', bodyClassName)}>{children}</div>
144
+ {/* Body. With a cap it scrolls in the package's bar: the cap goes
145
+ on the viewport (ScrollArea's rule on the region the viewport
146
+ grows to its content and nothing scrolls), and the padding and
147
+ the caller's layout go on the content, so the bar is drawn over
148
+ the padding rather than beside it. */}
149
+ {bodyMaxHeight ? (
150
+ <ScrollArea
151
+ className={cn(footer != null && 'border-b border-border-subtle')}
152
+ viewportClassName={bodyMaxHeight}
153
+ contentClassName={cn('pl-5 pr-4 py-4', bodyClassName)}
154
+ >
155
+ {children}
156
+ </ScrollArea>
157
+ ) : (
158
+ <div className={cn('pl-5 pr-4 py-4', footer != null && 'border-b border-border-subtle', bodyClassName)}>{children}</div>
159
+ )}
133
160
 
134
161
  {/* Footer */}
135
162
  {footer != null && <div className="h-12 flex items-center justify-end gap-2 pl-5 pr-4 shrink-0">{footer}</div>}
package/src/Field.mdx CHANGED
@@ -18,8 +18,9 @@ token; `required` marks it with the error-coloured asterisk.
18
18
  ## When not
19
19
 
20
20
  - Displaying a label–value pair → **Property**; Field is for editing.
21
- - It carries no helper or error text of its ownvalidation display is
22
- the caller's, under the control.
21
+ - The line belongs to a **group** of controls rather than to one a value
22
+ with Save beside it, a row of action controls → **Field line**, the same
23
+ small line on its own.
23
24
 
24
25
  ## How
25
26
 
package/src/Field.tsx CHANGED
@@ -57,6 +57,16 @@ export interface FieldProps {
57
57
  children: ReactNode
58
58
  }
59
59
 
60
+ /**
61
+ * The two looks the line under a control wears, in one place so `Field` and
62
+ * `FieldLine` cannot drift apart. `warning` is `FieldLine`'s alone — see there.
63
+ */
64
+ const LINE_STYLES = {
65
+ helper: 'text-caption text-text-muted',
66
+ warning: 'text-caption text-warning-default',
67
+ error: 'text-caption text-error-default',
68
+ } as const
69
+
60
70
  export function Field({ label, required = false, helper, error, children }: FieldProps) {
61
71
 
62
72
  /*
@@ -101,13 +111,58 @@ export function Field({ label, required = false, helper, error, children }: Fiel
101
111
  <div className="flex flex-col gap-1.5">
102
112
  {control}
103
113
  {error != null ? (
104
- <BaseField.Error match className="text-caption text-error-default">
114
+ <BaseField.Error match className={LINE_STYLES.error}>
105
115
  {error}
106
116
  </BaseField.Error>
107
117
  ) : helper != null ? (
108
- <BaseField.Description className="text-caption text-text-muted">{helper}</BaseField.Description>
118
+ <BaseField.Description className={LINE_STYLES.helper}>{helper}</BaseField.Description>
109
119
  ) : null}
110
120
  </div>
111
121
  </BaseField.Root>
112
122
  )
113
123
  }
124
+
125
+ /**
126
+ * The line on its own — the same small line `Field` draws under a control,
127
+ * for the places where there is no single control to draw it under.
128
+ *
129
+ * **Why it is not a `Field`.** Three surfaces in Peek put this line under a
130
+ * *group*: the rename row (a field and two buttons), a Folder's action
131
+ * controls, another app's action controls. Each already has a section heading
132
+ * above it, and `Field` comes with a label of its own — so wrapping the group
133
+ * in one would either say the heading twice or restyle it. Found at step 4 of
134
+ * Peek's adoption (`ADOPTION.md` B24); until this existed, all three spelled
135
+ * `text-xs text-error-default` by hand.
136
+ *
137
+ * **It announces itself**, which is the half the hand-written spans never had.
138
+ * These lines appear *after* something was done — a rename that the relay
139
+ * refused, an action that failed — so a reader who cannot see them is told:
140
+ * `role="alert"` for an error, `role="status"` for the rest. That is `Banner`'s
141
+ * rule (2026-09-02), kept here so the two agree.
142
+ *
143
+ * **`warning` is the tone `Field` has not got**, and deliberately: `Field`'s
144
+ * `error` also marks its control invalid, and a warning is not invalid — the
145
+ * rename went through, and something about it needs saying. A group has no
146
+ * single control to mark, so the distinction is free here and would not be
147
+ * there.
148
+ *
149
+ * When the line belongs to one control, it is `Field`'s `helper` or `error`:
150
+ * those are wired to the control with `aria-describedby` and `aria-invalid`,
151
+ * which this cannot be.
152
+ */
153
+ export type FieldLineTone = 'helper' | 'warning' | 'error'
154
+
155
+ export interface FieldLineProps {
156
+ /** `helper` (muted) is a hint; `warning` (amber) and `error` (red) are outcomes. Default `helper`. */
157
+ tone?: FieldLineTone
158
+ children: ReactNode
159
+ className?: string
160
+ }
161
+
162
+ export function FieldLine({ tone = 'helper', children, className }: FieldLineProps) {
163
+ return (
164
+ <p role={tone === 'error' ? 'alert' : 'status'} className={cn(LINE_STYLES[tone], className)}>
165
+ {children}
166
+ </p>
167
+ )
168
+ }
@@ -0,0 +1,57 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as FieldLineStories from './FieldLine.stories'
3
+
4
+ <Meta of={FieldLineStories} />
5
+
6
+ # Field line
7
+
8
+ The small line a **Field** draws under a control — on its own, for the
9
+ places where there is no single control to draw it under.
10
+
11
+ <Canvas of={FieldLineStories.UnderAGroup} />
12
+
13
+ ## When
14
+
15
+ - A line under a **group** of controls: a value with Save and Cancel beside
16
+ it, a row of action controls. The group already has a heading above it.
17
+ - `helper` is a hint. `warning` and `error` are outcomes: what happened when
18
+ the person did something.
19
+
20
+ ## When not
21
+
22
+ - The line belongs to **one control** → **Field**'s `helper` and `error`.
23
+ Those are wired to the control with `aria-describedby` and `aria-invalid`,
24
+ which this cannot be, and Field draws the label too.
25
+ - A strip under the app's header → **Banner**.
26
+ - Something that floats over the page and goes away → **Toast**.
27
+
28
+ ## How
29
+
30
+ ```tsx
31
+ import { FieldLine } from '@estiva-app/ui'
32
+
33
+ <div className="flex flex-col gap-1.5">
34
+ <div className="flex items-center gap-2">
35
+ <TextInput value={draft} onChange={…} />
36
+ <Button variant="primary" size="small">Save</Button>
37
+ </div>
38
+ {outcome && <FieldLine tone={outcome.kind}>{outcome.message}</FieldLine>}
39
+ </div>
40
+ ```
41
+
42
+ - **The 6px above it is the caller's.** Field puts the control and its line
43
+ in a `flex flex-col gap-1.5`; a group does the same around its own row, so
44
+ the line sits where a field's line sits.
45
+ - **It announces itself.** An `error` is `role="alert"`, the other two are
46
+ `role="status"` — Banner's rule, so the two agree. These lines appear
47
+ *after* something was done, and a reader who cannot see them is told.
48
+ - **`warning` is the tone Field has not got**, and deliberately: Field's
49
+ `error` also marks its control invalid, and a warning is not invalid — it
50
+ went through, and something about it needs saying. A group has no single
51
+ control to mark, so the distinction is free here.
52
+ - The three tones are the same two classes Field draws, from one map in
53
+ `Field.tsx`, so they cannot drift.
54
+
55
+ ## Props
56
+
57
+ <Controls of={FieldLineStories.Helper} />
@@ -0,0 +1,80 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { FieldLine } from './Field'
3
+ import { Select } from './Select'
4
+ import { Button } from './Button'
5
+ import { SectionLabel } from './SectionLabel'
6
+ import { TextInput } from './TextInput'
7
+
8
+ /** The small line a Field draws under a control, on its own — for a group of controls. */
9
+ const meta = {
10
+ title: 'Inputs/Field line',
11
+ component: FieldLine,
12
+ parameters: { layout: 'padded' },
13
+ args: { tone: 'helper', children: 'Leave this empty and one is made for you.' },
14
+ argTypes: { tone: { control: 'inline-radio', options: ['helper', 'warning', 'error'] } },
15
+ decorators: [(Story) => <div className="w-[360px]"><Story /></div>],
16
+ } satisfies Meta<typeof FieldLine>
17
+
18
+ export default meta
19
+ type Story = StoryObj<typeof meta>
20
+
21
+ /** A hint: what the format is, or what happens if it is left empty. */
22
+ export const Helper: Story = {
23
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
24
+ // the helper is muted caption text, 3.93:1 on --bg-base in signal (AA 4.5:1).
25
+ parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
26
+ }
27
+
28
+ /** It went through, and something about it needs saying. */
29
+ export const Warning: Story = {
30
+ args: { tone: 'warning', children: 'Saved, with one thing left undone.' },
31
+ }
32
+
33
+ /** It did not go through. This one announces itself. */
34
+ export const Error: Story = {
35
+ args: { tone: 'error', children: 'That could not be saved.' },
36
+ }
37
+
38
+ /** Under a row of controls, which is what it is for: one line for the group. */
39
+ export const UnderAGroup: Story = {
40
+ parameters: { controls: { disable: true } },
41
+ render: () => (
42
+ <div className="flex flex-col gap-3">
43
+ <SectionLabel>Label</SectionLabel>
44
+ <div className="flex flex-col gap-1.5">
45
+ <div className="flex items-center gap-2">
46
+ {/* A section heading is not a label: the control still owes its own
47
+ name, which is half of why this group cannot simply be a Field. */}
48
+ <TextInput defaultValue="Value" aria-label="Label" className="flex-1" />
49
+ <Button variant="primary" size="small">Save</Button>
50
+ <Button variant="outlined" size="small">Cancel</Button>
51
+ </div>
52
+ <FieldLine tone="warning">Saved, with one thing left undone.</FieldLine>
53
+ </div>
54
+ </div>
55
+ ),
56
+ }
57
+
58
+ /** The three tones together. */
59
+ export const AllTones: Story = {
60
+ parameters: {
61
+ controls: { disable: true },
62
+ a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } },
63
+ },
64
+ render: () => (
65
+ <div className="flex flex-col gap-3">
66
+ <div className="flex flex-col gap-1.5">
67
+ <Select value="one" onChange={() => {}} options={[{ value: 'one', label: 'Item one' }]} ariaLabel="Item" />
68
+ <FieldLine>Leave this empty and one is made for you.</FieldLine>
69
+ </div>
70
+ <div className="flex flex-col gap-1.5">
71
+ <Select value="one" onChange={() => {}} options={[{ value: 'one', label: 'Item one' }]} ariaLabel="Item" />
72
+ <FieldLine tone="warning">Saved, with one thing left undone.</FieldLine>
73
+ </div>
74
+ <div className="flex flex-col gap-1.5">
75
+ <Select value="one" onChange={() => {}} options={[{ value: 'one', label: 'Item one' }]} ariaLabel="Item" />
76
+ <FieldLine tone="error">That could not be saved.</FieldLine>
77
+ </div>
78
+ </div>
79
+ ),
80
+ }
@@ -0,0 +1,56 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the Field line page claims (0.12.2, ADOPTION B24).
4
+ *
5
+ * The line exists because three surfaces in Peek put one under a *group* of
6
+ * controls and had to spell `text-xs text-error-default` by hand — and none of
7
+ * those hand-written spans was announced. Both halves are pinned here: the
8
+ * role, and that the tones are the same classes `Field` draws.
9
+ */
10
+ import { afterEach, describe, expect, it } from 'vitest'
11
+ import { cleanup, render, screen } from '@testing-library/react'
12
+ import { Field, FieldLine } from './Field'
13
+ import { TextInput } from './TextInput'
14
+
15
+ afterEach(cleanup)
16
+
17
+ describe('FieldLine', () => {
18
+ it('announces an error, and is polite otherwise', () => {
19
+ const { unmount } = render(<FieldLine tone="error">That could not be saved.</FieldLine>)
20
+ expect(screen.getByRole('alert').textContent).toBe('That could not be saved.')
21
+ unmount()
22
+
23
+ render(
24
+ <>
25
+ <FieldLine tone="warning">Saved, not announced.</FieldLine>
26
+ <FieldLine>A hint.</FieldLine>
27
+ </>,
28
+ )
29
+ const polite = screen.getAllByRole('status')
30
+ expect(polite.map((el) => el.textContent)).toEqual(['Saved, not announced.', 'A hint.'])
31
+ expect(screen.queryByRole('alert')).toBe(null)
32
+ })
33
+
34
+ it('wears the same classes as the line a Field draws', () => {
35
+ // The point of one map: a caller who moves a line out of a Field, or into
36
+ // one, must not see the line change.
37
+ render(
38
+ <>
39
+ <Field label="Label" helper="A hint." error="That could not be saved.">
40
+ <TextInput />
41
+ </Field>
42
+ <FieldLine tone="error">That could not be saved.</FieldLine>
43
+ </>,
44
+ )
45
+ const inField = screen.getAllByText('That could not be saved.')[0]
46
+ const alone = screen.getByRole('alert')
47
+ expect(alone.className).toBe(inField.className)
48
+ })
49
+
50
+ it('takes extra classes without losing its tone', () => {
51
+ render(<FieldLine tone="error" className="mt-1">Nope.</FieldLine>)
52
+ const line = screen.getByRole('alert')
53
+ expect(line.className).toContain('mt-1')
54
+ expect(line.className).toContain('text-error-default')
55
+ })
56
+ })
package/src/Popover.mdx CHANGED
@@ -52,7 +52,9 @@ import { Popover } from '@estiva-app/ui'
52
52
  - **`actionsRef`** gives `close()`, for the Cancel and Save a form panel ends
53
53
  with. Unlike a menu row, a control inside a panel does not close it by being
54
54
  pressed: it is content, and content may be used more than once.
55
- - **`align="right"`** hangs the panel's right edge from the trigger's.
55
+ - **`align="right"`** hangs the panel's right edge from the trigger's;
56
+ **`align="center"`** puts the panel's middle over the anchor's, wherever
57
+ in the line that is — what a toolbar over a text selection wants.
56
58
  - **`side` is a preference, not a promise.** It says which side to try; Base UI
57
59
  measures the room and flips when there is none — which is the reason the
58
60
  placement is its job rather than arithmetic of ours. **A panel holding a
@@ -220,3 +220,18 @@ export const FlippedForRoom: Story = {
220
220
  </div>
221
221
  ),
222
222
  }
223
+
224
+ /**
225
+ * `align="center"` puts the panel's middle over the anchor's, wherever in the
226
+ * line that is — what a toolbar over a selection wants.
227
+ */
228
+ export const Centred: Story = {
229
+ parameters: { controls: { disable: true }, layout: 'fullscreen' },
230
+ render: () => (
231
+ <div className="flex h-[260px] w-full items-center justify-center">
232
+ <Popover trigger={<Button variant="outlined">Centred over this</Button>} align="center" ariaLabel="A panel" className="w-[120px]">
233
+ <span className="text-body-2 text-text-primary">Middle over middle.</span>
234
+ </Popover>
235
+ </div>
236
+ ),
237
+ }
@@ -186,3 +186,23 @@ describe('Popover', () => {
186
186
  expect(onOpenChange).toHaveBeenCalledWith(false)
187
187
  })
188
188
  })
189
+
190
+ /**
191
+ * `align="center"` (0.12.2, ADOPTION B21). Peek's selection toolbar is centred
192
+ * over the text you selected, and kept its own placement arithmetic while only
193
+ * the two edges were mapped.
194
+ */
195
+ describe('Popover, centred on its anchor', () => {
196
+ it('asks Base UI for the middle', async () => {
197
+ render(
198
+ <Popover trigger={<Button>Open</Button>} align="center" ariaLabel="A panel">
199
+ <span>Inside</span>
200
+ </Popover>,
201
+ )
202
+ await userEvent.click(screen.getByRole('button', { name: 'Open' }))
203
+ const panel = await screen.findByRole('dialog')
204
+ // Base UI writes the resolved placement on the positioner it owns.
205
+ const positioner = panel.closest('[data-align]') ?? panel.parentElement
206
+ expect(positioner?.getAttribute('data-align')).toBe('center')
207
+ })
208
+ })
package/src/Popover.tsx CHANGED
@@ -51,8 +51,17 @@ export interface PopoverProps {
51
51
  * Render it always and toggle `open`; do not mount it only while it is open.
52
52
  */
53
53
  anchor?: HTMLElement | DOMRect | null
54
- /** Which of the panel's edges hangs from the trigger's. Default left. */
55
- align?: 'left' | 'right'
54
+ /**
55
+ * Which of the panel's edges hangs from the trigger's, or `center` to put
56
+ * the panel's middle over the anchor's. Default left.
57
+ *
58
+ * **`center` is what a toolbar over a text selection wants** (Katerina,
59
+ * PEE-19, after Linear): the panel sits over the middle of what you
60
+ * selected, wherever in the line that is. Until 0.12.2 only the two edges
61
+ * were mapped, so Peek's selection toolbar kept the placement arithmetic
62
+ * the move onto this component was meant to delete (ADOPTION B21).
63
+ */
64
+ align?: 'left' | 'center' | 'right'
56
65
  /**
57
66
  * Which side of the trigger, or of the anchor, the panel prefers. Default
58
67
  * `bottom`.
@@ -117,7 +126,7 @@ export function Popover({ trigger, anchor, align = 'left', side = 'bottom', open
117
126
  <BasePopover.Positioner
118
127
  anchor={anchorTarget}
119
128
  side={side}
120
- align={align === 'right' ? 'end' : 'start'}
129
+ align={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
121
130
  sideOffset={GAP}
122
131
  collisionPadding={VIEWPORT_PAD}
123
132
  className="z-50 data-[anchor-hidden]:hidden"
@@ -39,6 +39,11 @@ import { ScrollArea } from '@estiva-app/ui'
39
39
  - `className` sizes and places the region; `viewportClassName` styles the
40
40
  scrolling box inside it — padding, gap, the layout of the content.
41
41
  - The region needs a height (or a `max-h-*`) to have anything to scroll.
42
+ - **`viewportRef` and `onScroll` hand back the box that scrolls**, for a
43
+ region that reads or drives its own scrolling: a conversation that arrives
44
+ at its newest message, keeps its place when older ones load above it, and
45
+ jumps to the bottom when a reply is sent. Nothing else here can do that for
46
+ the caller, and the caller cannot do it without the box.
42
47
  - The native scrollbar is hidden by Base UI; a native `overflow-y-auto` on
43
48
  the same element would draw a second one.
44
49
  - A region keeps the wheel only in an axis it can actually scroll: a list
@@ -5,8 +5,9 @@
5
5
  * it shows, is measured in Chrome (2026-09-08: the viewport keeps its full
6
6
  * width with 40 rows overflowing; the native bar is hidden).
7
7
  */
8
+ import { createRef } from 'react'
8
9
  import { afterEach, describe, expect, it } from 'vitest'
9
- import { cleanup, render, screen } from '@testing-library/react'
10
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
10
11
  import { ScrollArea } from './ScrollArea'
11
12
 
12
13
  afterEach(cleanup)
@@ -63,3 +64,28 @@ describe('ScrollArea and the page behind it', () => {
63
64
  expect(viewport.className).not.toMatch(/(^|\s)overscroll-contain(\s|$)/)
64
65
  })
65
66
  })
67
+
68
+ /**
69
+ * The viewport is the box a caller reads and drives (0.12.2, ADOPTION B20).
70
+ * A conversation arrives at its newest message and jumps to the bottom on a
71
+ * reply; neither is something the region can do for the caller, and neither is
72
+ * possible without the box.
73
+ */
74
+ describe('ScrollArea, the viewport it hands back', () => {
75
+ it('gives the caller the box that scrolls, and reports its scrolling', () => {
76
+ const ref = createRef<HTMLDivElement>()
77
+ let scrolled = 0
78
+ render(
79
+ <ScrollArea className="h-40" viewportRef={ref} onScroll={() => { scrolled += 1 }}>
80
+ <p>Inside</p>
81
+ </ScrollArea>,
82
+ )
83
+ // The box the caller gets is the one the content sits in, not the region.
84
+ expect(ref.current).not.toBe(null)
85
+ expect(ref.current!.contains(screen.getByText('Inside'))).toBe(true)
86
+ expect(ref.current!.className).toContain('overflow')
87
+
88
+ fireEvent.scroll(ref.current!)
89
+ expect(scrolled).toBe(1)
90
+ })
91
+ })