@estiva-app/ui 0.22.0 → 0.24.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/AppShell.d.ts.map +1 -1
- package/dist/AttachmentCard.d.ts.map +1 -1
- package/dist/Banner.d.ts +20 -2
- package/dist/Banner.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/Form.d.ts.map +1 -1
- package/dist/IconButton.d.ts +9 -1
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/PreviewCard.d.ts +9 -2
- package/dist/PreviewCard.d.ts.map +1 -1
- package/dist/SectionHeader.d.ts +7 -1
- package/dist/SectionHeader.d.ts.map +1 -1
- package/dist/Select.d.ts +11 -3
- package/dist/Select.d.ts.map +1 -1
- package/dist/Tooltip.d.ts +13 -1
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/eslint/index.js +2 -1
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-restyled-part.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 +196 -156
- package/dist/index.js.map +4 -4
- package/dist/registry/app-DQI6WLHR.js +10 -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 +68 -2
- package/dist/registry/build.d.ts.map +1 -1
- package/dist/registry/{chunk-MRSBS5OP.js → chunk-E4JNV7PC.js} +68 -12
- package/dist/registry/chunk-E4JNV7PC.js.map +7 -0
- package/dist/registry/{chunk-QDYGB3QN.js → chunk-NJN4MQAM.js} +60 -15
- package/dist/registry/chunk-NJN4MQAM.js.map +7 -0
- package/dist/registry/{chunk-IJNCYVH4.js → chunk-W2B2G7OE.js} +34 -9
- 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.js +81 -26
- package/dist/registry/cli.js.map +3 -3
- package/dist/registry/find.d.ts +12 -1
- package/dist/registry/find.d.ts.map +1 -1
- package/dist/registry/index.d.ts +5 -2
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +11 -3
- package/dist/registry/schema.d.ts +83 -6
- package/dist/registry/schema.d.ts.map +1 -1
- package/package.json +2 -2
- package/registry.json +283 -95
- package/src/AppShell.mdx +10 -0
- package/src/AppShell.test.tsx +16 -0
- package/src/AppShell.tsx +4 -1
- package/src/AttachmentCard.mdx +9 -0
- package/src/AttachmentCard.test.tsx +10 -0
- package/src/AttachmentCard.tsx +8 -5
- package/src/Avatar.mdx +13 -3
- package/src/AvatarGroup.mdx +4 -0
- package/src/Banner.mdx +30 -7
- package/src/Banner.stories.tsx +22 -0
- package/src/Banner.test.tsx +35 -0
- package/src/Banner.tsx +34 -9
- package/src/Breadcrumb.mdx +10 -0
- package/src/Button.mdx +14 -3
- package/src/Card.mdx +9 -0
- package/src/Checkbox.mdx +10 -0
- package/src/Chip.mdx +4 -0
- package/src/ChipInput.mdx +12 -0
- package/src/CollapsibleSection.mdx +9 -0
- package/src/CommandPalette.mdx +14 -2
- package/src/ConfirmDialog.mdx +11 -0
- package/src/ContainerHeader.mdx +60 -0
- package/src/ContainerHeader.stories.tsx +62 -0
- package/src/ContainerHeader.test.tsx +47 -0
- package/src/ContainerHeader.tsx +45 -0
- package/src/DialogShell.mdx +14 -0
- package/src/Divider.mdx +9 -1
- package/src/EditableText.mdx +11 -0
- package/src/EmptyState.mdx +27 -4
- package/src/EmptyState.stories.tsx +6 -1
- package/src/EmptyState.test.tsx +18 -1
- package/src/EmptyState.tsx +14 -1
- package/src/Field.mdx +10 -1
- package/src/FieldLine.mdx +9 -1
- package/src/FilePicker.mdx +8 -0
- package/src/Form.mdx +10 -0
- package/src/Form.test.tsx +26 -0
- package/src/Form.tsx +7 -0
- package/src/IconButton.mdx +16 -2
- package/src/IconButton.stories.tsx +3 -0
- package/src/IconButton.test.tsx +25 -0
- package/src/IconButton.tsx +61 -46
- package/src/IdentityMenu.mdx +9 -0
- package/src/InlineChip.mdx +8 -0
- package/src/Kbd.mdx +4 -0
- package/src/Link.mdx +10 -0
- package/src/Menu.mdx +16 -1
- package/src/MenuItem.mdx +12 -2
- package/src/NavItem.mdx +8 -0
- package/src/Person.mdx +4 -0
- package/src/PersonTrigger.mdx +9 -0
- package/src/Popover.mdx +21 -0
- package/src/PreviewCard.mdx +19 -4
- package/src/PreviewCard.tsx +11 -4
- package/src/ProgressBar.mdx +8 -0
- package/src/Property.mdx +4 -0
- package/src/Rail.mdx +9 -1
- package/src/RailItem.mdx +8 -0
- package/src/Reaction.mdx +9 -0
- package/src/ReactionPicker.mdx +8 -0
- package/src/ScrollArea.mdx +13 -2
- package/src/SearchInput.mdx +9 -0
- package/src/SectionHeader.mdx +13 -0
- package/src/SectionHeader.stories.tsx +9 -0
- package/src/SectionHeader.test.tsx +9 -0
- package/src/SectionHeader.tsx +8 -2
- package/src/SectionLabel.mdx +4 -0
- package/src/Select.mdx +21 -2
- package/src/Select.stories.tsx +4 -1
- package/src/Select.test.tsx +17 -0
- package/src/Select.tsx +34 -13
- package/src/Sidebar.mdx +8 -0
- package/src/Skeleton.mdx +4 -0
- package/src/Tabs.mdx +12 -1
- package/src/TextInput.mdx +8 -0
- package/src/Textarea.mdx +8 -0
- package/src/Toast.mdx +12 -1
- package/src/Toolbar.mdx +11 -0
- package/src/Tooltip.mdx +26 -2
- package/src/Tooltip.stories.tsx +26 -0
- package/src/Tooltip.test.tsx +29 -0
- package/src/Tooltip.tsx +17 -4
- package/src/TopBar.mdx +4 -0
- package/src/eslint/no-rebuilt-behaviour.ts +1 -1
- package/src/eslint/no-restyled-part.ts +1 -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/pages.test.ts +142 -0
- package/src/registry/app.test.ts +562 -0
- package/src/registry/app.ts +854 -0
- package/src/registry/build.ts +114 -25
- package/src/registry/cli.ts +105 -42
- package/src/registry/find.ts +45 -8
- package/src/registry/index.ts +8 -1
- package/src/registry/registry.test.ts +5 -5
- package/src/registry/schema.ts +163 -12
- package/tailwind-preset.js +22 -1
- package/dist/gates/chunk-AUXD4GCY.js.map +0 -7
- package/dist/gates/chunk-ZGJ2J5NU.js.map +0 -7
- package/dist/registry/build-GOVLABI6.js +0 -13
- package/dist/registry/chunk-IJNCYVH4.js.map +0 -7
- package/dist/registry/chunk-MRSBS5OP.js.map +0 -7
- package/dist/registry/chunk-QDYGB3QN.js.map +0 -7
- /package/dist/registry/{build-GOVLABI6.js.map → app-DQI6WLHR.js.map} +0 -0
|
@@ -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/DialogShell.mdx
CHANGED
|
@@ -28,6 +28,7 @@ footer for the buttons. A dialog is just what goes in the three slots.
|
|
|
28
28
|
dialog rebuilt by hand, which is the one thing this bullet exists to stop.
|
|
29
29
|
Its canvases and its nine tests are on that page.
|
|
30
30
|
- Telling someone what happened → **Toast**.
|
|
31
|
+
- Searching for something and acting on it → **CommandPalette**.
|
|
31
32
|
|
|
32
33
|
## How
|
|
33
34
|
|
|
@@ -73,6 +74,19 @@ import { DialogShell, Button } from '@estiva-app/ui'
|
|
|
73
74
|
stops closing it (Katerina, D20). Escape and the ✕ still do.
|
|
74
75
|
`ConfirmDialog` sets it; a form or a roster should not.
|
|
75
76
|
|
|
77
|
+
## What it owns
|
|
78
|
+
|
|
79
|
+
It does these by itself. Write none of them around it.
|
|
80
|
+
|
|
81
|
+
| It does | So you never write | Refused when written by hand |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| Floats on top of the page | a portal, or a `z-index` to win | ✓ |
|
|
84
|
+
| Closes on a press outside, except with `alert` | a click listener on the page | ✓ |
|
|
85
|
+
| Takes its keys by itself: Escape closes it | a key listener on the page | ✓ |
|
|
86
|
+
| Holds focus inside while open, and gives it back to whatever opened it | focus code, or a Tab handler | ✓ |
|
|
87
|
+
| Stops the page behind it scrolling | `overflow: hidden` on the page | ✓ |
|
|
88
|
+
| Says what it is to assistive technology: a dialog | `role="dialog"` | ✓ |
|
|
89
|
+
|
|
76
90
|
## Keys
|
|
77
91
|
|
|
78
92
|
| Input | What happens |
|
package/src/Divider.mdx
CHANGED
|
@@ -16,7 +16,7 @@ orientation for assistive tech.
|
|
|
16
16
|
|
|
17
17
|
- Between groups in a **Menu** (`className="my-1"` gives it its breathing
|
|
18
18
|
room there).
|
|
19
|
-
- Between sections of a panel or
|
|
19
|
+
- Between sections of a panel, a rail or a card.
|
|
20
20
|
- Vertical: between clusters in a toolbar or a dense row.
|
|
21
21
|
|
|
22
22
|
<Canvas of={DividerStories.Vertical} />
|
|
@@ -49,6 +49,14 @@ import { Divider } from '@estiva-app/ui'
|
|
|
49
49
|
- **Inside a `Menu` or a `Popover` it has no inset**: the hairline runs the
|
|
50
50
|
width of the rows. The panel tells it so; nothing is passed in.
|
|
51
51
|
|
|
52
|
+
## What it owns
|
|
53
|
+
|
|
54
|
+
It does these by itself. Write none of them around it.
|
|
55
|
+
|
|
56
|
+
| It does | So you never write | Refused when written by hand |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| Says what it is to assistive technology: a separator | `role="separator"` | ✓ |
|
|
59
|
+
|
|
52
60
|
## Props
|
|
53
61
|
|
|
54
62
|
<Controls of={DividerStories.Default} />
|
package/src/EditableText.mdx
CHANGED
|
@@ -23,6 +23,8 @@ edit is never silently lost.
|
|
|
23
23
|
- A field in a form or dialog → **TextInput** / **Textarea** in a
|
|
24
24
|
**Field**.
|
|
25
25
|
- Composing something new → the field, not an edit-in-place of nothing.
|
|
26
|
+
- An edit that publishes something, like a message → **Textarea** with Save
|
|
27
|
+
and Cancel. EditableText saves when you leave it.
|
|
26
28
|
|
|
27
29
|
## How
|
|
28
30
|
|
|
@@ -55,6 +57,15 @@ import { EditableText } from '@estiva-app/ui'
|
|
|
55
57
|
the page's font). `className` places it — `-mx-2` to line its text up with
|
|
56
58
|
a header's inset — and a size passed there is refused by the lint (UIG-9).
|
|
57
59
|
|
|
60
|
+
## What it owns
|
|
61
|
+
|
|
62
|
+
It does these by itself. Write none of them around it.
|
|
63
|
+
|
|
64
|
+
| It does | So you never write | Refused when written by hand |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| Opens its field on a click, with the value selected | an edit mode of your own | |
|
|
67
|
+
| Enter and leaving the field save; Escape cancels and puts the value back (`multiline`: Shift+Enter is a new line) | key or blur handlers | |
|
|
68
|
+
|
|
58
69
|
## Keys
|
|
59
70
|
|
|
60
71
|
| Input | What happens |
|
package/src/EmptyState.mdx
CHANGED
|
@@ -36,10 +36,12 @@ need a height from the caller (`className="h-full"`).
|
|
|
36
36
|
|
|
37
37
|
## When not
|
|
38
38
|
|
|
39
|
-
- Data is still on its way → **Skeleton**; empty and loading are
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
- Data is still on its way → **Skeleton**; empty and loading are different
|
|
40
|
+
truths. When the shape of what arrives is unknown, an EmptyState that says so
|
|
41
|
+
— "Opening…" — is fine.
|
|
42
|
+
- Something went wrong → say what failed, in words. An EmptyState can carry
|
|
43
|
+
it, in its usual look — never red. An empty state that hides a failure
|
|
44
|
+
misleads.
|
|
43
45
|
|
|
44
46
|
## How
|
|
45
47
|
|
|
@@ -53,6 +55,18 @@ import { EmptyState } from '@estiva-app/ui'
|
|
|
53
55
|
<EmptyState scope="section" message="No comments yet." />
|
|
54
56
|
```
|
|
55
57
|
|
|
58
|
+
**When there is one thing to do about it, give it as `action`.** It is drawn
|
|
59
|
+
16px under the line as the package's Button, outlined — the same look wherever
|
|
60
|
+
it appears (Katerina, 2026-09-18). Never put a Button beside an EmptyState in a
|
|
61
|
+
box of your own: the box places it, and no two boxes place it alike. A `page`
|
|
62
|
+
only; a section's line takes no action.
|
|
63
|
+
|
|
64
|
+
<Canvas of={EmptyStateStories.WithAction} />
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
<EmptyState icon={<IconMessagePlus size={16} stroke={1.5} />} message="No topics yet." action={{ label: 'New topic', onClick: openNewTopic }} />
|
|
68
|
+
```
|
|
69
|
+
|
|
56
70
|
**Put a section's empty state inside the box its rows live in, and give it no
|
|
57
71
|
padding.** Write the box once; inside it goes either the rows or the empty
|
|
58
72
|
state. The box's padding then places both, and the line starts where the first
|
|
@@ -71,6 +85,15 @@ The message is the caller's — a shared component has no words of its own
|
|
|
71
85
|
for what is missing. A hand-written "Nothing here" paragraph or span in a
|
|
72
86
|
section is the `section` manner, and becomes it.
|
|
73
87
|
|
|
88
|
+
## What it owns
|
|
89
|
+
|
|
90
|
+
It does these by itself. Write none of them around it.
|
|
91
|
+
|
|
92
|
+
| It does | So you never write | Refused when written by hand |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| As a `page`, fills the room it is given and centres itself in it | centring, or padding around it | |
|
|
95
|
+
| Draws its one action, an outlined Button, under the line when you pass `action` | a button of your own under the line | |
|
|
96
|
+
|
|
74
97
|
## Props
|
|
75
98
|
|
|
76
99
|
<Controls of={EmptyStateStories.Page} />
|
|
@@ -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/Field.mdx
CHANGED
|
@@ -19,7 +19,7 @@ token; `required` marks it with the error-coloured asterisk.
|
|
|
19
19
|
|
|
20
20
|
- Displaying a label–value pair → **Property**; Field is for editing.
|
|
21
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 → **
|
|
22
|
+
with Save beside it, a row of action controls → **FieldLine**, the same
|
|
23
23
|
small line on its own.
|
|
24
24
|
|
|
25
25
|
## How
|
|
@@ -55,6 +55,15 @@ import { Field, TextInput } from '@estiva-app/ui'
|
|
|
55
55
|
that says so already is left alone, and a `children` of several elements
|
|
56
56
|
keeps the asterisk and owes its own.
|
|
57
57
|
|
|
58
|
+
## What it owns
|
|
59
|
+
|
|
60
|
+
It does these by itself. Write none of them around it.
|
|
61
|
+
|
|
62
|
+
| It does | So you never write | Refused when written by hand |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| Ties the label, the description and the error to the control inside it | `aria-label`, an `id` / `htmlFor` pair, or `aria-describedby` | |
|
|
65
|
+
| Marks the control invalid when it has an `error`, and announces it | `aria-invalid` | |
|
|
66
|
+
|
|
58
67
|
## Props
|
|
59
68
|
|
|
60
69
|
<Controls of={FieldStories.Default} />
|
package/src/FieldLine.mdx
CHANGED
|
@@ -32,7 +32,7 @@ import { FieldLine } from '@estiva-app/ui'
|
|
|
32
32
|
|
|
33
33
|
<div className="flex flex-col gap-1.5">
|
|
34
34
|
<div className="flex items-center gap-2">
|
|
35
|
-
<TextInput value={draft} onChange={
|
|
35
|
+
<TextInput value={draft} onChange={(e) => setDraft(e.target.value)} />
|
|
36
36
|
<Button variant="primary" size="small">Save</Button>
|
|
37
37
|
</div>
|
|
38
38
|
{outcome && <FieldLine tone={outcome.kind}>{outcome.message}</FieldLine>}
|
|
@@ -52,6 +52,14 @@ import { FieldLine } from '@estiva-app/ui'
|
|
|
52
52
|
- The three tones are the same two classes Field draws, from one map in
|
|
53
53
|
`Field.tsx`, so they cannot drift.
|
|
54
54
|
|
|
55
|
+
## What it owns
|
|
56
|
+
|
|
57
|
+
It does these by itself. Write none of them around it.
|
|
58
|
+
|
|
59
|
+
| It does | So you never write | Refused when written by hand |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| Says what it is to assistive technology: an alert for an error, a status for the rest | `role="alert"` or `role="status"` | ✓ |
|
|
62
|
+
|
|
55
63
|
## Props
|
|
56
64
|
|
|
57
65
|
<Controls of={FieldLineStories.Helper} />
|
package/src/FilePicker.mdx
CHANGED
|
@@ -44,6 +44,14 @@ const picker = useRef<HTMLInputElement>(null)
|
|
|
44
44
|
nothing to reset afterwards.
|
|
45
45
|
- `data-*` attributes pass on to the input, for a test to find it.
|
|
46
46
|
|
|
47
|
+
## What it owns
|
|
48
|
+
|
|
49
|
+
It does these by itself. Write none of them around it.
|
|
50
|
+
|
|
51
|
+
| It does | So you never write | Refused when written by hand |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| Opens the system's file picker from its button, and keeps the real file input out of Tab and screen readers | a hidden `<input type="file">` | |
|
|
54
|
+
|
|
47
55
|
## Props
|
|
48
56
|
|
|
49
57
|
<Controls of={FilePickerStories.Default} />
|
package/src/Form.mdx
CHANGED
|
@@ -58,6 +58,16 @@ import { Button, Field, Form, TextInput } from '@estiva-app/ui'
|
|
|
58
58
|
- `className` places the fields: the form draws no box, and neither does the
|
|
59
59
|
`<fieldset>` inside it.
|
|
60
60
|
|
|
61
|
+
## What it owns
|
|
62
|
+
|
|
63
|
+
It does these by itself. Write none of them around it.
|
|
64
|
+
|
|
65
|
+
| It does | So you never write | Refused when written by hand |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| Sends on Enter in a one-line field, and on Ctrl+Enter anywhere inside | a key handler that submits | |
|
|
68
|
+
| Leaves Enter alone where it means something else: a Textarea, a list, a people picker | exceptions of your own | |
|
|
69
|
+
| Does nothing while it is sending: no second send, and nothing inside takes focus | a busy flag on every control | |
|
|
70
|
+
|
|
61
71
|
## Keys
|
|
62
72
|
|
|
63
73
|
The same in every form (Katerina, 16 September).
|
package/src/Form.test.tsx
CHANGED
|
@@ -18,6 +18,7 @@ import { Checkbox } from './Checkbox'
|
|
|
18
18
|
import { IconButton } from './IconButton'
|
|
19
19
|
import { Field } from './Field'
|
|
20
20
|
import { Form } from './Form'
|
|
21
|
+
import { Popover } from './Popover'
|
|
21
22
|
import { TextInput } from './TextInput'
|
|
22
23
|
import { Textarea } from './Textarea'
|
|
23
24
|
|
|
@@ -46,6 +47,31 @@ describe('Form', () => {
|
|
|
46
47
|
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
47
48
|
})
|
|
48
49
|
|
|
50
|
+
it('a Form in a Popover inside a Form sends only itself (C1)', async () => {
|
|
51
|
+
// A link field in a pop-up over a composer: Enter there adds the link, and
|
|
52
|
+
// must not also send the message around it. React carries the submit
|
|
53
|
+
// through the Popover's portal to the outer form.
|
|
54
|
+
const user = userEvent.setup()
|
|
55
|
+
const outer = vi.fn()
|
|
56
|
+
const inner = vi.fn()
|
|
57
|
+
render(
|
|
58
|
+
<Form onSubmit={outer}>
|
|
59
|
+
<TextInput aria-label="Message" />
|
|
60
|
+
<Popover trigger={<Button>Link</Button>} open ariaLabel="Link">
|
|
61
|
+
<Form onSubmit={inner}>
|
|
62
|
+
<TextInput aria-label="Address" />
|
|
63
|
+
</Form>
|
|
64
|
+
</Popover>
|
|
65
|
+
</Form>,
|
|
66
|
+
)
|
|
67
|
+
await user.type(screen.getByRole('textbox', { name: 'Address' }), 'example.com{Enter}')
|
|
68
|
+
expect(inner).toHaveBeenCalledTimes(1)
|
|
69
|
+
expect(outer).not.toHaveBeenCalled()
|
|
70
|
+
await user.type(screen.getByRole('textbox', { name: 'Message' }), 'hi{Enter}')
|
|
71
|
+
expect(outer).toHaveBeenCalledTimes(1)
|
|
72
|
+
expect(inner).toHaveBeenCalledTimes(1)
|
|
73
|
+
})
|
|
74
|
+
|
|
49
75
|
it('sends from its submit button', async () => {
|
|
50
76
|
const user = userEvent.setup()
|
|
51
77
|
const onSubmit = vi.fn()
|
package/src/Form.tsx
CHANGED
|
@@ -160,6 +160,13 @@ export function Form({ onSubmit, busy: ownBusy = false, enterSends = true, class
|
|
|
160
160
|
}}
|
|
161
161
|
onKeyDown={onKeyDown}
|
|
162
162
|
onSubmit={(event) => {
|
|
163
|
+
/*
|
|
164
|
+
A form's own send only. React carries a submit up the component tree,
|
|
165
|
+
through a portal too, so a Form in a Popover inside this Form sent both:
|
|
166
|
+
Enter in a link field sent the whole message around it (UIG-14, C1,
|
|
167
|
+
Katerina 19 September). The inner Form has already handled its own.
|
|
168
|
+
*/
|
|
169
|
+
if (event.target !== event.currentTarget) return
|
|
163
170
|
event.preventDefault()
|
|
164
171
|
if (busyNow.current) return
|
|
165
172
|
void onSubmit()
|
package/src/IconButton.mdx
CHANGED
|
@@ -22,8 +22,8 @@ variants. For the actions a surface repeats too often to label every time.
|
|
|
22
22
|
- The action deserves a visible label → **Button** (words are cheaper than
|
|
23
23
|
a guessed icon).
|
|
24
24
|
- Inside a menu → **MenuItem** with `leading`.
|
|
25
|
-
- A strip of these
|
|
26
|
-
persist state, see **Checkbox**.
|
|
25
|
+
- A strip of these acting on one thing → **Toolbar**. The moment rows toggle
|
|
26
|
+
and persist state, see **Checkbox**.
|
|
27
27
|
|
|
28
28
|
## How
|
|
29
29
|
|
|
@@ -54,11 +54,25 @@ import { IconPencil } from '@tabler/icons-react'
|
|
|
54
54
|
- `disabledReason="Read only"` disables it, keeps it reachable by Tab,
|
|
55
55
|
and shows the reason in place of the tooltip — **on keyboard focus as
|
|
56
56
|
well as on hover**.
|
|
57
|
+
- **`href`** makes it a link that looks like the button: an anchor, so the
|
|
58
|
+
address stays real for a modified click or a new tab. A router app passes
|
|
59
|
+
`onClick`, prevents the default and navigates. While it cannot be used it
|
|
60
|
+
is the button — a link cannot be disabled.
|
|
57
61
|
- `tooltip` costs no wrapper: the button is the tooltip's trigger, so
|
|
58
62
|
this component's root is the `<button>` whether it carries one or not,
|
|
59
63
|
and both sit at the same height. A `Dialog.Close` or a `Menu.Trigger`
|
|
60
64
|
can be an IconButton that has a tooltip.
|
|
61
65
|
|
|
66
|
+
## What it owns
|
|
67
|
+
|
|
68
|
+
It does these by itself. Write none of them around it.
|
|
69
|
+
|
|
70
|
+
| It does | So you never write | Refused when written by hand |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| Says what it is to assistive technology: a button | `role="button"` | ✓ |
|
|
73
|
+
| Is reachable with Tab, also when it is disabled with a reason | a `tabIndex` | ✓ |
|
|
74
|
+
| Presses on Enter and Space, and does nothing while disabled | a key handler | |
|
|
75
|
+
|
|
62
76
|
## Keys
|
|
63
77
|
|
|
64
78
|
| Key | Does |
|
|
@@ -26,6 +26,9 @@ export const WithTooltip: Story = { args: { tooltip: 'Settings', tooltipPlacemen
|
|
|
26
26
|
/** Disabled with its reason in place of the tooltip; Tab still reaches it. */
|
|
27
27
|
export const WithAReason: Story = { args: { tooltip: 'Settings', disabledReason: 'Sign in to change settings' } }
|
|
28
28
|
|
|
29
|
+
/** `href`: a link that looks like the button — the same box, an anchor underneath. */
|
|
30
|
+
export const AsALink: Story = { args: { href: '#settings', tooltip: 'Settings' } }
|
|
31
|
+
|
|
29
32
|
/** Every variant × enabled/disabled. */
|
|
30
33
|
export const AllVariants: Story = {
|
|
31
34
|
// axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
|
package/src/IconButton.test.tsx
CHANGED
|
@@ -28,6 +28,31 @@ describe('IconButton', () => {
|
|
|
28
28
|
expect(onClick).toHaveBeenCalledTimes(1)
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
+
it('href: a link that looks like the button, with its tooltip', async () => {
|
|
32
|
+
const user = userEvent.setup()
|
|
33
|
+
render(
|
|
34
|
+
<IconButton href="/documents/12" aria-label="Open" tooltip="Open">
|
|
35
|
+
{icon}
|
|
36
|
+
</IconButton>,
|
|
37
|
+
)
|
|
38
|
+
expect(screen.queryByRole('button')).toBeNull()
|
|
39
|
+
const link = screen.getByRole('link', { name: 'Open' })
|
|
40
|
+
expect(link.getAttribute('href')).toBe('/documents/12')
|
|
41
|
+
expect(link.className).toContain('rounded-lg')
|
|
42
|
+
await user.hover(link)
|
|
43
|
+
expect((await screen.findByRole('tooltip')).textContent).toBe('Open')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('href while it cannot be used: the button, since a link cannot be disabled', () => {
|
|
47
|
+
render(
|
|
48
|
+
<IconButton href="/documents/12" aria-label="Open" disabledReason="Read only">
|
|
49
|
+
{icon}
|
|
50
|
+
</IconButton>,
|
|
51
|
+
)
|
|
52
|
+
expect(screen.queryByRole('link')).toBeNull()
|
|
53
|
+
expect(screen.getByRole('button', { name: 'Open' }).getAttribute('aria-disabled')).toBe('true')
|
|
54
|
+
})
|
|
55
|
+
|
|
31
56
|
it('shows the tooltip on hover, with its shortcut', async () => {
|
|
32
57
|
const user = userEvent.setup()
|
|
33
58
|
render(
|