@estiva-app/ui 0.12.0 → 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.
- package/dist/AppShell.d.ts.map +1 -1
- package/dist/CollapsibleSection.d.ts +3 -1
- package/dist/CollapsibleSection.d.ts.map +1 -1
- package/dist/DialogShell.d.ts +14 -2
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/Field.d.ts +36 -0
- package/dist/Field.d.ts.map +1 -1
- package/dist/Popover.d.ts +11 -2
- package/dist/Popover.d.ts.map +1 -1
- package/dist/ScrollArea.d.ts +14 -2
- package/dist/ScrollArea.d.ts.map +1 -1
- package/dist/SectionHeader.d.ts +10 -1
- package/dist/SectionHeader.d.ts.map +1 -1
- package/dist/Toast.d.ts +17 -7
- package/dist/Toast.d.ts.map +1 -1
- package/dist/cn.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +138 -99
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/AppShell.mdx +15 -0
- package/src/AppShell.stories.tsx +15 -0
- package/src/AppShell.test.tsx +39 -0
- package/src/AppShell.tsx +22 -7
- package/src/CollapsibleSection.tsx +4 -2
- package/src/DialogShell.mdx +9 -3
- package/src/DialogShell.stories.tsx +29 -0
- package/src/DialogShell.test.tsx +35 -0
- package/src/DialogShell.tsx +31 -4
- package/src/EmptyState.mdx +5 -4
- package/src/Field.mdx +3 -2
- package/src/Field.tsx +57 -2
- package/src/FieldLine.mdx +57 -0
- package/src/FieldLine.stories.tsx +80 -0
- package/src/FieldLine.test.tsx +56 -0
- package/src/Popover.mdx +3 -1
- package/src/Popover.stories.tsx +15 -0
- package/src/Popover.test.tsx +20 -0
- package/src/Popover.tsx +12 -3
- package/src/ScrollArea.mdx +5 -0
- package/src/ScrollArea.test.tsx +27 -1
- package/src/ScrollArea.tsx +16 -2
- package/src/SectionHeader.mdx +4 -0
- package/src/SectionHeader.stories.tsx +10 -1
- package/src/SectionHeader.test.tsx +25 -0
- package/src/SectionHeader.tsx +14 -1
- package/src/Toast.mdx +8 -2
- package/src/Toast.stories.tsx +13 -3
- package/src/Toast.tsx +44 -11
- package/src/cn.ts +1 -1
- package/src/index.ts +1 -1
- package/stories/Choosing.mdx +2 -0
- package/tailwind-preset.js +4 -0
package/package.json
CHANGED
package/src/AppShell.mdx
CHANGED
|
@@ -39,6 +39,21 @@ Layout only: no data, no routes.
|
|
|
39
39
|
|
|
40
40
|
## How
|
|
41
41
|
|
|
42
|
+
**The page contract, in the solid manner.** The frame owns the page's
|
|
43
|
+
scrollbar: the content column is a ScrollArea, and `main` inside it is a
|
|
44
|
+
flex column. A page is a flex child of `main`, and says one of two things:
|
|
45
|
+
|
|
46
|
+
- `flex-1` — it fills the frame and, when taller, scrolls in the frame's
|
|
47
|
+
bar. A list page. (Its empty state centres on its own.)
|
|
48
|
+
- `flex-1 min-h-0 [contain:size]` — it takes exactly the frame's height
|
|
49
|
+
and scrolls inside itself, in regions of its own. A detail page with a
|
|
50
|
+
content column and a rail.
|
|
51
|
+
|
|
52
|
+
Never `h-full`: the frame's content box is *at least* the viewport's
|
|
53
|
+
height and grows with a tall page — which is what lets the bar know the
|
|
54
|
+
page changed — so a percentage height has nothing to resolve against.
|
|
55
|
+
Measured on all four of Ship's pages (2026-09-09).
|
|
56
|
+
|
|
42
57
|
```tsx
|
|
43
58
|
import { AppShell, Banner, IdentityMenu, Rail, RailItem } from '@estiva-app/ui'
|
|
44
59
|
|
package/src/AppShell.stories.tsx
CHANGED
|
@@ -74,6 +74,21 @@ export const Solid: Story = {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/** The banner belongs to the content area — at its top, never across the navigation. */
|
|
77
|
+
/** A page taller than the frame: it scrolls in the frame's own bar — the region every page passes through — never a native one. */
|
|
78
|
+
export const SolidScrolls: Story = {
|
|
79
|
+
render: (args) => (
|
|
80
|
+
<AppShell {...args} logo="Estiva" search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />} identity={identity} nav={sidebar}>
|
|
81
|
+
<div className="flex flex-col gap-px px-6 py-5">
|
|
82
|
+
{Array.from({ length: 60 }, (_, i) => (
|
|
83
|
+
<p key={i} className="rounded-md px-2 py-1.5 text-body-2 text-text-primary">
|
|
84
|
+
Row {i + 1}
|
|
85
|
+
</p>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
</AppShell>
|
|
89
|
+
),
|
|
90
|
+
}
|
|
91
|
+
|
|
77
92
|
export const SolidWithBanner: Story = {
|
|
78
93
|
render: (args) => (
|
|
79
94
|
<AppShell {...args} logo="Estiva" identity={identity} nav={sidebar} banner={<Banner tone="ok">Public key copied.</Banner>}>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The frame owns the page's scrollbar (2026-09-09): in the solid manner the
|
|
4
|
+
* page lands inside a ScrollArea's viewport — Base UI's `overflow: scroll`
|
|
5
|
+
* box — so no page can scroll in a native bar. Whether the bar takes width,
|
|
6
|
+
* and whether an inner region still scrolls on its own, is measured in
|
|
7
|
+
* Chrome on Ship's four pages.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
10
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
11
|
+
import { AppShell } from './AppShell'
|
|
12
|
+
|
|
13
|
+
afterEach(cleanup)
|
|
14
|
+
|
|
15
|
+
describe('AppShell', () => {
|
|
16
|
+
it('puts the page inside the frame’s scrolling region in the solid manner', () => {
|
|
17
|
+
render(
|
|
18
|
+
<AppShell nav={<nav>Nav</nav>}>
|
|
19
|
+
<p>Page</p>
|
|
20
|
+
</AppShell>,
|
|
21
|
+
)
|
|
22
|
+
const main = screen.getByRole('main')
|
|
23
|
+
const viewport = main.closest('[style*="overflow: scroll"]') as HTMLElement | null
|
|
24
|
+
expect(viewport).not.toBeNull()
|
|
25
|
+
expect(viewport!.contains(screen.getByText('Page'))).toBe(true)
|
|
26
|
+
expect(main.className).not.toContain('overflow-y-auto')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('leaves the floating manner’s card as it was', () => {
|
|
30
|
+
render(
|
|
31
|
+
<AppShell variant="floating" nav={<nav>Nav</nav>}>
|
|
32
|
+
<p>Page</p>
|
|
33
|
+
</AppShell>,
|
|
34
|
+
)
|
|
35
|
+
const main = screen.getByRole('main')
|
|
36
|
+
expect(main.className).toContain('overflow-hidden')
|
|
37
|
+
expect(main.closest('[style*="overflow: scroll"]')).toBeNull()
|
|
38
|
+
})
|
|
39
|
+
})
|
package/src/AppShell.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
import { cn } from './cn'
|
|
3
|
+
import { ScrollArea } from './ScrollArea'
|
|
3
4
|
import { TopBar } from './TopBar'
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -63,7 +64,9 @@ export function AppShell({ variant = 'solid', menu, logo, search, identity, bann
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
return (
|
|
66
|
-
/* `relative overflow-hidden` is the seal the floating manner already has
|
|
67
|
+
/* `relative overflow-hidden` is the seal the floating manner already has
|
|
68
|
+
(and the ScrollArea's own root is `relative`, so an absolutely placed
|
|
69
|
+
stray inside a page now belongs to the region and scrolls with it),
|
|
67
70
|
and it takes both halves: an absolutely positioned descendant with no
|
|
68
71
|
positioned ancestor belongs to the *viewport*, so a scroll container
|
|
69
72
|
never clips it and the document itself gains its position as scroll
|
|
@@ -76,12 +79,24 @@ export function AppShell({ variant = 'solid', menu, logo, search, identity, bann
|
|
|
76
79
|
{nav}
|
|
77
80
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
78
81
|
{banner}
|
|
79
|
-
{/*
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
{/* The frame owns the page's scrollbar (Katerina, 2026-09-09: the
|
|
83
|
+
Issues page still had a native bar — D40 said everywhere, and the
|
|
84
|
+
one place every page passes through had been left out). A
|
|
85
|
+
`ScrollArea` here means no page can forget it. The content box is
|
|
86
|
+
at least the viewport's height and grows with a tall page, so Base
|
|
87
|
+
UI sees its size change (the morning's phantom bar was a content
|
|
88
|
+
box it could not watch); `main` fills it as a flex column. The
|
|
89
|
+
page contract (the AppShell page says it): a page is a flex
|
|
90
|
+
child of `main` — `flex-1` to fill and scroll here; `flex-1
|
|
91
|
+
min-h-0 [contain:size]` to take exactly the frame's height and
|
|
92
|
+
scroll inside itself, as Ship's detail grids do — and the outer
|
|
93
|
+
region, with nothing to scroll, passes the wheel through. Never
|
|
94
|
+
`h-full`: a box that is *at least* the viewport's height gives a
|
|
95
|
+
percentage nothing to resolve against — measured, all four Ship
|
|
96
|
+
pages, 2026-09-09. */}
|
|
97
|
+
<ScrollArea className="min-h-0 flex-1" contentClassName="flex min-h-full flex-col">
|
|
98
|
+
<main className="flex min-w-0 flex-1 flex-col">{children}</main>
|
|
99
|
+
</ScrollArea>
|
|
85
100
|
</div>
|
|
86
101
|
</div>
|
|
87
102
|
</div>
|
|
@@ -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
|
package/src/DialogShell.mdx
CHANGED
|
@@ -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
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
}
|
package/src/DialogShell.test.tsx
CHANGED
|
@@ -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
|
+
})
|
package/src/DialogShell.tsx
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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/EmptyState.mdx
CHANGED
|
@@ -29,8 +29,9 @@ message is still a `page`; a tall section with nothing in it is still a
|
|
|
29
29
|
Start the thread." — not just that it is empty.
|
|
30
30
|
|
|
31
31
|
A `page` sits in the middle of its box both ways. Inside a flex column
|
|
32
|
-
it takes the room left (`flex-1`) and
|
|
33
|
-
|
|
32
|
+
— the frame's `main` is one — it takes the room left (`flex-1`) and
|
|
33
|
+
centres in it, with nothing to add; only outside a flex column does it
|
|
34
|
+
need a height from the caller (`className="h-full"`).
|
|
34
35
|
|
|
35
36
|
## When not
|
|
36
37
|
|
|
@@ -44,8 +45,8 @@ a page, give it the height — `className="h-full"`.
|
|
|
44
45
|
```tsx
|
|
45
46
|
import { EmptyState } from '@estiva-app/ui'
|
|
46
47
|
|
|
47
|
-
// the whole page,
|
|
48
|
-
<EmptyState
|
|
48
|
+
// the whole page, in the frame: it fills and centres on its own
|
|
49
|
+
<EmptyState message="No documents yet. Create the first one." />
|
|
49
50
|
|
|
50
51
|
// one section of a page
|
|
51
52
|
<EmptyState scope="section" message="No comments yet." />
|
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
|
-
-
|
|
22
|
-
|
|
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=
|
|
114
|
+
<BaseField.Error match className={LINE_STYLES.error}>
|
|
105
115
|
{error}
|
|
106
116
|
</BaseField.Error>
|
|
107
117
|
) : helper != null ? (
|
|
108
|
-
<BaseField.Description className=
|
|
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
|