@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.
Files changed (63) hide show
  1. package/dist/AppShell.d.ts.map +1 -1
  2. package/dist/Breadcrumb.d.ts +7 -1
  3. package/dist/Breadcrumb.d.ts.map +1 -1
  4. package/dist/Chip.d.ts.map +1 -1
  5. package/dist/CollapsibleSection.d.ts +42 -0
  6. package/dist/CollapsibleSection.d.ts.map +1 -0
  7. package/dist/Divider.d.ts +15 -1
  8. package/dist/Divider.d.ts.map +1 -1
  9. package/dist/EmptyState.d.ts +19 -5
  10. package/dist/EmptyState.d.ts.map +1 -1
  11. package/dist/Menu.d.ts.map +1 -1
  12. package/dist/NavItem.d.ts.map +1 -1
  13. package/dist/Popover.d.ts.map +1 -1
  14. package/dist/PreviewCard.d.ts.map +1 -1
  15. package/dist/Reaction.d.ts.map +1 -1
  16. package/dist/ScrollArea.d.ts +46 -0
  17. package/dist/ScrollArea.d.ts.map +1 -0
  18. package/dist/SectionHeader.d.ts +31 -10
  19. package/dist/SectionHeader.d.ts.map +1 -1
  20. package/dist/Select.d.ts.map +1 -1
  21. package/dist/Sidebar.d.ts.map +1 -1
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +375 -266
  25. package/dist/index.js.map +4 -4
  26. package/package.json +1 -1
  27. package/src/AppShell.tsx +5 -0
  28. package/src/Breadcrumb.mdx +6 -0
  29. package/src/Breadcrumb.stories.tsx +12 -0
  30. package/src/Breadcrumb.test.tsx +10 -0
  31. package/src/Breadcrumb.tsx +17 -2
  32. package/src/Chip.tsx +10 -2
  33. package/src/CollapsibleSection.mdx +72 -0
  34. package/src/CollapsibleSection.stories.tsx +38 -0
  35. package/src/CollapsibleSection.test.tsx +81 -0
  36. package/src/CollapsibleSection.tsx +92 -0
  37. package/src/Divider.mdx +9 -0
  38. package/src/Divider.stories.tsx +25 -0
  39. package/src/Divider.test.tsx +26 -0
  40. package/src/Divider.tsx +30 -1
  41. package/src/EmptyState.mdx +30 -11
  42. package/src/EmptyState.stories.tsx +7 -3
  43. package/src/EmptyState.test.tsx +39 -0
  44. package/src/EmptyState.tsx +23 -6
  45. package/src/Menu.tsx +11 -2
  46. package/src/NavItem.tsx +5 -1
  47. package/src/Popover.tsx +6 -2
  48. package/src/PreviewCard.tsx +5 -2
  49. package/src/Reaction.tsx +6 -1
  50. package/src/ScrollArea.mdx +65 -0
  51. package/src/ScrollArea.stories.tsx +104 -0
  52. package/src/ScrollArea.test.tsx +65 -0
  53. package/src/ScrollArea.tsx +94 -0
  54. package/src/SectionHeader.mdx +26 -17
  55. package/src/SectionHeader.stories.tsx +4 -35
  56. package/src/SectionHeader.test.tsx +40 -0
  57. package/src/SectionHeader.tsx +70 -39
  58. package/src/Select.tsx +8 -1
  59. package/src/Sidebar.mdx +14 -10
  60. package/src/Sidebar.stories.tsx +17 -15
  61. package/src/Sidebar.tsx +8 -5
  62. package/src/index.ts +2 -0
  63. package/stories/Choosing.mdx +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.10.1",
3
+ "version": "0.12.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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>
@@ -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
+ }
@@ -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
+ })
@@ -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
- item.muted || (last && item.mono) ? 'text-text-muted' : last ? 'text-text-primary' : 'text-text-secondary',
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] min-w-[16px] px-2 py-1', typeStyles[type], className)}>
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
- <span className="text-chip whitespace-nowrap signal:font-mono signal:text-[10px] signal:font-semibold signal:tracking-[0.02em] signal:tabular-nums">{label}</span>
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
@@ -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"
@@ -5,18 +5,32 @@ import * as EmptyStateStories from './EmptyState.stories'
5
5
 
6
6
  # EmptyState
7
7
 
8
- A 16px icon over a centred line of secondary text, for the place where
9
- content will be once there is some.
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.Default} />
12
+ <Canvas of={EmptyStateStories.Page} />
12
13
 
13
14
  ## When
14
15
 
15
- - A list, panel or view with nothing in it yet. The message says **what
16
- would fill it** "No comments yet. Start the thread." — not just that
17
- it is empty.
18
- - `icon` swaps the default speech bubble for one that matches the
19
- subject.
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
- <EmptyState message="No documents yet. Create the first one." />
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.Default} />
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 with the caller's words. */
16
- export const Default: Story = {}
17
- export const LongerMessage: Story = { args: { message: 'No topics yet. Start one from any conversation.' } }
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.' } }