@estiva-app/ui 0.21.1 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/Avatar.d.ts +3 -0
- package/dist/Avatar.d.ts.map +1 -1
- package/dist/Banner.d.ts +20 -2
- package/dist/Banner.d.ts.map +1 -1
- package/dist/ChipInput.d.ts +3 -0
- package/dist/ChipInput.d.ts.map +1 -1
- package/dist/CommandPalette.d.ts +6 -0
- package/dist/CommandPalette.d.ts.map +1 -1
- package/dist/ContainerHeader.d.ts +25 -0
- package/dist/ContainerHeader.d.ts.map +1 -0
- package/dist/EmptyState.d.ts +10 -1
- package/dist/EmptyState.d.ts.map +1 -1
- package/dist/IdentityMenu.d.ts +3 -0
- package/dist/IdentityMenu.d.ts.map +1 -1
- package/dist/Menu.d.ts +7 -0
- package/dist/Menu.d.ts.map +1 -1
- package/dist/Skeleton.d.ts +2 -0
- package/dist/Skeleton.d.ts.map +1 -1
- package/dist/Toast.d.ts +5 -0
- package/dist/Toast.d.ts.map +1 -1
- package/dist/Toolbar.d.ts +4 -0
- package/dist/Toolbar.d.ts.map +1 -1
- package/dist/Tooltip.d.ts +4 -0
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-ZGJ2J5NU.js → chunk-EA33NP5B.js} +275 -11
- package/dist/gates/chunk-EA33NP5B.js.map +7 -0
- package/dist/gates/{chunk-AUXD4GCY.js → chunk-GTQZEHPC.js} +15 -1
- package/dist/gates/chunk-GTQZEHPC.js.map +7 -0
- package/dist/gates/cli.js +1 -1
- package/dist/gates/create-app.d.ts +2 -0
- package/dist/gates/create-app.d.ts.map +1 -1
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/index.js +25 -3
- package/dist/gates/index.js.map +2 -2
- package/dist/gates/status.d.ts +2 -0
- package/dist/gates/status.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +69 -48
- package/dist/index.js.map +4 -4
- package/dist/registry/app-DQI6WLHR.js +10 -0
- package/dist/registry/app-DQI6WLHR.js.map +7 -0
- package/dist/registry/app.d.ts +21 -0
- package/dist/registry/app.d.ts.map +1 -0
- package/dist/registry/build-LGFCCOLR.js +27 -0
- package/dist/registry/build-LGFCCOLR.js.map +7 -0
- package/dist/registry/build.d.ts +95 -0
- package/dist/registry/build.d.ts.map +1 -0
- package/dist/registry/chunk-E4JNV7PC.js +155 -0
- package/dist/registry/chunk-E4JNV7PC.js.map +7 -0
- package/dist/registry/chunk-NJN4MQAM.js +397 -0
- package/dist/registry/chunk-NJN4MQAM.js.map +7 -0
- package/dist/registry/chunk-W2B2G7OE.js +188 -0
- package/dist/registry/chunk-W2B2G7OE.js.map +7 -0
- package/dist/registry/chunk-WMFF3MPP.js +648 -0
- package/dist/registry/chunk-WMFF3MPP.js.map +7 -0
- package/dist/registry/cli.d.ts +2 -0
- package/dist/registry/cli.d.ts.map +1 -0
- package/dist/registry/cli.js +176 -0
- package/dist/registry/cli.js.map +7 -0
- package/dist/registry/find.d.ts +54 -0
- package/dist/registry/find.d.ts.map +1 -0
- package/dist/registry/index.d.ts +17 -0
- package/dist/registry/index.d.ts.map +1 -0
- package/dist/registry/index.js +34 -0
- package/dist/registry/index.js.map +7 -0
- package/dist/registry/schema.d.ts +224 -0
- package/dist/registry/schema.d.ts.map +1 -0
- package/package.json +15 -2
- package/registry.json +4615 -0
- package/src/Avatar.tsx +3 -0
- package/src/Banner.mdx +17 -4
- package/src/Banner.stories.tsx +22 -0
- package/src/Banner.test.tsx +35 -0
- package/src/Banner.tsx +34 -9
- package/src/ChipInput.tsx +3 -0
- package/src/CommandPalette.tsx +6 -0
- package/src/ContainerHeader.mdx +56 -0
- package/src/ContainerHeader.stories.tsx +62 -0
- package/src/ContainerHeader.test.tsx +47 -0
- package/src/ContainerHeader.tsx +45 -0
- package/src/EmptyState.mdx +12 -0
- package/src/EmptyState.stories.tsx +6 -1
- package/src/EmptyState.test.tsx +18 -1
- package/src/EmptyState.tsx +14 -1
- package/src/IdentityMenu.tsx +3 -0
- package/src/Menu.tsx +7 -0
- package/src/Skeleton.tsx +2 -0
- package/src/Toast.tsx +5 -0
- package/src/Toolbar.tsx +4 -0
- package/src/Tooltip.tsx +4 -0
- package/src/gates/app-checks.ts +23 -1
- package/src/gates/create-app.test.ts +77 -6
- package/src/gates/create-app.ts +288 -15
- package/src/gates/status.ts +16 -0
- package/src/index.ts +1 -0
- package/src/registry/app.test.ts +562 -0
- package/src/registry/app.ts +854 -0
- package/src/registry/build.ts +679 -0
- package/src/registry/cli.ts +204 -0
- package/src/registry/find.ts +232 -0
- package/src/registry/index.ts +31 -0
- package/src/registry/registry.test.ts +448 -0
- package/src/registry/schema.ts +419 -0
- package/dist/gates/chunk-AUXD4GCY.js.map +0 -7
- package/dist/gates/chunk-ZGJ2J5NU.js.map +0 -7
package/src/Avatar.tsx
CHANGED
|
@@ -29,6 +29,9 @@ import { cn } from './cn'
|
|
|
29
29
|
*/
|
|
30
30
|
const HUES = ['#56c8ff', '#ff8f6b', '#4ade8c', '#b18cff', '#ffc94d', '#ff7eb0', '#7ea8ff', '#5fdfd6']
|
|
31
31
|
|
|
32
|
+
/** The colour a person's initials sit on, chosen from a key: the same key
|
|
33
|
+
* always gives the same hue, so a face reads as theirs in every app. The
|
|
34
|
+
* eight are a fallback palette, not tokens — see the note above `HUES`. */
|
|
32
35
|
export const hueFor = (key: string) => {
|
|
33
36
|
let h = 0
|
|
34
37
|
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0
|
package/src/Banner.mdx
CHANGED
|
@@ -28,8 +28,8 @@ line, one of four tones, gone when there is nothing to say.
|
|
|
28
28
|
under the header at the app's full width; a floating pill is not one, even
|
|
29
29
|
a permanent one (Katerina, D21).
|
|
30
30
|
- A standing property of one thing → **Chip** on that thing.
|
|
31
|
-
-
|
|
32
|
-
|
|
31
|
+
- More than one action, or a second line of text — that is a different
|
|
32
|
+
component (not in the package yet).
|
|
33
33
|
|
|
34
34
|
## How
|
|
35
35
|
|
|
@@ -41,8 +41,21 @@ import { Banner } from '@estiva-app/ui'
|
|
|
41
41
|
|
|
42
42
|
- Render it only while there is something to say — an empty strip is not
|
|
43
43
|
a state.
|
|
44
|
-
- One line of
|
|
45
|
-
|
|
44
|
+
- One line of text, `body-2`. If it needs a **second line**, it has outgrown
|
|
45
|
+
this component.
|
|
46
|
+
- `icon` puts a 16px icon before the line and `action` one small muted Button
|
|
47
|
+
at the end of it, both in the tone (Katerina, 2026-09-18: Peek's strip above
|
|
48
|
+
the composer became a Banner, `info`). With either, the banner is one line
|
|
49
|
+
and the text truncates. One action at most.
|
|
50
|
+
|
|
51
|
+
<Canvas of={BannerStories.WithIconAndAction} />
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
<Banner tone="info" icon={<IconUserPlus size={16} stroke={1.5} />} action={{ label: 'Join', onClick: join }}>
|
|
55
|
+
You are not in <span className="font-medium">{title}</span> yet
|
|
56
|
+
</Banner>
|
|
57
|
+
```
|
|
58
|
+
|
|
46
59
|
- `onDismiss` adds an ✕ at the right-hand end (Katerina, D21). Use it where
|
|
47
60
|
the reader decides the notice is done with, rather than the app. The row
|
|
48
61
|
is 40px rather than 36px with it, because the button is taller than the
|
package/src/Banner.stories.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconPencilMinus, IconUserPlus } from '@tabler/icons-react'
|
|
2
3
|
import { Banner } from './Banner'
|
|
3
4
|
|
|
4
5
|
const meta = {
|
|
@@ -34,6 +35,27 @@ export const Dismissible: Story = {
|
|
|
34
35
|
args: { tone: 'warning', children: 'This workspace is read-only until sign-in.', onDismiss: () => {} },
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* With an icon and one action (Katerina, 2026-09-18). One line: the text
|
|
40
|
+
* truncates, and the Button stays at the end.
|
|
41
|
+
*/
|
|
42
|
+
export const WithIconAndAction: Story = {
|
|
43
|
+
parameters: { controls: { disable: true } },
|
|
44
|
+
render: () => (
|
|
45
|
+
<div className="flex w-full max-w-xl flex-col gap-2">
|
|
46
|
+
<Banner tone="info" icon={<IconPencilMinus size={16} stroke={1.5} />} action={{ label: 'Invite people', onClick: () => {} }}>
|
|
47
|
+
This is the start of the conversation in <span className="font-medium">Project Alpha</span>
|
|
48
|
+
</Banner>
|
|
49
|
+
<Banner tone="info" icon={<IconPencilMinus size={16} stroke={1.5} />}>
|
|
50
|
+
This is the start of your conversation with <span className="font-medium">Sam Lee</span>
|
|
51
|
+
</Banner>
|
|
52
|
+
<Banner tone="info" icon={<IconUserPlus size={16} stroke={1.5} />} action={{ label: 'Join', onClick: () => {} }}>
|
|
53
|
+
You are not in <span className="font-medium">Project Alpha</span> yet — join to take part in the conversation
|
|
54
|
+
</Banner>
|
|
55
|
+
</div>
|
|
56
|
+
),
|
|
57
|
+
}
|
|
58
|
+
|
|
37
59
|
/** All four tones, stacked. */
|
|
38
60
|
export const AllTones: Story = {
|
|
39
61
|
parameters: { controls: { disable: true } },
|
package/src/Banner.test.tsx
CHANGED
|
@@ -57,4 +57,39 @@ describe('Banner', () => {
|
|
|
57
57
|
)
|
|
58
58
|
expect(container.firstElementChild!.getAttribute('role')).toBe('alert')
|
|
59
59
|
})
|
|
60
|
+
|
|
61
|
+
it('puts an icon before the line and one small muted action after it, on one line (Katerina, 2026-09-18)', async () => {
|
|
62
|
+
const onClick = vi.fn()
|
|
63
|
+
const { container } = render(
|
|
64
|
+
<Banner tone="info" icon={<svg data-testid="icon" />} action={{ label: 'Join', onClick }}>
|
|
65
|
+
You are not in <b>Alpha</b> yet
|
|
66
|
+
</Banner>,
|
|
67
|
+
)
|
|
68
|
+
const row = container.firstElementChild!
|
|
69
|
+
expect(row.getAttribute('role')).toBe('status')
|
|
70
|
+
expect(row.firstElementChild!.querySelector('[data-testid="icon"]')).not.toBeNull()
|
|
71
|
+
const line = screen.getByText(/You are not in/)
|
|
72
|
+
expect(line.className).toContain('truncate')
|
|
73
|
+
const button = screen.getByRole('button', { name: 'Join' })
|
|
74
|
+
expect(line.nextElementSibling).toBe(button)
|
|
75
|
+
await userEvent.click(button)
|
|
76
|
+
expect(onClick).toHaveBeenCalledOnce()
|
|
77
|
+
// No dismiss unless asked for.
|
|
78
|
+
expect(screen.queryByRole('button', { name: 'Dismiss' })).toBeNull()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('an icon alone still keeps the line on one line, and a dismiss alone still wraps', () => {
|
|
82
|
+
const { rerender } = render(
|
|
83
|
+
<Banner tone="info" icon={<svg />}>
|
|
84
|
+
One line
|
|
85
|
+
</Banner>,
|
|
86
|
+
)
|
|
87
|
+
expect(screen.getByText('One line').className).toContain('truncate')
|
|
88
|
+
rerender(
|
|
89
|
+
<Banner tone="info" onDismiss={() => {}}>
|
|
90
|
+
One line
|
|
91
|
+
</Banner>,
|
|
92
|
+
)
|
|
93
|
+
expect(screen.getByText('One line').className).not.toContain('truncate')
|
|
94
|
+
})
|
|
60
95
|
})
|
package/src/Banner.tsx
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
1
2
|
import { IconX } from '@tabler/icons-react'
|
|
3
|
+
import { Button } from './Button'
|
|
2
4
|
import { cn } from './cn'
|
|
3
5
|
import { IconButton } from './IconButton'
|
|
4
6
|
|
|
@@ -13,6 +15,11 @@ import { IconButton } from './IconButton'
|
|
|
13
15
|
* An `error` announces itself (`role="alert"`); the other tones are
|
|
14
16
|
* polite (`role="status"`).
|
|
15
17
|
*
|
|
18
|
+
* `icon` and `action` put a 16px icon before the line and one small muted
|
|
19
|
+
* Button after it (Katerina, 2026-09-18, UIG-13: Peek's composer strip became
|
|
20
|
+
* a Banner in the `info` tone). With either, the banner is one line and the
|
|
21
|
+
* text truncates.
|
|
22
|
+
*
|
|
16
23
|
* `onDismiss` adds an `✕` at the right-hand end (Katerina, D21,
|
|
17
24
|
* 2026-09-07). Without it the strip is exactly what it was — a banner an
|
|
18
25
|
* app removes by not rendering it. With it the row is 40px rather than
|
|
@@ -28,7 +35,16 @@ export type BannerTone = 'ok' | 'error' | 'info' | 'warning'
|
|
|
28
35
|
|
|
29
36
|
export interface BannerProps {
|
|
30
37
|
tone: BannerTone
|
|
31
|
-
|
|
38
|
+
/** The one line. Text, or text with a name in `font-medium`. */
|
|
39
|
+
children: ReactNode
|
|
40
|
+
/**
|
|
41
|
+
* A 16px icon before the line, in the tone's colour (Katerina, 2026-09-18:
|
|
42
|
+
* Peek's composer strip became a Banner). With an icon or an action the
|
|
43
|
+
* banner is one line, and the text truncates.
|
|
44
|
+
*/
|
|
45
|
+
icon?: ReactNode
|
|
46
|
+
/** The one thing to do: the package's Button, muted and small, at the end of the line. */
|
|
47
|
+
action?: { label: string; onClick?: () => void }
|
|
32
48
|
/** Adds an `✕` at the right-hand end. Absent: the app removes the banner. */
|
|
33
49
|
onDismiss?: () => void
|
|
34
50
|
/** The dismiss button's accessible name. Defaults to "Dismiss". */
|
|
@@ -43,25 +59,34 @@ const TONE_STYLES: Record<BannerTone, string> = {
|
|
|
43
59
|
warning: 'bg-warning-muted text-warning-default',
|
|
44
60
|
}
|
|
45
61
|
|
|
46
|
-
export function Banner({ tone, children, onDismiss, dismissLabel = 'Dismiss', className }: BannerProps) {
|
|
62
|
+
export function Banner({ tone, children, icon, action, onDismiss, dismissLabel = 'Dismiss', className }: BannerProps) {
|
|
47
63
|
const role = tone === 'error' ? 'alert' : 'status'
|
|
48
|
-
//
|
|
49
|
-
// the height it had.
|
|
50
|
-
if (!onDismiss) {
|
|
64
|
+
// Nothing beside the line, no row: every banner that predates the props keeps
|
|
65
|
+
// the DOM and the height it had.
|
|
66
|
+
if (!onDismiss && !icon && !action) {
|
|
51
67
|
return (
|
|
52
68
|
<div role={role} className={cn('px-4 py-2 text-body-2', TONE_STYLES[tone], className)}>
|
|
53
69
|
{children}
|
|
54
70
|
</div>
|
|
55
71
|
)
|
|
56
72
|
}
|
|
73
|
+
const oneLine = Boolean(icon || action)
|
|
57
74
|
return (
|
|
58
75
|
<div role={role} className={cn('flex items-center gap-3 px-4 py-2 text-body-2', TONE_STYLES[tone], className)}>
|
|
59
|
-
<span className="
|
|
76
|
+
{icon && <span className="flex shrink-0">{icon}</span>}
|
|
77
|
+
<span className={cn('min-w-0 flex-1', oneLine && 'truncate')}>{children}</span>
|
|
78
|
+
{action && (
|
|
79
|
+
<Button variant="muted" size="small" className="shrink-0" onClick={action.onClick}>
|
|
80
|
+
{action.label}
|
|
81
|
+
</Button>
|
|
82
|
+
)}
|
|
60
83
|
{/* `current` so the ✕ takes the tone's colour rather than the
|
|
61
84
|
muted grey an IconButton wears on a neutral surface. */}
|
|
62
|
-
|
|
63
|
-
<
|
|
64
|
-
|
|
85
|
+
{onDismiss && (
|
|
86
|
+
<IconButton variant="current" aria-label={dismissLabel} onClick={onDismiss} className="-mr-1 shrink-0">
|
|
87
|
+
<IconX size={16} stroke={1.5} />
|
|
88
|
+
</IconButton>
|
|
89
|
+
)}
|
|
65
90
|
</div>
|
|
66
91
|
)
|
|
67
92
|
}
|
package/src/ChipInput.tsx
CHANGED
|
@@ -54,6 +54,9 @@ export interface InputChipProps {
|
|
|
54
54
|
className?: string
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** A chip standing on its own: a label, an optional leading icon, an optional
|
|
58
|
+
* ✕. For a caller that keeps its own list. Inside a `ChipInput` the same look
|
|
59
|
+
* goes onto Base UI's `Combobox.Chip`, which joins the field's keyboard. */
|
|
57
60
|
export function InputChip({ label, leading, onRemove, removeLabel, truncate, className }: InputChipProps) {
|
|
58
61
|
return (
|
|
59
62
|
<div className={cn(CHIP_BOX, chipPadding(!!leading, !!onRemove), className)}>
|
package/src/CommandPalette.tsx
CHANGED
|
@@ -237,6 +237,9 @@ export interface CommandPaletteSearchProps {
|
|
|
237
237
|
|
|
238
238
|
type ListGroup = { value: string; items: CommandPaletteRow[] }
|
|
239
239
|
|
|
240
|
+
/** The palette as a search: a query field, rows in labelled groups walked with
|
|
241
|
+
* the arrow keys, and what to show while nothing has arrived and when nothing
|
|
242
|
+
* matches. */
|
|
240
243
|
export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups, chip, pending, notes = [], empty, children }: CommandPaletteSearchProps) {
|
|
241
244
|
const { modKey, popupRef } = usePalette('CommandPaletteSearch')
|
|
242
245
|
const back = useBack(chip, popupRef)
|
|
@@ -478,6 +481,9 @@ function isTextField(el: Element | null): el is HTMLInputElement | HTMLTextAreaE
|
|
|
478
481
|
return el instanceof HTMLInputElement && TEXT_TYPES.has(el.getAttribute('type') ?? '') && el.getAttribute('role') !== 'combobox' && el.tabIndex >= 0
|
|
479
482
|
}
|
|
480
483
|
|
|
484
|
+
/** The palette as a form: fields, one submit button, and Ctrl+Enter — both ways
|
|
485
|
+
* in run Base UI's field check. It shows what it is working on and what failed,
|
|
486
|
+
* and sends focus to the first field that needs something. */
|
|
481
487
|
export function CommandPaletteForm({ chip, icon, submitLabel, onSubmit, submitWaits, working, error, children }: CommandPaletteFormProps) {
|
|
482
488
|
const { modKey, popupRef } = usePalette('CommandPaletteForm')
|
|
483
489
|
const back = useBack(chip, popupRef)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as ContainerHeaderStories from './ContainerHeader.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={ContainerHeaderStories} />
|
|
5
|
+
|
|
6
|
+
# ContainerHeader
|
|
7
|
+
|
|
8
|
+
The bar across the top of a column — a list, a thread, a side panel: its
|
|
9
|
+
title, and the buttons that act on the whole column at the right edge. 48px,
|
|
10
|
+
with a hairline under it. Peek's ContainerHeader, moved into the package as it
|
|
11
|
+
looks (Katerina, 2026-09-18).
|
|
12
|
+
|
|
13
|
+
<Canvas of={ContainerHeaderStories.WithActions} />
|
|
14
|
+
|
|
15
|
+
## When
|
|
16
|
+
|
|
17
|
+
- The top of a column in a frame: the list beside the conversation, the
|
|
18
|
+
thread, a side panel. One per column.
|
|
19
|
+
- The column's own actions — new, sort, close — as IconButtons with tooltips
|
|
20
|
+
in `actions`.
|
|
21
|
+
|
|
22
|
+
## When not
|
|
23
|
+
|
|
24
|
+
- One section inside a column → **SectionHeader**, the 32px row with a
|
|
25
|
+
SectionLabel.
|
|
26
|
+
- The top of the whole app → **TopBar**.
|
|
27
|
+
- A dialog's title → **DialogShell** draws its own.
|
|
28
|
+
|
|
29
|
+
## How
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { ContainerHeader, IconButton } from '@estiva-app/ui'
|
|
33
|
+
|
|
34
|
+
<ContainerHeader
|
|
35
|
+
title="All items"
|
|
36
|
+
chevron
|
|
37
|
+
actions={
|
|
38
|
+
<IconButton tooltip="New item" aria-label="New item" onClick={openNew}>
|
|
39
|
+
<IconEdit size={16} stroke={1.5} />
|
|
40
|
+
</IconButton>
|
|
41
|
+
}
|
|
42
|
+
/>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- A string title is one line in `body-2-strong` and keeps its width. Anything
|
|
46
|
+
else — an EditableText, a title over a caption — takes the room left and is
|
|
47
|
+
drawn as given.
|
|
48
|
+
|
|
49
|
+
<Canvas of={ContainerHeaderStories.WithRichTitle} />
|
|
50
|
+
|
|
51
|
+
- Only offer a button that does something: a button with nothing behind it is
|
|
52
|
+
a promise the column cannot keep.
|
|
53
|
+
|
|
54
|
+
## Props
|
|
55
|
+
|
|
56
|
+
<Controls of={ContainerHeaderStories.Default} />
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconEdit, IconSortDescending, IconX } from '@tabler/icons-react'
|
|
3
|
+
import { ContainerHeader } from './ContainerHeader'
|
|
4
|
+
import { IconButton } from './IconButton'
|
|
5
|
+
|
|
6
|
+
// The top of a column: a surface with a hairline, so the bar's own hairline and height can be seen.
|
|
7
|
+
const inColumn: Decorator = (Story) => <div className="w-96 overflow-hidden rounded-lg border border-border-default bg-bg-surface">{Story()}</div>
|
|
8
|
+
|
|
9
|
+
const meta = {
|
|
10
|
+
title: 'Navigation/ContainerHeader',
|
|
11
|
+
component: ContainerHeader,
|
|
12
|
+
args: { title: 'Items' },
|
|
13
|
+
argTypes: { actions: { control: false } },
|
|
14
|
+
decorators: [inColumn],
|
|
15
|
+
} satisfies Meta<typeof ContainerHeader>
|
|
16
|
+
|
|
17
|
+
export default meta
|
|
18
|
+
type Story = StoryObj<typeof meta>
|
|
19
|
+
|
|
20
|
+
/** A string title, one line, alone. */
|
|
21
|
+
export const Default: Story = {}
|
|
22
|
+
|
|
23
|
+
/** A chevron after the title, for a title that opens something. */
|
|
24
|
+
export const WithChevron: Story = { args: { title: 'All items', chevron: true } }
|
|
25
|
+
|
|
26
|
+
/** The column's own buttons at the right edge: IconButtons with tooltips. */
|
|
27
|
+
export const WithActions: Story = {
|
|
28
|
+
args: {
|
|
29
|
+
title: 'All items',
|
|
30
|
+
chevron: true,
|
|
31
|
+
actions: (
|
|
32
|
+
<>
|
|
33
|
+
<IconButton tooltip="Sort by" aria-label="Sort by">
|
|
34
|
+
<IconSortDescending size={16} stroke={1.5} />
|
|
35
|
+
</IconButton>
|
|
36
|
+
<IconButton tooltip="New item" aria-label="New item">
|
|
37
|
+
<IconEdit size={16} stroke={1.5} />
|
|
38
|
+
</IconButton>
|
|
39
|
+
</>
|
|
40
|
+
),
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A title that is not a string — a name over its caption — takes the room left and is drawn as given. */
|
|
45
|
+
export const WithRichTitle: Story = {
|
|
46
|
+
// axe color-contrast is off here until PLAN.md stage 0.10 is ruled: the caption
|
|
47
|
+
// is muted text, 3.94:1 in signal (AA 4.5:1) — as Peek's file page draws it.
|
|
48
|
+
parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
|
|
49
|
+
args: {
|
|
50
|
+
title: (
|
|
51
|
+
<div className="min-w-0">
|
|
52
|
+
<p className="truncate text-body-2-strong text-text-primary">Quarterly plan</p>
|
|
53
|
+
<p className="truncate text-caption text-text-muted">In Project Alpha</p>
|
|
54
|
+
</div>
|
|
55
|
+
),
|
|
56
|
+
actions: (
|
|
57
|
+
<IconButton tooltip="Close" aria-label="Close">
|
|
58
|
+
<IconX size={16} stroke={1.5} />
|
|
59
|
+
</IconButton>
|
|
60
|
+
),
|
|
61
|
+
},
|
|
62
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What `ContainerHeader.mdx` claims: a string title is one line that keeps its
|
|
4
|
+
* width, a node is drawn as given and takes the room left, and the column's
|
|
5
|
+
* buttons sit at the right edge. The pixels are proved against Peek's own
|
|
6
|
+
* (docs/GATES.md, UIG-13: A).
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
9
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
10
|
+
import { ContainerHeader } from './ContainerHeader'
|
|
11
|
+
|
|
12
|
+
afterEach(cleanup)
|
|
13
|
+
|
|
14
|
+
describe('ContainerHeader', () => {
|
|
15
|
+
it('is the 48px bar with a hairline under it', () => {
|
|
16
|
+
const { container } = render(<ContainerHeader title="Items" />)
|
|
17
|
+
const bar = container.firstElementChild!.className.split(' ')
|
|
18
|
+
expect(bar).toContain('h-12')
|
|
19
|
+
expect(bar).toContain('border-b')
|
|
20
|
+
expect(bar).toContain('border-border-subtle')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('a string title is one line in body-2-strong, and keeps its width', () => {
|
|
24
|
+
render(<ContainerHeader title="Items" />)
|
|
25
|
+
const title = screen.getByText('Items')
|
|
26
|
+
expect(title.className).toContain('text-body-2-strong')
|
|
27
|
+
expect(title.className).toContain('whitespace-nowrap')
|
|
28
|
+
expect(title.parentElement!.className).toContain('shrink-0')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('a node title is drawn as given and takes the room left', () => {
|
|
32
|
+
render(<ContainerHeader title={<em>Plan</em>} />)
|
|
33
|
+
const holder = screen.getByText('Plan').parentElement!
|
|
34
|
+
expect(holder.className).toContain('flex-1')
|
|
35
|
+
expect(holder.className).toContain('min-w-0')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('draws a chevron only when asked, and the buttons at the right edge', () => {
|
|
39
|
+
const { container, rerender } = render(<ContainerHeader title="Items" />)
|
|
40
|
+
expect(container.querySelector('svg')).toBeNull()
|
|
41
|
+
expect(container.firstElementChild!.children).toHaveLength(1)
|
|
42
|
+
rerender(<ContainerHeader title="Items" chevron actions={<button type="button">New</button>} />)
|
|
43
|
+
expect(container.querySelector('svg')).not.toBeNull()
|
|
44
|
+
expect(container.firstElementChild!.children).toHaveLength(2)
|
|
45
|
+
expect(screen.getByRole('button', { name: 'New' }).closest('div')!.className).toContain('gap-1')
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { IconChevronDown } from '@tabler/icons-react'
|
|
3
|
+
import { cn } from './cn'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The bar across the top of a column — a list, a thread, a side panel: its
|
|
7
|
+
* title, and the buttons that act on the whole column at the right edge.
|
|
8
|
+
* Peek's ContainerHeader, moved into the package exactly as it looks
|
|
9
|
+
* (Katerina, 2026-09-18, UIG-13).
|
|
10
|
+
*
|
|
11
|
+
* 48px tall, a hairline under it. A string title is one line in
|
|
12
|
+
* `body-2-strong` and keeps its width; anything else — an EditableText, a
|
|
13
|
+
* title over a caption — takes the room that is left and is drawn as given.
|
|
14
|
+
*
|
|
15
|
+
* Not a SectionHeader: that is the 32px row one section of a column starts
|
|
16
|
+
* with. This is the column's own bar, one per column.
|
|
17
|
+
*/
|
|
18
|
+
export interface ContainerHeaderProps {
|
|
19
|
+
/** A string keeps the one-line treatment; a node is drawn as given. */
|
|
20
|
+
title: ReactNode
|
|
21
|
+
/** A chevron after the title, for a title that opens something. */
|
|
22
|
+
chevron?: boolean
|
|
23
|
+
/** The column's own buttons, at the right edge: IconButtons with tooltips, 4px apart. */
|
|
24
|
+
actions?: ReactNode
|
|
25
|
+
className?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ContainerHeader({ title, chevron = false, actions, className }: ContainerHeaderProps) {
|
|
29
|
+
const text = typeof title === 'string'
|
|
30
|
+
return (
|
|
31
|
+
<div className={cn('flex h-12 shrink-0 items-center justify-between overflow-hidden border-b border-border-subtle py-2 pr-4 pl-5', className)}>
|
|
32
|
+
<div className={cn('flex items-center gap-2 overflow-hidden', text ? 'shrink-0' : 'min-w-0 flex-1')}>
|
|
33
|
+
<div className={cn('flex items-center gap-1', text ? 'shrink-0' : 'min-w-0 flex-1')}>
|
|
34
|
+
{text ? <span className="whitespace-nowrap text-body-2-strong text-text-primary">{title}</span> : <div className="min-w-0 flex-1">{title}</div>}
|
|
35
|
+
{chevron && <IconChevronDown size={12} stroke={1.5} className="shrink-0 text-text-secondary" />}
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
{actions && (
|
|
39
|
+
<div className="flex shrink-0 items-center justify-end gap-3">
|
|
40
|
+
<div className="flex shrink-0 items-center gap-1">{actions}</div>
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
package/src/EmptyState.mdx
CHANGED
|
@@ -53,6 +53,18 @@ import { EmptyState } from '@estiva-app/ui'
|
|
|
53
53
|
<EmptyState scope="section" message="No comments yet." />
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
+
**When there is one thing to do about it, give it as `action`.** It is drawn
|
|
57
|
+
16px under the line as the package's Button, outlined — the same look wherever
|
|
58
|
+
it appears (Katerina, 2026-09-18). Never put a Button beside an EmptyState in a
|
|
59
|
+
box of your own: the box places it, and no two boxes place it alike. A `page`
|
|
60
|
+
only; a section's line takes no action.
|
|
61
|
+
|
|
62
|
+
<Canvas of={EmptyStateStories.WithAction} />
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
<EmptyState icon={<IconMessagePlus size={16} stroke={1.5} />} message="No topics yet." action={{ label: 'New topic', onClick: openNewTopic }} />
|
|
66
|
+
```
|
|
67
|
+
|
|
56
68
|
**Put a section's empty state inside the box its rows live in, and give it no
|
|
57
69
|
padding.** Write the box once; inside it goes either the rows or the empty
|
|
58
70
|
state. The box's padding then places both, and the line starts where the first
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
-
import { IconLock } from '@tabler/icons-react'
|
|
2
|
+
import { IconLock, IconMessagePlus } from '@tabler/icons-react'
|
|
3
3
|
import { EmptyState } from './EmptyState'
|
|
4
4
|
|
|
5
5
|
// A box with a hairline, so where the state sits inside its room can be seen: a page's in the middle both ways, a section's at the top left.
|
|
@@ -25,6 +25,11 @@ const sectionContrastDeferred = { a11y: { config: { rules: [{ id: 'color-contras
|
|
|
25
25
|
export const Section: Story = { args: { scope: 'section' }, decorators: [inBox], parameters: sectionContrastDeferred }
|
|
26
26
|
export const LongerMessage: Story = { args: { message: 'No items yet. Add one from any list.' }, decorators: [inBox] }
|
|
27
27
|
export const CustomIcon: Story = { args: { icon: <IconLock size={16} stroke={1.5} />, message: 'Nothing you can read here yet.' }, decorators: [inBox] }
|
|
28
|
+
/** The one thing to do about it, under the line: the package's Button, outlined. */
|
|
29
|
+
export const WithAction: Story = {
|
|
30
|
+
args: { icon: <IconMessagePlus size={16} stroke={1.5} />, message: 'No topics yet.', action: { label: 'New topic', onClick: () => {} } },
|
|
31
|
+
decorators: [inBox],
|
|
32
|
+
}
|
|
28
33
|
|
|
29
34
|
/** One box, written once, holding the rows or the empty state: its padding places both, so the line starts where the first row does. */
|
|
30
35
|
export const InsideTheRowsBox: Story = {
|
package/src/EmptyState.test.tsx
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* structure.
|
|
7
7
|
*/
|
|
8
8
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
9
|
-
import { cleanup, render, screen } from '@testing-library/react'
|
|
9
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
10
10
|
import { EmptyState } from './EmptyState'
|
|
11
11
|
|
|
12
12
|
afterEach(cleanup)
|
|
@@ -59,4 +59,21 @@ describe('EmptyState', () => {
|
|
|
59
59
|
expect(container.querySelector('[data-testid="own"]')).not.toBeNull()
|
|
60
60
|
expect(container.querySelector('svg')).toBeNull()
|
|
61
61
|
})
|
|
62
|
+
|
|
63
|
+
it('draws its action under the line as the package Button, outlined (Katerina, 2026-09-18)', () => {
|
|
64
|
+
let clicked = 0
|
|
65
|
+
render(<EmptyState message="No topics yet." action={{ label: 'New topic', onClick: () => (clicked += 1) }} />)
|
|
66
|
+
const button = screen.getByRole('button', { name: 'New topic' })
|
|
67
|
+
expect(button.className).toContain('border-border-default')
|
|
68
|
+
expect(button.className).toContain('mt-2')
|
|
69
|
+
// Under the line, in the same column.
|
|
70
|
+
expect(screen.getByText('No topics yet.').nextElementSibling).toBe(button)
|
|
71
|
+
fireEvent.click(button)
|
|
72
|
+
expect(clicked).toBe(1)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('a section takes no action', () => {
|
|
76
|
+
render(<EmptyState scope="section" message="Nothing here yet." action={{ label: 'Add', onClick: () => {} }} />)
|
|
77
|
+
expect(screen.queryByRole('button')).toBeNull()
|
|
78
|
+
})
|
|
62
79
|
})
|
package/src/EmptyState.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
import { IconMessage2 } from '@tabler/icons-react'
|
|
3
|
+
import { Button } from './Button'
|
|
3
4
|
import { cn } from './cn'
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -28,10 +29,16 @@ export interface EmptyStateProps {
|
|
|
28
29
|
message: string
|
|
29
30
|
/** `page` when the whole page is empty; `section` when one part of it is. */
|
|
30
31
|
scope?: 'page' | 'section'
|
|
32
|
+
/**
|
|
33
|
+
* The one thing to do about it — "New topic", "Try again" — drawn 16px under
|
|
34
|
+
* the line as the package's Button, outlined (Katerina, 2026-09-18). A `page`
|
|
35
|
+
* only: a section's empty line is one line among others, not a stage.
|
|
36
|
+
*/
|
|
37
|
+
action?: { label: string; onClick: () => void }
|
|
31
38
|
className?: string
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
export function EmptyState({ icon, message, scope = 'page', className }: EmptyStateProps) {
|
|
41
|
+
export function EmptyState({ icon, message, scope = 'page', action, className }: EmptyStateProps) {
|
|
35
42
|
if (scope === 'section') {
|
|
36
43
|
return <p className={cn('text-caption text-text-muted', className)}>{message}</p>
|
|
37
44
|
}
|
|
@@ -39,6 +46,12 @@ export function EmptyState({ icon, message, scope = 'page', className }: EmptySt
|
|
|
39
46
|
<div className={cn('flex flex-1 flex-col items-center justify-center gap-2', className)}>
|
|
40
47
|
<span className="text-text-secondary">{icon ?? <IconMessage2 size={16} stroke={1.5} />}</span>
|
|
41
48
|
<p className="text-body-2 text-text-secondary text-center">{message}</p>
|
|
49
|
+
{/* 8px of the column's gap and 8px of its own: 16px under the line, as Peek had it. */}
|
|
50
|
+
{action && (
|
|
51
|
+
<Button variant="outlined" className="mt-2" onClick={action.onClick}>
|
|
52
|
+
{action.label}
|
|
53
|
+
</Button>
|
|
54
|
+
)}
|
|
42
55
|
</div>
|
|
43
56
|
)
|
|
44
57
|
}
|
package/src/IdentityMenu.tsx
CHANGED
|
@@ -62,6 +62,9 @@ export interface IdentityPanelProps extends Omit<IdentityMenuProps, 'compact'> {
|
|
|
62
62
|
onClose?: () => void
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** The identity rows on a panel of their own, with no menu around them — for a
|
|
66
|
+
* docs page, or anywhere the panel is already placed. `IdentityMenu` is the
|
|
67
|
+
* same rows inside a real menu. */
|
|
65
68
|
export function IdentityPanel({ className, onClose = () => {}, ...rest }: IdentityPanelProps) {
|
|
66
69
|
return (
|
|
67
70
|
<MenuPanel className={cn('w-72', className)}>
|
package/src/Menu.tsx
CHANGED
|
@@ -86,6 +86,10 @@ export interface MenuPanelProps extends Omit<ComponentPropsWithRef<'div'>, 'chil
|
|
|
86
86
|
children?: ReactNode
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/** The menu's surface without the menu: the elevated box, the rows' padding,
|
|
90
|
+
* and a divider that runs the full width of what it separates. `Menu` renders
|
|
91
|
+
* its popup as one. On its own it is for the places that draw a menu's look
|
|
92
|
+
* but not its behaviour — a docs page, Peek's `@`, `/` and `[` pickers. */
|
|
89
93
|
export function MenuPanel({ children, className, ...props }: MenuPanelProps) {
|
|
90
94
|
return (
|
|
91
95
|
<div
|
|
@@ -277,6 +281,9 @@ export interface MenuSubProps {
|
|
|
277
281
|
className?: string
|
|
278
282
|
}
|
|
279
283
|
|
|
284
|
+
/** A row that opens another menu beside it; its rows are this row's children.
|
|
285
|
+
* Never inside a menu opened on hover — leaving the submenu strands both open,
|
|
286
|
+
* measured below. */
|
|
280
287
|
export function MenuSub({ label, leading, selected, children, className }: MenuSubProps) {
|
|
281
288
|
/*
|
|
282
289
|
* A submenu inside a hover-opened menu strands it.
|
package/src/Skeleton.tsx
CHANGED
|
@@ -10,6 +10,8 @@ import { cn } from './cn'
|
|
|
10
10
|
* A list reveals after 150ms (`animate-skeleton-in`, in the preset) so a
|
|
11
11
|
* fast load never flashes a skeleton — Peek's rule.
|
|
12
12
|
*/
|
|
13
|
+
/** One pulsing bar at the inset colour: the piece every placeholder here is
|
|
14
|
+
* built from. It draws nothing of its own — give it a width and a height. */
|
|
13
15
|
export function SkeletonBar({ className, style }: { className?: string; style?: CSSProperties }) {
|
|
14
16
|
return <div className={cn('bg-bg-inset rounded-sm animate-pulse', className)} style={style} />
|
|
15
17
|
}
|
package/src/Toast.tsx
CHANGED
|
@@ -153,6 +153,9 @@ const ToastContext = createContext<ToastValue | null>(null)
|
|
|
153
153
|
/** D7: three on screen at once. */
|
|
154
154
|
const VISIBLE_TOASTS = 3
|
|
155
155
|
|
|
156
|
+
/** Mount once, near the top of an app. It owns the stack, the timers and the
|
|
157
|
+
* portal every toast goes through, and it is what `useToast` talks to. Three
|
|
158
|
+
* show at once; a fourth waits for one of them to close. */
|
|
156
159
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
157
160
|
// One manager per provider, so `useToast` can add and close without
|
|
158
161
|
// re-rendering every caller each time the list changes.
|
|
@@ -227,6 +230,8 @@ function ToastList() {
|
|
|
227
230
|
})
|
|
228
231
|
}
|
|
229
232
|
|
|
233
|
+
/** How anything under a `ToastProvider` raises a toast (`showToast`) or takes
|
|
234
|
+
* one away (`dismissToast`). Throws outside a provider. */
|
|
230
235
|
export function useToast(): ToastValue {
|
|
231
236
|
const ctx = useContext(ToastContext)
|
|
232
237
|
if (!ctx) throw new Error('useToast must be used within ToastProvider')
|
package/src/Toolbar.tsx
CHANGED
|
@@ -104,6 +104,8 @@ export interface ToolbarButtonProps extends IconButtonProps {
|
|
|
104
104
|
ref?: Ref<HTMLButtonElement>
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/** An `IconButton` that is an item of a `Toolbar`: the arrow keys reach it, and
|
|
108
|
+
* a disabled one stays in the walk so its reason can still be read. */
|
|
107
109
|
export function ToolbarButton({ ref, disabled, disabledReason, ...props }: ToolbarButtonProps) {
|
|
108
110
|
return (
|
|
109
111
|
<BaseToolbar.Button
|
|
@@ -137,6 +139,8 @@ export function ToolbarButton({ ref, disabled, disabledReason, ...props }: Toolb
|
|
|
137
139
|
*/
|
|
138
140
|
export type ToolbarInputProps = TextInputProps
|
|
139
141
|
|
|
142
|
+
/** A `TextInput` that is an item of a `Toolbar`, so the arrow keys walk into the
|
|
143
|
+
* field and out of it again. */
|
|
140
144
|
export function ToolbarInput({ size, ...props }: ToolbarInputProps) {
|
|
141
145
|
// `size` is TextInput's own (default or small), not the native attribute
|
|
142
146
|
// Base UI's part would take, so it goes to the field it draws.
|
package/src/Tooltip.tsx
CHANGED
|
@@ -146,6 +146,10 @@ function TooltipSurface({ label, shortcut, placement }: { label: string; shortcu
|
|
|
146
146
|
)
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/** A tooltip on something that cannot be its own trigger — a span, a `Chip`,
|
|
150
|
+
* an icon. It wraps what it is given in an element that carries the handlers.
|
|
151
|
+
* A control that can be the trigger does it itself: `IconButton` takes a
|
|
152
|
+
* `tooltip` prop, and `Button` shows a `disabledReason` the same way. */
|
|
149
153
|
export function WithTooltip({ label, shortcut, placement = 'top', wrapperClassName, children }: WithTooltipProps) {
|
|
150
154
|
return (
|
|
151
155
|
<BaseTooltip.Root disableHoverablePopup>
|