@estiva-app/ui 0.50.4 → 0.51.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/CollapsibleSection.d.ts +7 -1
- package/dist/CollapsibleSection.d.ts.map +1 -1
- package/dist/NavItem.d.ts +23 -3
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/NavTree.d.ts +49 -0
- package/dist/NavTree.d.ts.map +1 -0
- package/dist/SectionLabel.d.ts +7 -1
- package/dist/SectionLabel.d.ts.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-native-title.d.ts +1 -1
- package/dist/eslint/no-native-title.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +332 -212
- package/dist/index.js.map +4 -4
- package/dist/looks.d.ts +7 -0
- package/dist/looks.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +115 -5
- package/src/CollapsibleSection.mdx +6 -0
- package/src/CollapsibleSection.stories.tsx +5 -0
- package/src/CollapsibleSection.tsx +12 -4
- package/src/NavItem.mdx +30 -0
- package/src/NavItem.stories.tsx +34 -0
- package/src/NavItem.test.tsx +82 -0
- package/src/NavItem.tsx +123 -25
- package/src/NavTree.mdx +83 -0
- package/src/NavTree.stories.tsx +84 -0
- package/src/NavTree.test.tsx +54 -0
- package/src/NavTree.tsx +112 -0
- package/src/SectionHeader.tsx +1 -1
- package/src/SectionLabel.mdx +4 -1
- package/src/SectionLabel.stories.tsx +3 -0
- package/src/SectionLabel.tsx +10 -2
- package/src/eslint/no-native-title.ts +1 -1
- package/src/index.ts +1 -0
- package/src/looks.ts +8 -0
- package/src/registry/registry.test.ts +4 -4
- package/stories/Choosing.mdx +1 -0
package/src/NavTree.mdx
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as NavTreeStories from './NavTree.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={NavTreeStories} />
|
|
5
|
+
|
|
6
|
+
# NavTree
|
|
7
|
+
|
|
8
|
+
A sidebar's tree from one nested list: a quiet label, groups that fold
|
|
9
|
+
under the standard heading with their rows lightly indented, and rows under
|
|
10
|
+
rows to any depth.
|
|
11
|
+
|
|
12
|
+
<Canvas of={NavTreeStories.Default} />
|
|
13
|
+
|
|
14
|
+
## When
|
|
15
|
+
|
|
16
|
+
- Places that nest, in a **Sidebar**: groups, rows in them, rows under
|
|
17
|
+
those rows. Hand it the whole list; it draws every level.
|
|
18
|
+
- `selected` is the current row: it is `active`, and only its group starts
|
|
19
|
+
open. Rows with rows under them start open.
|
|
20
|
+
|
|
21
|
+
<Canvas of={NavTreeStories.CurrentDeep} />
|
|
22
|
+
|
|
23
|
+
- A row's `hint` (a neutral **Chip**), `count` and `menu` are **NavItem**'s,
|
|
24
|
+
passed through.
|
|
25
|
+
- `groups={null}` while the first read is on its way draws a skeleton;
|
|
26
|
+
`[]` draws `emptyMessage`, level with the rows.
|
|
27
|
+
|
|
28
|
+
<Canvas of={NavTreeStories.Loading} />
|
|
29
|
+
|
|
30
|
+
## When not
|
|
31
|
+
|
|
32
|
+
- A flat list of places → **NavItem** rows in the **Sidebar**.
|
|
33
|
+
- One group that folds, written by hand → **CollapsibleSection**.
|
|
34
|
+
- A list beside the thing that is open → **ListColumn**.
|
|
35
|
+
|
|
36
|
+
## How
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import { NavTree, Sidebar } from '@estiva-app/ui'
|
|
40
|
+
|
|
41
|
+
<Sidebar>
|
|
42
|
+
<NavTree
|
|
43
|
+
title="Groups"
|
|
44
|
+
groups={groups}
|
|
45
|
+
selected={currentId}
|
|
46
|
+
onSelect={(node, event) => {
|
|
47
|
+
event.preventDefault()
|
|
48
|
+
navigate(node.href)
|
|
49
|
+
}}
|
|
50
|
+
emptyMessage="Nothing here yet."
|
|
51
|
+
storageKey="app.sidebar.groups"
|
|
52
|
+
/>
|
|
53
|
+
</Sidebar>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- Each row is a **NavItem**, so a row is a link: a router app prevents the
|
|
57
|
+
default in `onSelect` and navigates.
|
|
58
|
+
- `storageKey` remembers each group open or closed in this browser, under
|
|
59
|
+
the key and the group's id. The rows under a row are not remembered yet.
|
|
60
|
+
- The words in `emptyMessage` are the caller's.
|
|
61
|
+
|
|
62
|
+
## What it owns
|
|
63
|
+
|
|
64
|
+
It does these by itself. Write none of them around it.
|
|
65
|
+
|
|
66
|
+
| It does | So you never write | Refused when written by hand |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| Draws every level from one list, rows under rows | a recursive render of **NavItem**s and **CollapsibleSection**s | |
|
|
69
|
+
| Opens the group that holds the current row | a search through the list for the current row | |
|
|
70
|
+
| Says it is loading, or empty, in the rows' place | a skeleton or an **EmptyState** placed by hand | |
|
|
71
|
+
|
|
72
|
+
## Keys
|
|
73
|
+
|
|
74
|
+
| Key | Does |
|
|
75
|
+
|---|---|
|
|
76
|
+
| Tab | each group's title, then its rows: each row's link, then its arrow |
|
|
77
|
+
| Enter, Space | on a group's title or a row's arrow: folds or unfolds |
|
|
78
|
+
|
|
79
|
+
It is not an ARIA tree yet: there are no arrow keys between rows.
|
|
80
|
+
|
|
81
|
+
## Props
|
|
82
|
+
|
|
83
|
+
<Controls of={NavTreeStories.Default} />
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconSquareRounded } from '@tabler/icons-react'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { Chip } from './Chip'
|
|
5
|
+
import { NavItem } from './NavItem'
|
|
6
|
+
import { NavTree, type NavTreeGroup } from './NavTree'
|
|
7
|
+
import { Sidebar } from './Sidebar'
|
|
8
|
+
|
|
9
|
+
const placeholder = <IconSquareRounded size={16} stroke={1.5} />
|
|
10
|
+
const hint = <Chip type="neutral" label="Label" />
|
|
11
|
+
|
|
12
|
+
/** Three groups; the open one holds rows under rows, hints and counts, and long labels to see what fits. */
|
|
13
|
+
const GROUPS: NavTreeGroup[] = [
|
|
14
|
+
{ id: 'g1', title: 'Group one', nodes: [{ id: 'a', label: 'Item two', href: '#', icon: placeholder }] },
|
|
15
|
+
{
|
|
16
|
+
id: 'g2',
|
|
17
|
+
title: 'A group with a longer name',
|
|
18
|
+
nodes: [
|
|
19
|
+
{ id: 'b', label: 'Item three', href: '#', icon: placeholder, count: 1, countLabel: '1 open' },
|
|
20
|
+
{
|
|
21
|
+
id: 'c',
|
|
22
|
+
label: 'An item whose label runs longer than the column',
|
|
23
|
+
href: '#',
|
|
24
|
+
icon: placeholder,
|
|
25
|
+
children: [
|
|
26
|
+
{ id: 'c1', label: 'An item one level in, also long', href: '#', icon: placeholder, count: 2, countLabel: '2 open' },
|
|
27
|
+
{ id: 'c2', label: 'Item four', href: '#', icon: placeholder, children: [{ id: 'c21', label: 'An item two levels in, long too', href: '#', icon: placeholder }] },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{ id: 'd', label: 'An item with a hint and a long label', href: '#', icon: placeholder, hint, count: 3, countLabel: '3 open' },
|
|
31
|
+
{ id: 'e', label: 'Item five', href: '#', icon: placeholder, hint },
|
|
32
|
+
{ id: 'f', label: 'An item with a hint and a count', href: '#', icon: placeholder, hint, count: 12, countLabel: '12 open' },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
{ id: 'g3', title: 'Group three', nodes: [{ id: 'g', label: 'Item six', href: '#', icon: placeholder }] },
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
const meta = {
|
|
39
|
+
title: 'Frame/NavTree',
|
|
40
|
+
component: NavTree,
|
|
41
|
+
parameters: { layout: 'fullscreen' },
|
|
42
|
+
args: { title: 'Groups', groups: GROUPS, selected: 'b', emptyMessage: 'Nothing here yet.' },
|
|
43
|
+
argTypes: { groups: { control: false } },
|
|
44
|
+
// In the Sidebar, under a first row, as it sits in an app; a click marks the row it was on.
|
|
45
|
+
render: function Render(args) {
|
|
46
|
+
const [selected, setSelected] = useState(args.selected)
|
|
47
|
+
return (
|
|
48
|
+
<div className="flex h-screen bg-bg-base">
|
|
49
|
+
<Sidebar>
|
|
50
|
+
<NavItem href="#" label="Item one" icon={placeholder} />
|
|
51
|
+
<NavTree
|
|
52
|
+
{...args}
|
|
53
|
+
selected={selected}
|
|
54
|
+
onSelect={(node, event) => {
|
|
55
|
+
event.preventDefault()
|
|
56
|
+
setSelected(node.id)
|
|
57
|
+
}}
|
|
58
|
+
/>
|
|
59
|
+
</Sidebar>
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
},
|
|
63
|
+
} satisfies Meta<typeof NavTree>
|
|
64
|
+
|
|
65
|
+
export default meta
|
|
66
|
+
type Story = StoryObj<typeof meta>
|
|
67
|
+
|
|
68
|
+
/** Only the group holding the current row starts open. Point at a row with rows under it: its icon becomes the arrow. Point at a row with a hint: the chip shows. */
|
|
69
|
+
export const Default: Story = {}
|
|
70
|
+
|
|
71
|
+
/** The current row is two levels in: its group opens, and the rows above it are open too. */
|
|
72
|
+
export const CurrentDeep: Story = { args: { selected: 'c21' } }
|
|
73
|
+
|
|
74
|
+
/** No quiet label over the groups. */
|
|
75
|
+
export const NoTitle: Story = { args: { title: undefined } }
|
|
76
|
+
|
|
77
|
+
/** The first read is on its way. */
|
|
78
|
+
export const Loading: Story = { args: { groups: null } }
|
|
79
|
+
|
|
80
|
+
/** No groups: the caller's words, level with the rows. */
|
|
81
|
+
export const Empty: Story = { args: { groups: [] } }
|
|
82
|
+
|
|
83
|
+
/** Close a group and reload the page: still closed. This browser remembers each group, under the key. */
|
|
84
|
+
export const Remembered: Story = { args: { storageKey: 'estiva-ui.stories.nav-tree' } }
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the page claims: every level is drawn from the one list; only the
|
|
4
|
+
* group holding the current row starts open, and the current row is active;
|
|
5
|
+
* a click reaches onSelect with its node; loading and empty say so.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
8
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
9
|
+
import userEvent from '@testing-library/user-event'
|
|
10
|
+
import { NavTree, type NavTreeGroup } from './NavTree'
|
|
11
|
+
|
|
12
|
+
afterEach(cleanup)
|
|
13
|
+
|
|
14
|
+
const GROUPS: NavTreeGroup[] = [
|
|
15
|
+
{ id: 'g1', title: 'Group one', nodes: [{ id: 'a', label: 'Item a', href: '#a' }] },
|
|
16
|
+
{
|
|
17
|
+
id: 'g2',
|
|
18
|
+
title: 'Group two',
|
|
19
|
+
nodes: [{ id: 'b', label: 'Item b', href: '#b', children: [{ id: 'c', label: 'Item c', href: '#c', children: [{ id: 'd', label: 'Item d', href: '#d' }] }] }],
|
|
20
|
+
},
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
const hidden = (text: string) => screen.getByText(text).closest('[hidden]') !== null
|
|
24
|
+
|
|
25
|
+
describe('NavTree', () => {
|
|
26
|
+
it('draws every level, and opens only the group holding the current row', () => {
|
|
27
|
+
render(<NavTree groups={GROUPS} selected="d" emptyMessage="Nothing here yet." />)
|
|
28
|
+
expect(hidden('Item a')).toBe(true)
|
|
29
|
+
expect(hidden('Item d')).toBe(false)
|
|
30
|
+
expect(screen.getByRole('link', { name: 'Item d' }).getAttribute('aria-current')).toBe('page')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('a click reaches onSelect with the node it was on', async () => {
|
|
34
|
+
const onSelect = vi.fn((_node, event: { preventDefault: () => void }) => event.preventDefault())
|
|
35
|
+
render(<NavTree groups={GROUPS} selected="b" onSelect={onSelect} emptyMessage="Nothing here yet." />)
|
|
36
|
+
await userEvent.click(screen.getByRole('link', { name: 'Item c' }))
|
|
37
|
+
expect(onSelect.mock.calls[0][0].id).toBe('c')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('draws the quiet label when given one', () => {
|
|
41
|
+
render(<NavTree title="Groups" groups={GROUPS} emptyMessage="Nothing here yet." />)
|
|
42
|
+
expect(screen.getByText('Groups')).not.toBeNull()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('says it is empty in the caller’s words', () => {
|
|
46
|
+
render(<NavTree groups={[]} emptyMessage="Nothing here yet." />)
|
|
47
|
+
expect(screen.getByText('Nothing here yet.')).not.toBeNull()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('draws no rows while loading', () => {
|
|
51
|
+
render(<NavTree groups={null} emptyMessage="Nothing here yet." />)
|
|
52
|
+
expect(screen.queryAllByRole('link')).toHaveLength(0)
|
|
53
|
+
})
|
|
54
|
+
})
|
package/src/NavTree.tsx
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { MouseEvent, ReactNode } from 'react'
|
|
2
|
+
import { CollapsibleSection } from './CollapsibleSection'
|
|
3
|
+
import { EmptyState } from './EmptyState'
|
|
4
|
+
import { NavItem } from './NavItem'
|
|
5
|
+
import { SectionHeader } from './SectionHeader'
|
|
6
|
+
import { SkeletonList } from './Skeleton'
|
|
7
|
+
|
|
8
|
+
/** One row of the tree, and the rows under it. */
|
|
9
|
+
export interface NavTreeNode {
|
|
10
|
+
id: string
|
|
11
|
+
label: string
|
|
12
|
+
href: string
|
|
13
|
+
/** 16px, stroke 1.5. */
|
|
14
|
+
icon?: ReactNode
|
|
15
|
+
/** `0` or absent renders no counter. */
|
|
16
|
+
count?: number
|
|
17
|
+
/** What the count counts, for the tooltip. */
|
|
18
|
+
countLabel?: string
|
|
19
|
+
/** Shown only while the row is pointed at or focused, left of the count: a neutral `Chip`, typically. */
|
|
20
|
+
hint?: ReactNode
|
|
21
|
+
/** The row's "More options" menu: `MenuItem`s. */
|
|
22
|
+
menu?: ReactNode
|
|
23
|
+
/** Rows under this one. Their row folds them; open unless `defaultOpen` says otherwise. */
|
|
24
|
+
children?: NavTreeNode[]
|
|
25
|
+
defaultOpen?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A group heading over its rows: the top of the tree. */
|
|
29
|
+
export interface NavTreeGroup {
|
|
30
|
+
id: string
|
|
31
|
+
title: string
|
|
32
|
+
nodes: NavTreeNode[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface NavTreeProps {
|
|
36
|
+
/** The quiet label over the groups. Absent, none is drawn. */
|
|
37
|
+
title?: string
|
|
38
|
+
/** `null` while the first read is on its way: a skeleton in the rows' place. */
|
|
39
|
+
groups: NavTreeGroup[] | null
|
|
40
|
+
/** The row that is current: `active`, and its group starts open. */
|
|
41
|
+
selected?: string
|
|
42
|
+
/** A row was clicked. A router app prevents the default and navigates. */
|
|
43
|
+
onSelect?: (node: NavTreeNode, event: MouseEvent<HTMLAnchorElement>) => void
|
|
44
|
+
/** What to say when there are no groups. The words are the caller's. */
|
|
45
|
+
emptyMessage: string
|
|
46
|
+
/** Remember each group open or closed in this browser, under this key and the group's id. The app prefixes it. */
|
|
47
|
+
storageKey?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function holds(nodes: NavTreeNode[], id: string | undefined): boolean {
|
|
51
|
+
return id !== undefined && nodes.some((node) => node.id === id || holds(node.children ?? [], id))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function Row({ node, selected, onSelect }: { node: NavTreeNode } & Pick<NavTreeProps, 'selected' | 'onSelect'>) {
|
|
55
|
+
return (
|
|
56
|
+
<NavItem
|
|
57
|
+
href={node.href}
|
|
58
|
+
label={node.label}
|
|
59
|
+
icon={node.icon}
|
|
60
|
+
count={node.count}
|
|
61
|
+
countLabel={node.countLabel}
|
|
62
|
+
hint={node.hint}
|
|
63
|
+
menu={node.menu}
|
|
64
|
+
active={node.id === selected}
|
|
65
|
+
defaultOpen={node.defaultOpen ?? true}
|
|
66
|
+
onClick={onSelect ? (event) => onSelect(node, event) : undefined}
|
|
67
|
+
>
|
|
68
|
+
{node.children?.length
|
|
69
|
+
? node.children.map((child) => <Row key={child.id} node={child} selected={selected} onSelect={onSelect} />)
|
|
70
|
+
: undefined}
|
|
71
|
+
</NavItem>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A sidebar's tree, from one nested list (Katerina, 2 October: Leaf's folder
|
|
77
|
+
* tree): a quiet label, then each group as the standard heading folding its
|
|
78
|
+
* rows, lightly indented under its title; rows under rows to any depth, each
|
|
79
|
+
* row a link whose icon becomes the arrow that folds what is under it. Only
|
|
80
|
+
* the group that holds the current row starts open.
|
|
81
|
+
*/
|
|
82
|
+
export function NavTree({ title, groups, selected, onSelect, emptyMessage, storageKey }: NavTreeProps) {
|
|
83
|
+
return (
|
|
84
|
+
<>
|
|
85
|
+
{title && <SectionHeader title={title} look="quiet" className="mt-4 shrink-0" />}
|
|
86
|
+
{groups === null ? (
|
|
87
|
+
<SkeletonList rows={5} />
|
|
88
|
+
) : groups.length === 0 ? (
|
|
89
|
+
// Level with the rows: a row pads itself, so the line takes a row's inset (Katerina, 14 September).
|
|
90
|
+
<div className="px-2 py-1">
|
|
91
|
+
<EmptyState scope="section" message={emptyMessage} />
|
|
92
|
+
</div>
|
|
93
|
+
) : (
|
|
94
|
+
groups.map((group) => (
|
|
95
|
+
<CollapsibleSection
|
|
96
|
+
key={group.id}
|
|
97
|
+
title={group.title}
|
|
98
|
+
indent
|
|
99
|
+
defaultOpen={holds(group.nodes, selected)}
|
|
100
|
+
storageKey={storageKey ? `${storageKey}.${group.id}` : undefined}
|
|
101
|
+
className="mt-2 shrink-0"
|
|
102
|
+
contentClassName="gap-px"
|
|
103
|
+
>
|
|
104
|
+
{group.nodes.map((node) => (
|
|
105
|
+
<Row key={node.id} node={node} selected={selected} onSelect={onSelect} />
|
|
106
|
+
))}
|
|
107
|
+
</CollapsibleSection>
|
|
108
|
+
))
|
|
109
|
+
)}
|
|
110
|
+
</>
|
|
111
|
+
)
|
|
112
|
+
}
|
package/src/SectionHeader.tsx
CHANGED
|
@@ -106,7 +106,7 @@ export function SectionHeader({ title, chevron = false, isExpanded = true, onTog
|
|
|
106
106
|
// A row's words: NavItem's size and colour, brightening under the pointer with the row.
|
|
107
107
|
<span className="min-w-0 flex-1 truncate text-body-2">{title}</span>
|
|
108
108
|
) : (
|
|
109
|
-
<SectionLabel>{title}</SectionLabel>
|
|
109
|
+
<SectionLabel folds={chevron}>{title}</SectionLabel>
|
|
110
110
|
)}
|
|
111
111
|
</>
|
|
112
112
|
),
|
package/src/SectionLabel.mdx
CHANGED
|
@@ -40,7 +40,10 @@ It is a bare span: the caller owns the row it sits in and the spacing
|
|
|
40
40
|
around it. Read it `tone="secondary"` where it labels rather than leads — menu
|
|
41
41
|
headings and the command palette's groups do. `tone="muted"` where it marks a
|
|
42
42
|
place in a list rather than heading it — a date between messages. `truncate`
|
|
43
|
-
cuts a long label short with an ellipsis where its row has no room.
|
|
43
|
+
cuts a long label short with an ellipsis where its row has no room. `folds`
|
|
44
|
+
is the title of a group that opens and closes: **SectionHeader** passes it
|
|
45
|
+
whenever it draws the chevron, so in Leaf a folding group keeps Ship's type
|
|
46
|
+
while every other label takes Signal's. A colour
|
|
44
47
|
passed in `className` is refused by the lint.
|
|
45
48
|
|
|
46
49
|
- Import it — never copy its classes. A copied class list drifts the first time
|
|
@@ -45,3 +45,6 @@ export const Truncated: Story = {
|
|
|
45
45
|
</div>
|
|
46
46
|
),
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
/** The title of a group that folds: in Leaf it keeps Ship's type; in Signal and Ship it looks like any label. Switch the toolbar to Leaf to see the difference. */
|
|
50
|
+
export const Folds: Story = { args: { folds: true, children: 'Group' } }
|
package/src/SectionLabel.tsx
CHANGED
|
@@ -18,6 +18,10 @@ import { cn } from './cn'
|
|
|
18
18
|
* long label short with an ellipsis where its row has no room, as a date line in
|
|
19
19
|
* a narrow panel must.
|
|
20
20
|
*
|
|
21
|
+
* `folds` is the title of a group that opens and closes: in Leaf it keeps
|
|
22
|
+
* Ship's type rather than taking Signal's micro-label, so a folder reads as
|
|
23
|
+
* a name (Katerina, 2 October). Every other Leaf label keeps Signal's.
|
|
24
|
+
*
|
|
21
25
|
* Deliberately NOT the same thing as Property's stacked field label (the
|
|
22
26
|
* 9px `menu`-token one): they were merged for a day and unmerged by
|
|
23
27
|
* Katerina's ruling (2026-09-01) — a section title and a field label are
|
|
@@ -27,20 +31,24 @@ export function SectionLabel({
|
|
|
27
31
|
children,
|
|
28
32
|
tone = 'primary',
|
|
29
33
|
truncate = false,
|
|
34
|
+
folds = false,
|
|
30
35
|
className,
|
|
31
36
|
}: {
|
|
32
37
|
children: ReactNode
|
|
33
38
|
tone?: 'primary' | 'secondary' | 'muted'
|
|
34
39
|
/** Cut a long label short with an ellipsis instead of letting it wrap or spill. */
|
|
35
40
|
truncate?: boolean
|
|
41
|
+
/** The title of a group that opens and closes. In Leaf, Ship's type instead of the micro-label. */
|
|
42
|
+
folds?: boolean
|
|
36
43
|
className?: string
|
|
37
44
|
}) {
|
|
38
45
|
return (
|
|
39
46
|
<span
|
|
40
47
|
className={cn(
|
|
41
48
|
'text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest',
|
|
42
|
-
// Leaf's section labels are Signal's type (Katerina, 28 September)
|
|
43
|
-
|
|
49
|
+
// Leaf's section labels are Signal's type (Katerina, 28 September),
|
|
50
|
+
// but a group that folds keeps Ship's (Katerina, 2 October).
|
|
51
|
+
!folds && 'leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest',
|
|
44
52
|
tone === 'secondary' ? 'text-text-secondary' : tone === 'muted' ? 'text-text-muted' : 'text-text-primary',
|
|
45
53
|
truncate && 'min-w-0 truncate',
|
|
46
54
|
className,
|
|
@@ -8,7 +8,7 @@ import { bindingOfTag, partsOfFile, type Node, type Parser, type PartBinding } f
|
|
|
8
8
|
* the browser shows it as its tooltip. `no-native-title.test.ts` holds this
|
|
9
9
|
* list to the catalogue: a part that gains a `title` of its own joins it.
|
|
10
10
|
*/
|
|
11
|
-
export const PARTS_WITH_A_TITLE = ['CollapsibleSection', 'ConfirmDialog', 'ContainerHeader', 'DialogShell', 'ListColumn', 'Panel', 'SectionHeader'] as const
|
|
11
|
+
export const PARTS_WITH_A_TITLE = ['CollapsibleSection', 'ConfirmDialog', 'ContainerHeader', 'DialogShell', 'ListColumn', 'NavTree', 'Panel', 'SectionHeader'] as const
|
|
12
12
|
|
|
13
13
|
const child = (node: Node, key: string) => node[key] as Node | null | undefined
|
|
14
14
|
const nameOf = (node: Node | null | undefined): string | undefined =>
|
package/src/index.ts
CHANGED
|
@@ -70,6 +70,7 @@ export { SkeletonBar, SkeletonList, SkeletonRow } from './Skeleton'
|
|
|
70
70
|
export { Breadcrumb, type BreadcrumbProps, type Crumb } from './Breadcrumb'
|
|
71
71
|
export { EnterHint, Menu, MenuItem, MenuPanel, MenuRow, MenuSection, MenuSeparator, MenuSub, type MenuItemProps, type MenuPanelProps, type MenuProps, type MenuSubProps } from './Menu'
|
|
72
72
|
export { NavItem, type NavItemProps } from './NavItem'
|
|
73
|
+
export { NavTree, type NavTreeGroup, type NavTreeNode, type NavTreeProps } from './NavTree'
|
|
73
74
|
export { Popover, type PopoverProps } from './Popover'
|
|
74
75
|
export { SuggestionMenu, type SuggestionMenuHandle, type SuggestionMenuProps, type SuggestionMenuRow, type SuggestionMenuSection } from './SuggestionMenu'
|
|
75
76
|
export { ScrollArea, type ScrollAreaProps } from './ScrollArea'
|
package/src/looks.ts
CHANGED
|
@@ -46,3 +46,11 @@ export const BARE_INPUT_CLASSES = 'bg-transparent text-text-primary placeholder:
|
|
|
46
46
|
/** A sidebar row's shape and its words at rest, brightening under the pointer: NavItem, and SectionHeader's `row` (a folder among rows). */
|
|
47
47
|
export const SIDEBAR_ROW_CLASSES = 'rounded-md transition-colors'
|
|
48
48
|
export const SIDEBAR_ROW_TEXT_CLASSES = 'text-text-secondary hover:text-text-primary'
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A panel that slides open and shut on Base UI's Collapsible: Base UI measures
|
|
52
|
+
* it and writes its height to a variable, `auto` again once the slide ends, and
|
|
53
|
+
* the panel is 0 high on its opening and closing frames. None under
|
|
54
|
+
* prefers-reduced-motion. CollapsibleSection's rows, and the rows under a NavItem.
|
|
55
|
+
*/
|
|
56
|
+
export const COLLAPSIBLE_PANEL_CLASSES = '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'
|
|
@@ -48,18 +48,18 @@ describe('the registry builds', () => {
|
|
|
48
48
|
expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
-
it('counts
|
|
51
|
+
it('counts 105 names over 65 files: 91 components, 13 helpers and 1 hook (NavTree joined on 2 October)', () => {
|
|
52
52
|
// The reconciliation GATES.md §24 explains. If this changes, that changes.
|
|
53
53
|
// The five `EDITOR_EXTENSIONS` count as components by their names; they are
|
|
54
54
|
// editor extensions, documented on SelectionToolbar's and BlockNodes' pages.
|
|
55
55
|
const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
|
|
56
|
-
expect(kinds).toEqual({ component:
|
|
57
|
-
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(
|
|
56
|
+
expect(kinds).toEqual({ component: 91, helper: 13, hook: 1 })
|
|
57
|
+
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(65)
|
|
58
58
|
})
|
|
59
59
|
|
|
60
60
|
it('gives every entry a purpose, from its own page or from the comment above it', () => {
|
|
61
61
|
expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
|
|
62
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(
|
|
62
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(62)
|
|
63
63
|
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(43)
|
|
64
64
|
})
|
|
65
65
|
|
package/stories/Choosing.mdx
CHANGED
|
@@ -116,6 +116,7 @@ the **floating** TopBar goes with the **Rail** and the content card.
|
|
|
116
116
|
| The floating frame: bar over the content, rail, rounded card | **AppShell** `floating` |
|
|
117
117
|
| The 52px bar alone | **TopBar** — `solid` in flow, or `floating` over the content |
|
|
118
118
|
| A 240px navigation column | **Sidebar**, filled with **NavItem** rows |
|
|
119
|
+
| Groups of rows in that column, rows under rows, from one nested list | **NavTree**, inside the **Sidebar** |
|
|
119
120
|
| A 64px icon strip | **Rail**, filled with **RailItem** tiles |
|
|
120
121
|
| The menu button and the logo | the TopBar's `menu` and `logo` slots — collapsing stays yours |
|
|
121
122
|
| The list beside the thing that is open | **ListColumn** — 290px, its name in a **ContainerHeader**, the list under it scrolling |
|