@estiva-app/ui 0.10.1 → 0.12.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/dist/AppShell.d.ts.map +1 -1
- package/dist/Breadcrumb.d.ts +7 -1
- package/dist/Breadcrumb.d.ts.map +1 -1
- package/dist/Chip.d.ts.map +1 -1
- package/dist/CollapsibleSection.d.ts +42 -0
- package/dist/CollapsibleSection.d.ts.map +1 -0
- package/dist/Divider.d.ts +15 -1
- package/dist/Divider.d.ts.map +1 -1
- package/dist/EmptyState.d.ts +19 -5
- package/dist/EmptyState.d.ts.map +1 -1
- package/dist/Menu.d.ts.map +1 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/Popover.d.ts.map +1 -1
- package/dist/PreviewCard.d.ts.map +1 -1
- package/dist/Reaction.d.ts.map +1 -1
- package/dist/ScrollArea.d.ts +46 -0
- package/dist/ScrollArea.d.ts.map +1 -0
- package/dist/SectionHeader.d.ts +31 -10
- package/dist/SectionHeader.d.ts.map +1 -1
- package/dist/Select.d.ts.map +1 -1
- package/dist/Sidebar.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +375 -266
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/AppShell.tsx +5 -0
- package/src/Breadcrumb.mdx +6 -0
- package/src/Breadcrumb.stories.tsx +12 -0
- package/src/Breadcrumb.test.tsx +10 -0
- package/src/Breadcrumb.tsx +17 -2
- package/src/Chip.tsx +10 -2
- package/src/CollapsibleSection.mdx +72 -0
- package/src/CollapsibleSection.stories.tsx +38 -0
- package/src/CollapsibleSection.test.tsx +81 -0
- package/src/CollapsibleSection.tsx +92 -0
- package/src/Divider.mdx +9 -0
- package/src/Divider.stories.tsx +25 -0
- package/src/Divider.test.tsx +26 -0
- package/src/Divider.tsx +30 -1
- package/src/EmptyState.mdx +30 -11
- package/src/EmptyState.stories.tsx +7 -3
- package/src/EmptyState.test.tsx +39 -0
- package/src/EmptyState.tsx +23 -6
- package/src/Menu.tsx +11 -2
- package/src/NavItem.tsx +5 -1
- package/src/Popover.tsx +6 -2
- package/src/PreviewCard.tsx +5 -2
- package/src/Reaction.tsx +6 -1
- package/src/ScrollArea.mdx +65 -0
- package/src/ScrollArea.stories.tsx +104 -0
- package/src/ScrollArea.test.tsx +65 -0
- package/src/ScrollArea.tsx +94 -0
- package/src/SectionHeader.mdx +26 -17
- package/src/SectionHeader.stories.tsx +4 -35
- package/src/SectionHeader.test.tsx +40 -0
- package/src/SectionHeader.tsx +70 -39
- package/src/Select.tsx +8 -1
- package/src/Sidebar.mdx +14 -10
- package/src/Sidebar.stories.tsx +17 -15
- package/src/Sidebar.tsx +8 -5
- package/src/index.ts +2 -0
- package/stories/Choosing.mdx +2 -1
package/package.json
CHANGED
package/src/AppShell.tsx
CHANGED
|
@@ -76,6 +76,11 @@ export function AppShell({ variant = 'solid', menu, logo, search, identity, bann
|
|
|
76
76
|
{nav}
|
|
77
77
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
78
78
|
{banner}
|
|
79
|
+
{/* Not a ScrollArea: the apps' pages scroll inside themselves (Ship's
|
|
80
|
+
detail columns are `h-full` grids with their own regions), and a
|
|
81
|
+
region here kept a bar on Ship's page for overflow that was not
|
|
82
|
+
there (2026-09-09). A page that does scroll here gets its region
|
|
83
|
+
where it scrolls. */}
|
|
79
84
|
<main className="min-w-0 flex-1 overflow-y-auto">{children}</main>
|
|
80
85
|
</div>
|
|
81
86
|
</div>
|
package/src/Breadcrumb.mdx
CHANGED
|
@@ -22,6 +22,12 @@ between them, and a tooltip on any crumb that has had to truncate.
|
|
|
22
22
|
|
|
23
23
|
<Canvas of={BreadcrumbStories.LongName} />
|
|
24
24
|
|
|
25
|
+
- `icon` draws a 16px icon before a crumb — what kind of place it is, so
|
|
26
|
+
a container's name is not read as an item's. Decorative: the trail is
|
|
27
|
+
still read as its labels.
|
|
28
|
+
|
|
29
|
+
<Canvas of={BreadcrumbStories.WithAnIcon} />
|
|
30
|
+
|
|
25
31
|
## When not
|
|
26
32
|
|
|
27
33
|
- Switching between sibling views of one thing → **Tabs**.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconSquareRounded } from '@tabler/icons-react'
|
|
2
3
|
import { Breadcrumb } from './Breadcrumb'
|
|
3
4
|
|
|
4
5
|
/** A trail of places, ending where you are — or on a mono ref. */
|
|
@@ -43,3 +44,14 @@ export const LongName: Story = {
|
|
|
43
44
|
],
|
|
44
45
|
},
|
|
45
46
|
}
|
|
47
|
+
|
|
48
|
+
/** An icon before a crumb says what kind of place it is — a container, not an item. */
|
|
49
|
+
export const WithAnIcon: Story = {
|
|
50
|
+
args: {
|
|
51
|
+
items: [
|
|
52
|
+
{ label: 'Documents', href: '#' },
|
|
53
|
+
{ label: 'Quarterly plan', href: '#', icon: <IconSquareRounded size={16} stroke={1.5} /> },
|
|
54
|
+
{ label: 'DOC-12', mono: true },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
}
|
package/src/Breadcrumb.test.tsx
CHANGED
|
@@ -42,3 +42,13 @@ describe('Breadcrumb', () => {
|
|
|
42
42
|
expect(screen.queryByText('/', { ignore: '[aria-hidden="true"]' })).toBeNull()
|
|
43
43
|
})
|
|
44
44
|
})
|
|
45
|
+
|
|
46
|
+
describe('Breadcrumb with an icon', () => {
|
|
47
|
+
it('draws the icon before the crumb, and the trail still reads as its labels', () => {
|
|
48
|
+
render(<Breadcrumb items={[{ label: 'Documents', href: '#' }, { label: 'Quarterly plan', icon: <i data-testid="kind" /> }]} />)
|
|
49
|
+
const icon = screen.getByTestId('kind').parentElement!
|
|
50
|
+
expect(icon.getAttribute('aria-hidden')).toBe('true')
|
|
51
|
+
expect(icon.nextElementSibling).toBe(screen.getByText('Quarterly plan'))
|
|
52
|
+
expect(screen.getByRole('navigation', { name: 'Breadcrumb' }).textContent).toBe('Documents/Quarterly plan')
|
|
53
|
+
})
|
|
54
|
+
})
|
package/src/Breadcrumb.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Fragment, useCallback, useLayoutEffect, useRef, useState, type MouseEventHandler } from 'react'
|
|
1
|
+
import { Fragment, useCallback, useLayoutEffect, useRef, useState, type MouseEventHandler, type ReactNode } from 'react'
|
|
2
2
|
import { cn } from './cn'
|
|
3
3
|
import { WithTooltip } from './Tooltip'
|
|
4
4
|
|
|
@@ -29,6 +29,12 @@ export interface Crumb {
|
|
|
29
29
|
onClick?: MouseEventHandler<HTMLAnchorElement>
|
|
30
30
|
/** Set the item in the mono face — a ref, an id. */
|
|
31
31
|
mono?: boolean
|
|
32
|
+
/**
|
|
33
|
+
* 16px, stroke 1.5, before the label — what kind of place this is, so a
|
|
34
|
+
* container's name is not mistaken for an item's (Katerina, 2026-09-09).
|
|
35
|
+
* Decorative: the label says where you are; the icon says what it is.
|
|
36
|
+
*/
|
|
37
|
+
icon?: ReactNode
|
|
32
38
|
/** Quieter — a label that is not a place. */
|
|
33
39
|
muted?: boolean
|
|
34
40
|
}
|
|
@@ -69,13 +75,14 @@ export function Breadcrumb({ items, className }: BreadcrumbProps) {
|
|
|
69
75
|
// The mono size is an arbitrary value (the caption token) because this
|
|
70
76
|
// string goes through cn() and a token size before a colour class is
|
|
71
77
|
// dropped (the tailwind-merge pitfall).
|
|
78
|
+
const tone = item.muted || (last && item.mono) ? 'text-text-muted' : last ? 'text-text-primary' : 'text-text-secondary'
|
|
72
79
|
const text = cn(
|
|
73
80
|
'truncate',
|
|
74
81
|
// A mono crumb is a ref — the identity. It never gives up width to a
|
|
75
82
|
// long name beside it (the LongName story always claimed "the ref
|
|
76
83
|
// stays"; flexbox was squeezing it anyway until this line).
|
|
77
84
|
item.mono && 'shrink-0 font-mono text-[12px] leading-[120%]',
|
|
78
|
-
|
|
85
|
+
tone,
|
|
79
86
|
)
|
|
80
87
|
const setLabelRef = (el: HTMLElement | null) => {
|
|
81
88
|
labelRefs.current[index] = el
|
|
@@ -96,6 +103,14 @@ export function Breadcrumb({ items, className }: BreadcrumbProps) {
|
|
|
96
103
|
/
|
|
97
104
|
</span>
|
|
98
105
|
)}
|
|
106
|
+
{/* Beside the crumb, not inside it: the crumb stays the one element
|
|
107
|
+
that truncates and is measured, and the trail's own gap — 6px —
|
|
108
|
+
is the distance between an icon and its name everywhere here. */}
|
|
109
|
+
{item.icon && (
|
|
110
|
+
<span aria-hidden="true" className={cn('flex shrink-0', tone)}>
|
|
111
|
+
{item.icon}
|
|
112
|
+
</span>
|
|
113
|
+
)}
|
|
99
114
|
{truncated.has(index) ? (
|
|
100
115
|
// `min-w-0 shrink` undoes the wrapper's own shrink-0 — the crumb
|
|
101
116
|
// must keep truncating inside it, or wrapping it would widen the
|
package/src/Chip.tsx
CHANGED
|
@@ -33,10 +33,18 @@ const typeStyles: Record<ChipType, string> = {
|
|
|
33
33
|
|
|
34
34
|
export function Chip({ type = 'neutral', label, leadingIcon, trailingIcon, className }: ChipProps) {
|
|
35
35
|
return (
|
|
36
|
-
<div className={cn('inline-flex items-center justify-center gap-1.5 rounded-full max-h-[20px]
|
|
36
|
+
<div className={cn('inline-flex min-w-0 items-center justify-center gap-1.5 rounded-full max-h-[20px] px-2 py-1', typeStyles[type], className)}>
|
|
37
37
|
{leadingIcon && <span className="flex size-3 shrink-0 items-center justify-center">{leadingIcon}</span>}
|
|
38
38
|
{label && (
|
|
39
|
-
|
|
39
|
+
// A chip given a `max-w-*` cuts a long label with an ellipsis instead
|
|
40
|
+
// of growing past it — Ship's project chip on an issue row
|
|
41
|
+
// (2026-09-09). Clipped sideways only: `overflow-x-clip`, not
|
|
42
|
+
// `truncate`, because `truncate` is `overflow: hidden` on both axes and
|
|
43
|
+
// the label's line box (11px under Signal) is tighter than its glyphs —
|
|
44
|
+
// the screenshot diff caught every descender cut off. `clip` is the
|
|
45
|
+
// one overflow that leaves the other axis visible. A chip with no cap
|
|
46
|
+
// draws exactly as before.
|
|
47
|
+
<span className="min-w-0 overflow-x-clip text-ellipsis whitespace-nowrap text-chip signal:font-mono signal:text-[10px] signal:font-semibold signal:tracking-[0.02em] signal:tabular-nums">{label}</span>
|
|
40
48
|
)}
|
|
41
49
|
{trailingIcon && <span className="flex size-3 shrink-0 items-center justify-center">{trailingIcon}</span>}
|
|
42
50
|
</div>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as CollapsibleSectionStories from './CollapsibleSection.stories'
|
|
3
|
+
import * as SidebarStories from './Sidebar.stories'
|
|
4
|
+
|
|
5
|
+
<Meta of={CollapsibleSectionStories} />
|
|
6
|
+
|
|
7
|
+
# CollapsibleSection
|
|
8
|
+
|
|
9
|
+
A section that opens and closes: a SectionHeader whose title is the
|
|
10
|
+
toggle, and the rows under it, sliding shut and open.
|
|
11
|
+
|
|
12
|
+
<Canvas of={CollapsibleSectionStories.Default} />
|
|
13
|
+
|
|
14
|
+
## When
|
|
15
|
+
|
|
16
|
+
- A group of rows in a sidebar or a panel that the reader may want out of
|
|
17
|
+
the way — projects, folders, starred items.
|
|
18
|
+
- `defaultOpen={false}` for a group that starts folded.
|
|
19
|
+
- `storageKey` when the section should stay how the reader left it across
|
|
20
|
+
reloads: this browser remembers, under that key. The app prefixes the
|
|
21
|
+
key (`ship.sidebar.projects`).
|
|
22
|
+
- `open` + `onOpenChange` when the app owns the state.
|
|
23
|
+
- `actions` for add, sort, filter beside the title — SectionHeader's,
|
|
24
|
+
revealed on hover or focus.
|
|
25
|
+
|
|
26
|
+
In a sidebar — the Sidebar's own Composed canvas, which is where that
|
|
27
|
+
composition lives:
|
|
28
|
+
|
|
29
|
+
<Canvas of={SidebarStories.Composed} />
|
|
30
|
+
|
|
31
|
+
## When not
|
|
32
|
+
|
|
33
|
+
- A heading over rows that never fold → **SectionHeader** without
|
|
34
|
+
`chevron`, or **SectionLabel** in a 32px row.
|
|
35
|
+
- Several panels where opening one closes the others → not here; that is
|
|
36
|
+
an accordion, and no app has asked for one.
|
|
37
|
+
- Content that appears on demand from a control elsewhere → **Popover**.
|
|
38
|
+
|
|
39
|
+
## How
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
import { CollapsibleSection, NavItem } from '@estiva-app/ui'
|
|
43
|
+
|
|
44
|
+
<CollapsibleSection title="Projects" storageKey="app.sidebar.projects" className="mt-2 shrink-0" contentClassName="gap-px">
|
|
45
|
+
<NavItem href="/p/1" label="Item one" />
|
|
46
|
+
<NavItem href="/p/2" label="Item two" />
|
|
47
|
+
</CollapsibleSection>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- On Base UI's `Collapsible`: the state, the title button's
|
|
51
|
+
`aria-expanded` and `aria-controls`, the keys, the panel's height for
|
|
52
|
+
the slide (150ms; none under `prefers-reduced-motion`), and
|
|
53
|
+
`hiddenUntilFound` — the browser's find-in-page can find a row inside a
|
|
54
|
+
closed section and open it. The rows stay in the DOM while closed.
|
|
55
|
+
- `className` is the section's box: `mt-2` between groups, `shrink-0` in
|
|
56
|
+
a scrolling column — the missing-`shrink-0` family. `contentClassName`
|
|
57
|
+
is the box the rows sit in: the gap between them.
|
|
58
|
+
- A closed section stays closed whatever is selected inside it — the
|
|
59
|
+
reader closed it. An app that wants it to spring open owns the state
|
|
60
|
+
with `open`.
|
|
61
|
+
- A browser that blocks storage still gets a working section; it forgets.
|
|
62
|
+
|
|
63
|
+
## Keys
|
|
64
|
+
|
|
65
|
+
| Key | Does |
|
|
66
|
+
|---|---|
|
|
67
|
+
| Enter, Space | on the title: opens or closes |
|
|
68
|
+
| Tab | the title, then each action, then the rows when open |
|
|
69
|
+
|
|
70
|
+
## Props
|
|
71
|
+
|
|
72
|
+
<Controls of={CollapsibleSectionStories.Default} />
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconPlus } from '@tabler/icons-react'
|
|
3
|
+
import { CollapsibleSection } from './CollapsibleSection'
|
|
4
|
+
import { NavItem } from './NavItem'
|
|
5
|
+
|
|
6
|
+
/** A section that opens and closes: the header's title is the toggle, and the rows slide. */
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'Navigation/CollapsibleSection',
|
|
9
|
+
component: CollapsibleSection,
|
|
10
|
+
decorators: [(Story) => <div className="w-[280px]"><Story /></div>],
|
|
11
|
+
args: { title: 'Section', contentClassName: 'gap-px', children: null },
|
|
12
|
+
argTypes: { children: { control: false }, actions: { control: false } },
|
|
13
|
+
render: (args) => (
|
|
14
|
+
<CollapsibleSection {...args}>
|
|
15
|
+
<NavItem href="#" label="Item one" count={7} countLabel="7 open" />
|
|
16
|
+
<NavItem href="#" label="Item two" count={2} countLabel="2 open" />
|
|
17
|
+
<NavItem href="#" label="Item three" />
|
|
18
|
+
</CollapsibleSection>
|
|
19
|
+
),
|
|
20
|
+
} satisfies Meta<typeof CollapsibleSection>
|
|
21
|
+
|
|
22
|
+
export default meta
|
|
23
|
+
type Story = StoryObj<typeof meta>
|
|
24
|
+
|
|
25
|
+
/** Open, as a section is unless told otherwise. Click the title, or focus it and press Enter or Space. */
|
|
26
|
+
export const Default: Story = {}
|
|
27
|
+
|
|
28
|
+
/** Starts closed. Ctrl+F for "Item two" opens it: the rows are hidden until found, not gone. */
|
|
29
|
+
export const Closed: Story = { args: { defaultOpen: false } }
|
|
30
|
+
|
|
31
|
+
/** Actions beside the title, revealed on hover or focus; a click on one never toggles the section. */
|
|
32
|
+
export const WithActions: Story = {
|
|
33
|
+
args: { actions: [{ icon: <IconPlus size={16} stroke={1.5} />, tooltip: 'Add', onClick: () => {} }] },
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Close it and reload the page: still closed. This browser remembers, under the key. */
|
|
37
|
+
export const Remembered: Story = { args: { storageKey: 'estiva-ui.stories.collapsible-section' } }
|
|
38
|
+
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the page claims: the title toggles by click and by key and says its
|
|
4
|
+
* state; the rows are hidden, not removed, while closed; a `storageKey` is
|
|
5
|
+
* read on mount and written on change. The slide is Chrome's to show
|
|
6
|
+
* (measured 2026-09-09).
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
9
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
10
|
+
import userEvent from '@testing-library/user-event'
|
|
11
|
+
import { CollapsibleSection } from './CollapsibleSection'
|
|
12
|
+
|
|
13
|
+
afterEach(cleanup)
|
|
14
|
+
beforeEach(() => window.localStorage.clear())
|
|
15
|
+
|
|
16
|
+
const rows = (
|
|
17
|
+
<>
|
|
18
|
+
<a href="#">Item one</a>
|
|
19
|
+
<a href="#">Item two</a>
|
|
20
|
+
</>
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
const title = () => screen.getByRole('button', { name: 'Section' })
|
|
24
|
+
|
|
25
|
+
describe('CollapsibleSection', () => {
|
|
26
|
+
it('is open by default: the title says so, and the rows are there', () => {
|
|
27
|
+
render(<CollapsibleSection title="Section">{rows}</CollapsibleSection>)
|
|
28
|
+
expect(title().getAttribute('aria-expanded')).toBe('true')
|
|
29
|
+
expect(screen.getByText('Item one').closest('[hidden]')).toBeNull()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('a click on the title closes it, and the rows are hidden rather than gone', async () => {
|
|
33
|
+
render(<CollapsibleSection title="Section">{rows}</CollapsibleSection>)
|
|
34
|
+
await userEvent.click(title())
|
|
35
|
+
expect(title().getAttribute('aria-expanded')).toBe('false')
|
|
36
|
+
expect(screen.getByText('Item one').closest('[hidden]')).not.toBeNull()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('Enter and Space on the title toggle it', async () => {
|
|
40
|
+
render(<CollapsibleSection title="Section">{rows}</CollapsibleSection>)
|
|
41
|
+
title().focus()
|
|
42
|
+
await userEvent.keyboard('{Enter}')
|
|
43
|
+
expect(title().getAttribute('aria-expanded')).toBe('false')
|
|
44
|
+
await userEvent.keyboard(' ')
|
|
45
|
+
expect(title().getAttribute('aria-expanded')).toBe('true')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('starts closed with defaultOpen false', () => {
|
|
49
|
+
render(<CollapsibleSection title="Section" defaultOpen={false}>{rows}</CollapsibleSection>)
|
|
50
|
+
expect(title().getAttribute('aria-expanded')).toBe('false')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('an action beside the title acts without toggling', async () => {
|
|
54
|
+
const add = vi.fn()
|
|
55
|
+
render(
|
|
56
|
+
<CollapsibleSection title="Section" actions={[{ icon: <i />, tooltip: 'Add', onClick: add }]}>
|
|
57
|
+
{rows}
|
|
58
|
+
</CollapsibleSection>,
|
|
59
|
+
)
|
|
60
|
+
await userEvent.click(screen.getByRole('button', { name: 'Add' }))
|
|
61
|
+
expect(add).toHaveBeenCalledTimes(1)
|
|
62
|
+
expect(title().getAttribute('aria-expanded')).toBe('true')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('remembers under storageKey: written on change, read on mount', async () => {
|
|
66
|
+
const { unmount } = render(<CollapsibleSection title="Section" storageKey="test.section">{rows}</CollapsibleSection>)
|
|
67
|
+
await userEvent.click(title())
|
|
68
|
+
expect(window.localStorage.getItem('test.section')).toBe('closed')
|
|
69
|
+
unmount()
|
|
70
|
+
render(<CollapsibleSection title="Section" storageKey="test.section">{rows}</CollapsibleSection>)
|
|
71
|
+
expect(title().getAttribute('aria-expanded')).toBe('false')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('the caller can own the state', async () => {
|
|
75
|
+
const onOpenChange = vi.fn()
|
|
76
|
+
render(<CollapsibleSection title="Section" open={false} onOpenChange={onOpenChange}>{rows}</CollapsibleSection>)
|
|
77
|
+
await userEvent.click(title())
|
|
78
|
+
expect(onOpenChange).toHaveBeenCalledWith(true)
|
|
79
|
+
expect(title().getAttribute('aria-expanded')).toBe('false')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
|
+
import { Collapsible } from '@base-ui/react/collapsible'
|
|
3
|
+
import { cn } from './cn'
|
|
4
|
+
import { SectionHeader, type SectionAction } from './SectionHeader'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A section that opens and closes: a `SectionHeader` whose title is the
|
|
8
|
+
* toggle, and the rows under it, sliding shut and open (Katerina,
|
|
9
|
+
* 2026-09-09, for the sidebars of both apps). On Base UI's `Collapsible`
|
|
10
|
+
* (D6): the state, the title button's `aria-expanded` and `aria-controls`,
|
|
11
|
+
* Enter and Space, the panel's height for the slide, and `hiddenUntilFound`
|
|
12
|
+
* — the browser's find-in-page can find a row inside a closed section and
|
|
13
|
+
* open it, because the rows stay in the DOM while closed.
|
|
14
|
+
*
|
|
15
|
+
* Three ways to hold the state, from least to most work for the caller:
|
|
16
|
+
*
|
|
17
|
+
* - none: `defaultOpen` (open unless told otherwise), kept while mounted;
|
|
18
|
+
* - `storageKey`: the same, and this browser remembers it under that key
|
|
19
|
+
* across reloads — one prop for a sidebar that stays how you left it;
|
|
20
|
+
* - `open` + `onOpenChange`: the caller owns it.
|
|
21
|
+
*
|
|
22
|
+
* A closed section stays closed whatever is selected inside it (her
|
|
23
|
+
* ruling, 2026-09-09) — the reader closed it.
|
|
24
|
+
*/
|
|
25
|
+
export interface CollapsibleSectionProps {
|
|
26
|
+
title: string
|
|
27
|
+
/** Open unless told otherwise. */
|
|
28
|
+
defaultOpen?: boolean
|
|
29
|
+
/** The caller owns the state. */
|
|
30
|
+
open?: boolean
|
|
31
|
+
onOpenChange?: (open: boolean) => void
|
|
32
|
+
/** Remember open or closed in this browser, under this key. The app prefixes it. */
|
|
33
|
+
storageKey?: string
|
|
34
|
+
/** Beside the title, revealed on hover or focus — `SectionHeader`'s. */
|
|
35
|
+
actions?: SectionAction[]
|
|
36
|
+
showActions?: 'hover' | 'always'
|
|
37
|
+
/** The rows. */
|
|
38
|
+
children: ReactNode
|
|
39
|
+
/** On the section's box: `mt-2` between groups, `shrink-0` in a scrolling column. */
|
|
40
|
+
className?: string
|
|
41
|
+
/** On the box the rows sit in: the gap between them. */
|
|
42
|
+
contentClassName?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const OPEN = 'open'
|
|
46
|
+
const CLOSED = 'closed'
|
|
47
|
+
|
|
48
|
+
function readStored(key: string | undefined): boolean | undefined {
|
|
49
|
+
if (!key) return undefined
|
|
50
|
+
try {
|
|
51
|
+
const value = window.localStorage.getItem(key)
|
|
52
|
+
return value === OPEN ? true : value === CLOSED ? false : undefined
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function writeStored(key: string | undefined, open: boolean) {
|
|
59
|
+
if (!key) return
|
|
60
|
+
try {
|
|
61
|
+
window.localStorage.setItem(key, open ? OPEN : CLOSED)
|
|
62
|
+
} catch {
|
|
63
|
+
// A browser that blocks storage still gets a working section; it forgets.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function CollapsibleSection({ title, defaultOpen = true, open: openProp, onOpenChange, storageKey, actions, showActions, children, className, contentClassName }: CollapsibleSectionProps) {
|
|
68
|
+
const [openState, setOpenState] = useState(() => readStored(storageKey) ?? defaultOpen)
|
|
69
|
+
const open = openProp ?? openState
|
|
70
|
+
const setOpen = (next: boolean) => {
|
|
71
|
+
setOpenState(next)
|
|
72
|
+
writeStored(storageKey, next)
|
|
73
|
+
onOpenChange?.(next)
|
|
74
|
+
}
|
|
75
|
+
return (
|
|
76
|
+
<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 />} />
|
|
78
|
+
{/* The slide: Base UI measures the panel and writes its height to a
|
|
79
|
+
variable — `auto` again once the slide ends, so rows that arrive
|
|
80
|
+
later are not clipped — and the panel is 0 high on its opening frame
|
|
81
|
+
and its closing frame. The Tooltip's idiom (D26): the data attributes
|
|
82
|
+
are Base UI's, the transition is ours, and none under
|
|
83
|
+
prefers-reduced-motion. */}
|
|
84
|
+
<Collapsible.Panel
|
|
85
|
+
hiddenUntilFound
|
|
86
|
+
className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-150 ease-out motion-reduce:transition-none data-[starting-style]:h-0 data-[ending-style]:h-0"
|
|
87
|
+
>
|
|
88
|
+
<div className={cn('flex flex-col', contentClassName)}>{children}</div>
|
|
89
|
+
</Collapsible.Panel>
|
|
90
|
+
</Collapsible.Root>
|
|
91
|
+
)
|
|
92
|
+
}
|
package/src/Divider.mdx
CHANGED
|
@@ -20,6 +20,13 @@ carries the `separator` role for assistive tech.
|
|
|
20
20
|
|
|
21
21
|
<Canvas of={DividerStories.Vertical} />
|
|
22
22
|
|
|
23
|
+
- With a `label`, words sit in the middle of the line — a date between two
|
|
24
|
+
days of messages, or where "new since you last read this" begins.
|
|
25
|
+
`tone="warning"` for the second: the label in the warning colour, the
|
|
26
|
+
lines in its wash. The line is named by its label for assistive tech.
|
|
27
|
+
|
|
28
|
+
<Canvas of={DividerStories.Warning} />
|
|
29
|
+
|
|
23
30
|
## When not
|
|
24
31
|
|
|
25
32
|
- Between every row of a list — spacing does that; a hairline per row is
|
|
@@ -34,6 +41,8 @@ import { Divider } from '@estiva-app/ui'
|
|
|
34
41
|
|
|
35
42
|
<Divider />
|
|
36
43
|
<Divider orientation="vertical" />
|
|
44
|
+
<Divider label="Today" />
|
|
45
|
+
<Divider label="New since you last read this" tone="warning" />
|
|
37
46
|
```
|
|
38
47
|
|
|
39
48
|
## Props
|
package/src/Divider.stories.tsx
CHANGED
|
@@ -42,3 +42,28 @@ export const Vertical: Story = {
|
|
|
42
42
|
</div>
|
|
43
43
|
),
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
/** Words in the middle of the line — a date between two days of messages. */
|
|
47
|
+
export const WithALabel: Story = {
|
|
48
|
+
// axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
|
|
49
|
+
// the label is muted caption text, 3.78:1 on --bg-surface in signal (AA 4.5:1).
|
|
50
|
+
parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
|
|
51
|
+
render: () => (
|
|
52
|
+
<div className="w-96 rounded-lg border border-border-default bg-bg-surface py-3 text-body-2 text-text-primary">
|
|
53
|
+
<p className="px-3 pb-2">Yesterday's last message</p>
|
|
54
|
+
<Divider label="Today" />
|
|
55
|
+
<p className="px-3 pt-2">Today's first</p>
|
|
56
|
+
</div>
|
|
57
|
+
),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Asking for attention: where "new since you last read this" begins. The warning colour, because the accent could not be read on Ship's background. */
|
|
61
|
+
export const Warning: Story = {
|
|
62
|
+
render: () => (
|
|
63
|
+
<div className="w-96 rounded-lg border border-border-default bg-bg-surface py-3 text-body-2 text-text-primary">
|
|
64
|
+
<p className="px-3 pb-2">Yes — it re-reads the folder union and the archived one lands last.</p>
|
|
65
|
+
<Divider label="New since you last read this" tone="warning" />
|
|
66
|
+
<p className="px-3 pt-2">Same here, from Peek.</p>
|
|
67
|
+
</div>
|
|
68
|
+
),
|
|
69
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/** What the Divider page claims, pinned: the role, and a label that names the line. */
|
|
3
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
4
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
5
|
+
import { Divider } from './Divider'
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup)
|
|
8
|
+
|
|
9
|
+
describe('Divider', () => {
|
|
10
|
+
it('is a separator, horizontal by default', () => {
|
|
11
|
+
render(<Divider />)
|
|
12
|
+
expect(screen.getByRole('separator').getAttribute('aria-orientation')).toBe('horizontal')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('with a label, the separator is named by it and the words are drawn once', () => {
|
|
16
|
+
render(<Divider label="New since you last read this" tone="warning" />)
|
|
17
|
+
const rule = screen.getByRole('separator', { name: 'New since you last read this' })
|
|
18
|
+
expect(rule.textContent).toBe('New since you last read this')
|
|
19
|
+
expect(rule.querySelectorAll('[aria-hidden="true"]')).toHaveLength(2)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('vertical ignores a label — there is no middle to put it in', () => {
|
|
23
|
+
render(<Divider orientation="vertical" label="Nope" />)
|
|
24
|
+
expect(screen.getByRole('separator').textContent).toBe('')
|
|
25
|
+
})
|
|
26
|
+
})
|
package/src/Divider.tsx
CHANGED
|
@@ -8,10 +8,39 @@ import { cn } from './cn'
|
|
|
8
8
|
*/
|
|
9
9
|
export interface DividerProps {
|
|
10
10
|
orientation?: 'horizontal' | 'vertical'
|
|
11
|
+
/**
|
|
12
|
+
* Words in the middle of the line — "New since you last read this", a
|
|
13
|
+
* date. Horizontal only; the line splits around the label.
|
|
14
|
+
*/
|
|
15
|
+
label?: string
|
|
16
|
+
/**
|
|
17
|
+
* `warning` for a line that asks for attention: the label in the warning
|
|
18
|
+
* colour, the lines in its wash. Peek and Ship both drew their "new since
|
|
19
|
+
* you last read" rule in the accent, which measures 3.3:1 on Ship's
|
|
20
|
+
* background and could not be read (Katerina, 2026-09-08); the warning
|
|
21
|
+
* colour measures 9.6:1 there. Default: the muted text, `border-subtle`
|
|
22
|
+
* lines, for a date.
|
|
23
|
+
*/
|
|
24
|
+
tone?: 'default' | 'warning'
|
|
11
25
|
className?: string
|
|
12
26
|
}
|
|
13
27
|
|
|
14
|
-
export function Divider({ orientation = 'horizontal', className }: DividerProps) {
|
|
28
|
+
export function Divider({ orientation = 'horizontal', label, tone = 'default', className }: DividerProps) {
|
|
29
|
+
if (label && orientation === 'horizontal') {
|
|
30
|
+
const line = tone === 'warning' ? 'bg-warning-muted' : 'bg-border-subtle'
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
role="separator"
|
|
34
|
+
aria-orientation="horizontal"
|
|
35
|
+
aria-label={label}
|
|
36
|
+
className={cn('flex shrink-0 items-center gap-2 mx-3', className)}
|
|
37
|
+
>
|
|
38
|
+
<span aria-hidden="true" className={cn('h-px flex-1', line)} />
|
|
39
|
+
<span className={cn('shrink-0 text-caption', tone === 'warning' ? 'text-warning-default' : 'text-text-muted')}>{label}</span>
|
|
40
|
+
<span aria-hidden="true" className={cn('h-px flex-1', line)} />
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
15
44
|
return (
|
|
16
45
|
<div
|
|
17
46
|
role="separator"
|
package/src/EmptyState.mdx
CHANGED
|
@@ -5,18 +5,32 @@ import * as EmptyStateStories from './EmptyState.stories'
|
|
|
5
5
|
|
|
6
6
|
# EmptyState
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
The place where content will be once there is some, in two manners: a
|
|
9
|
+
16px icon over a centred line when the whole page is empty, the line
|
|
10
|
+
alone and left-aligned when one section of a page is.
|
|
10
11
|
|
|
11
|
-
<Canvas of={EmptyStateStories.
|
|
12
|
+
<Canvas of={EmptyStateStories.Page} />
|
|
12
13
|
|
|
13
14
|
## When
|
|
14
15
|
|
|
15
|
-
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
-
|
|
19
|
-
|
|
16
|
+
- **`page`** — the whole page is empty: no items yet, a link that leads to
|
|
17
|
+
nothing. The icon over a centred line; `icon` swaps the default speech
|
|
18
|
+
bubble for one that matches the subject.
|
|
19
|
+
- **`section`** — one section of a page is empty and the page has other
|
|
20
|
+
things on it: no replies under an item, a search with no results. The
|
|
21
|
+
line alone, left-aligned, no icon. A section's emptiness is one line
|
|
22
|
+
among the page's content, not a stage of its own.
|
|
23
|
+
|
|
24
|
+
<Canvas of={EmptyStateStories.Section} />
|
|
25
|
+
|
|
26
|
+
The page decides, not the size of the box: a whole page with one short
|
|
27
|
+
message is still a `page`; a tall section with nothing in it is still a
|
|
28
|
+
`section`. The message says **what would fill it** — "No comments yet.
|
|
29
|
+
Start the thread." — not just that it is empty.
|
|
30
|
+
|
|
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 centres in it; drawn straight into
|
|
33
|
+
a page, give it the height — `className="h-full"`.
|
|
20
34
|
|
|
21
35
|
## When not
|
|
22
36
|
|
|
@@ -30,12 +44,17 @@ content will be once there is some.
|
|
|
30
44
|
```tsx
|
|
31
45
|
import { EmptyState } from '@estiva-app/ui'
|
|
32
46
|
|
|
33
|
-
|
|
47
|
+
// the whole page, drawn straight into it
|
|
48
|
+
<EmptyState className="h-full" message="No documents yet. Create the first one." />
|
|
49
|
+
|
|
50
|
+
// one section of a page
|
|
51
|
+
<EmptyState scope="section" message="No comments yet." />
|
|
34
52
|
```
|
|
35
53
|
|
|
36
54
|
The message is the caller's — a shared component has no words of its own
|
|
37
|
-
for what is missing.
|
|
55
|
+
for what is missing. A hand-written "Nothing here" paragraph or span in a
|
|
56
|
+
section is the `section` manner, and becomes it.
|
|
38
57
|
|
|
39
58
|
## Props
|
|
40
59
|
|
|
41
|
-
<Controls of={EmptyStateStories.
|
|
60
|
+
<Controls of={EmptyStateStories.Page} />
|
|
@@ -7,12 +7,16 @@ const meta = {
|
|
|
7
7
|
component: EmptyState,
|
|
8
8
|
args: { message: 'Nothing here yet.' },
|
|
9
9
|
argTypes: { icon: { control: false } },
|
|
10
|
+
// 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.
|
|
11
|
+
decorators: [(Story) => <div className="flex h-[280px] w-[480px] flex-col rounded-lg border border-border-default p-4">{Story()}</div>],
|
|
10
12
|
} satisfies Meta<typeof EmptyState>
|
|
11
13
|
|
|
12
14
|
export default meta
|
|
13
15
|
type Story = StoryObj<typeof meta>
|
|
14
16
|
|
|
15
|
-
/** The default icon
|
|
16
|
-
export const
|
|
17
|
-
|
|
17
|
+
/** The `page` manner: the default icon over the caller's words, in the middle of its box both ways. */
|
|
18
|
+
export const Page: Story = {}
|
|
19
|
+
/** The `section` manner: the words alone, left-aligned — one line in a page that has other things on it. */
|
|
20
|
+
export const Section: Story = { args: { scope: 'section' } }
|
|
21
|
+
export const LongerMessage: Story = { args: { message: 'No items yet. Add one from any list.' } }
|
|
18
22
|
export const CustomIcon: Story = { args: { icon: <IconLock size={16} stroke={1.5} />, message: 'Nothing you can read here yet.' } }
|