@estiva-app/ui 0.53.0 → 0.54.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/NavItem.d.ts +14 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/NavTree.d.ts +93 -13
- package/dist/NavTree.d.ts.map +1 -1
- package/dist/UnreadDot.d.ts +12 -0
- package/dist/UnreadDot.d.ts.map +1 -0
- 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/gates/{chunk-FIBZLYQI.js → chunk-SNYYAFZS.js} +8 -1
- package/dist/gates/chunk-SNYYAFZS.js.map +7 -0
- package/dist/gates/create-app.d.ts.map +1 -1
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/index.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +215 -141
- package/dist/index.js.map +4 -4
- package/dist/looks.d.ts +1 -1
- package/dist/looks.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +154 -11
- package/src/NavItem.mdx +14 -1
- package/src/NavItem.stories.tsx +15 -0
- package/src/NavItem.test.tsx +31 -0
- package/src/NavItem.tsx +49 -12
- package/src/NavTree.mdx +25 -0
- package/src/NavTree.stories.tsx +57 -1
- package/src/NavTree.test.tsx +78 -1
- package/src/NavTree.tsx +175 -40
- package/src/SectionLabel.stories.tsx +2 -3
- package/src/UnreadDot.mdx +57 -0
- package/src/UnreadDot.stories.tsx +22 -0
- package/src/UnreadDot.test.tsx +17 -0
- package/src/UnreadDot.tsx +17 -0
- package/src/eslint/no-native-title.ts +1 -1
- package/src/gates/create-app.test.ts +8 -0
- package/src/gates/create-app.ts +7 -0
- package/src/index.ts +2 -1
- package/src/looks.ts +2 -1
- package/src/registry/registry.test.ts +5 -5
- package/stories/Choosing.mdx +1 -0
- package/tailwind-preset.js +1 -0
- package/tokens.css +5 -0
- package/dist/gates/chunk-FIBZLYQI.js.map +0 -7
package/src/NavTree.test.tsx
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
8
8
|
import { cleanup, render, screen } from '@testing-library/react'
|
|
9
9
|
import userEvent from '@testing-library/user-event'
|
|
10
|
-
import { NavTree, type NavTreeGroup } from './NavTree'
|
|
10
|
+
import { NavTree, NavTreeSection, type NavTreeGroup } from './NavTree'
|
|
11
11
|
|
|
12
12
|
afterEach(cleanup)
|
|
13
13
|
|
|
@@ -52,3 +52,80 @@ describe('NavTree', () => {
|
|
|
52
52
|
expect(screen.queryAllByRole('link')).toHaveLength(0)
|
|
53
53
|
})
|
|
54
54
|
})
|
|
55
|
+
|
|
56
|
+
describe('NavTree read as it opens', () => {
|
|
57
|
+
it('a group being read draws no rows; a group’s message is said in their place', () => {
|
|
58
|
+
render(
|
|
59
|
+
<NavTree
|
|
60
|
+
groups={[
|
|
61
|
+
{ id: 'a', title: 'Group a', defaultOpen: true, nodes: null },
|
|
62
|
+
{ id: 'b', title: 'Group b', defaultOpen: true, nodes: [], message: 'Nothing in this yet.' },
|
|
63
|
+
]}
|
|
64
|
+
emptyMessage="Nothing here yet."
|
|
65
|
+
/>,
|
|
66
|
+
)
|
|
67
|
+
expect(screen.queryAllByRole('link')).toHaveLength(0)
|
|
68
|
+
expect(screen.getByText('Nothing in this yet.')).not.toBeNull()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('a row with hasChildren has its arrow, and says when it opens', async () => {
|
|
72
|
+
const onOpenChange = vi.fn()
|
|
73
|
+
render(
|
|
74
|
+
<NavTree
|
|
75
|
+
groups={[{ id: 'a', title: 'Group a', defaultOpen: true, nodes: [{ id: 'x', label: 'Item x', href: '#x', hasChildren: true, defaultOpen: false, onOpenChange }] }]}
|
|
76
|
+
emptyMessage="Nothing here yet."
|
|
77
|
+
/>,
|
|
78
|
+
)
|
|
79
|
+
await userEvent.click(screen.getByRole('button', { name: /Show what is under Item x/ }))
|
|
80
|
+
expect(onOpenChange).toHaveBeenCalledWith(true)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('the label’s and a group’s actions are drawn', () => {
|
|
84
|
+
render(
|
|
85
|
+
<NavTree
|
|
86
|
+
title="Groups"
|
|
87
|
+
titleActions={[{ icon: <span />, tooltip: 'New', onClick: () => {} }]}
|
|
88
|
+
groups={[{ id: 'a', title: 'Group a', nodes: [], actions: [{ icon: <span />, tooltip: 'Open', onClick: () => {} }] }]}
|
|
89
|
+
emptyMessage="Nothing here yet."
|
|
90
|
+
/>,
|
|
91
|
+
)
|
|
92
|
+
expect(screen.getByRole('button', { name: 'New' })).not.toBeNull()
|
|
93
|
+
expect(screen.getByRole('button', { name: 'Open' })).not.toBeNull()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('onIntent: the keyboard reaching a group says so at once', () => {
|
|
97
|
+
const onIntent = vi.fn()
|
|
98
|
+
render(<NavTree groups={[{ id: 'a', title: 'Group a', nodes: [], onIntent }]} emptyMessage="Nothing here yet." />)
|
|
99
|
+
screen.getByRole('button', { name: 'Group a' }).focus()
|
|
100
|
+
expect(onIntent).toHaveBeenCalled()
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('NavTree drawn by the app', () => {
|
|
105
|
+
it('draws NavTreeSections given as children, each with its rows, line or skeleton', () => {
|
|
106
|
+
render(
|
|
107
|
+
<NavTree title="Groups">
|
|
108
|
+
<NavTreeSection title="Group a"><a href="#a">Item a</a></NavTreeSection>
|
|
109
|
+
<NavTreeSection title="Group b" message="Nothing in this yet." />
|
|
110
|
+
<NavTreeSection title="Group c" loading />
|
|
111
|
+
</NavTree>,
|
|
112
|
+
)
|
|
113
|
+
expect(screen.getByRole('button', { name: 'Group a' })).not.toBeNull()
|
|
114
|
+
expect(screen.getByText('Item a')).not.toBeNull()
|
|
115
|
+
expect(screen.getByText('Nothing in this yet.')).not.toBeNull()
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('NavTree marks its groups', () => {
|
|
120
|
+
it('each group box carries data-nav-group with its id', () => {
|
|
121
|
+
const { container } = render(<NavTree groups={[{ id: 'g1', title: 'Group a', nodes: [] }]} emptyMessage="Nothing here yet." />)
|
|
122
|
+
expect(container.querySelector('[data-nav-group="g1"]')).not.toBeNull()
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('NavTree with no groups and no words', () => {
|
|
127
|
+
it('draws no line at all', () => {
|
|
128
|
+
const { container } = render(<NavTree groups={[]} />)
|
|
129
|
+
expect(container.textContent).toBe('')
|
|
130
|
+
})
|
|
131
|
+
})
|
package/src/NavTree.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useEffect, useRef, type MouseEvent, type ReactNode } from 'react'
|
|
2
2
|
import { CollapsibleSection } from './CollapsibleSection'
|
|
3
3
|
import { EmptyState } from './EmptyState'
|
|
4
4
|
import { NavItem } from './NavItem'
|
|
5
|
-
import { SectionHeader } from './SectionHeader'
|
|
5
|
+
import { SectionHeader, type SectionAction } from './SectionHeader'
|
|
6
6
|
import { SkeletonList } from './Skeleton'
|
|
7
7
|
|
|
8
8
|
/** One row of the tree, and the rows under it. */
|
|
@@ -18,40 +18,103 @@ export interface NavTreeNode {
|
|
|
18
18
|
countLabel?: string
|
|
19
19
|
/** Shown only while the row is pointed at or focused, left of the count: a neutral `Chip`, typically. */
|
|
20
20
|
hint?: ReactNode
|
|
21
|
+
/** Something new here: NavItem's `unread`. */
|
|
22
|
+
unread?: boolean
|
|
23
|
+
/** With `unread`: the warning badge in the dot's place. */
|
|
24
|
+
urgent?: boolean
|
|
21
25
|
/** The row's "More options" menu: `MenuItem`s. */
|
|
22
26
|
menu?: ReactNode
|
|
23
|
-
/**
|
|
24
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Rows under this one. `null` while they are being read: the row has its
|
|
29
|
+
* arrow, and a skeleton under it once open. Absent with `hasChildren`, the
|
|
30
|
+
* same — for rows read only when the row opens.
|
|
31
|
+
*/
|
|
32
|
+
children?: NavTreeNode[] | null
|
|
33
|
+
/** The row has rows under it that are not read yet: it gets its arrow, and `onOpenChange` says when to read them. */
|
|
34
|
+
hasChildren?: boolean
|
|
35
|
+
/** Said under the row, level with its rows, in place of them: "Nothing in this yet.", "This could not be read." The caller's words. */
|
|
36
|
+
childrenMessage?: string
|
|
25
37
|
defaultOpen?: boolean
|
|
38
|
+
/** The caller owns whether its rows are open. */
|
|
39
|
+
open?: boolean
|
|
40
|
+
onOpenChange?: (open: boolean) => void
|
|
26
41
|
}
|
|
27
42
|
|
|
28
43
|
/** A group heading over its rows: the top of the tree. */
|
|
29
44
|
export interface NavTreeGroup {
|
|
30
45
|
id: string
|
|
31
46
|
title: string
|
|
32
|
-
|
|
47
|
+
/** The rows. `null` while they are being read: a skeleton in their place. */
|
|
48
|
+
nodes: NavTreeNode[] | null
|
|
49
|
+
/** Said in the rows' place, level with them: "Nothing in this yet.", "This could not be read." The caller's words. */
|
|
50
|
+
message?: string
|
|
51
|
+
/** Beside the title, always shown: an `UnreadDot`, typically. */
|
|
52
|
+
trailing?: ReactNode
|
|
53
|
+
/** Beside the title, shown on hover or focus: "Open", typically. */
|
|
54
|
+
actions?: SectionAction[]
|
|
55
|
+
defaultOpen?: boolean
|
|
56
|
+
/** The caller owns whether the group is open. */
|
|
57
|
+
open?: boolean
|
|
58
|
+
onOpenChange?: (open: boolean) => void
|
|
59
|
+
/**
|
|
60
|
+
* The pointer rests on the group, or the keyboard reaches it: read its rows
|
|
61
|
+
* now, so opening it draws them. Resting, not passing — a pointer sweeping
|
|
62
|
+
* down the column calls nothing.
|
|
63
|
+
*/
|
|
64
|
+
onIntent?: () => void
|
|
33
65
|
}
|
|
34
66
|
|
|
35
67
|
export interface NavTreeProps {
|
|
36
68
|
/** The quiet label over the groups. Absent, none is drawn. */
|
|
37
69
|
title?: string
|
|
38
|
-
/**
|
|
39
|
-
|
|
70
|
+
/** Beside the label, always shown: "New", typically. */
|
|
71
|
+
titleActions?: SectionAction[]
|
|
72
|
+
/**
|
|
73
|
+
* `null` while the first read is on its way: a skeleton in the groups' place.
|
|
74
|
+
* Absent when the app draws its own groups as `children`.
|
|
75
|
+
*/
|
|
76
|
+
groups?: NavTreeGroup[] | null
|
|
77
|
+
/**
|
|
78
|
+
* The groups, drawn by the app: `NavTreeSection`s with `NavItem` rows — for
|
|
79
|
+
* an app whose groups and rows each read their own data. Used when `groups`
|
|
80
|
+
* is absent.
|
|
81
|
+
*/
|
|
82
|
+
children?: ReactNode
|
|
40
83
|
/** The row that is current: `active`, and its group starts open. */
|
|
41
84
|
selected?: string
|
|
42
85
|
/** A row was clicked. A router app prevents the default and navigates. */
|
|
43
86
|
onSelect?: (node: NavTreeNode, event: MouseEvent<HTMLAnchorElement>) => void
|
|
44
|
-
/** What to say when
|
|
45
|
-
emptyMessage
|
|
87
|
+
/** What to say when `groups` is empty. The words are the caller's; absent, nothing is said. */
|
|
88
|
+
emptyMessage?: string
|
|
46
89
|
/** Remember each group open or closed in this browser, under this key and the group's id. The app prefixes it. */
|
|
47
90
|
storageKey?: string
|
|
48
91
|
}
|
|
49
92
|
|
|
50
|
-
|
|
51
|
-
|
|
93
|
+
/** How long the pointer rests on a group before `onIntent` (Peek's FolderTree, 150ms). */
|
|
94
|
+
export const NAV_TREE_INTENT_MS = 150
|
|
95
|
+
|
|
96
|
+
function holds(nodes: NavTreeNode[] | null, id: string | undefined): boolean {
|
|
97
|
+
return !!nodes && id !== undefined && nodes.some((node) => node.id === id || holds(node.children ?? null, id))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A line in the rows' place: level with the rows, which pad themselves (Katerina, 14 September). */
|
|
101
|
+
function Line({ message }: { message: string }) {
|
|
102
|
+
return (
|
|
103
|
+
<div className="px-2 py-1">
|
|
104
|
+
<EmptyState scope="section" message={message} />
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
52
107
|
}
|
|
53
108
|
|
|
54
109
|
function Row({ node, selected, onSelect }: { node: NavTreeNode } & Pick<NavTreeProps, 'selected' | 'onSelect'>) {
|
|
110
|
+
const under =
|
|
111
|
+
node.childrenMessage !== undefined ? (
|
|
112
|
+
<Line message={node.childrenMessage} />
|
|
113
|
+
) : node.children?.length ? (
|
|
114
|
+
node.children.map((child) => <Row key={child.id} node={child} selected={selected} onSelect={onSelect} />)
|
|
115
|
+
) : node.children === null || (node.hasChildren && node.children === undefined) ? (
|
|
116
|
+
<SkeletonList rows={2} />
|
|
117
|
+
) : undefined
|
|
55
118
|
return (
|
|
56
119
|
<NavItem
|
|
57
120
|
href={node.href}
|
|
@@ -60,52 +123,124 @@ function Row({ node, selected, onSelect }: { node: NavTreeNode } & Pick<NavTreeP
|
|
|
60
123
|
count={node.count}
|
|
61
124
|
countLabel={node.countLabel}
|
|
62
125
|
hint={node.hint}
|
|
126
|
+
unread={node.unread}
|
|
127
|
+
urgent={node.urgent}
|
|
63
128
|
menu={node.menu}
|
|
64
129
|
active={node.id === selected}
|
|
65
130
|
defaultOpen={node.defaultOpen ?? true}
|
|
131
|
+
open={node.open}
|
|
132
|
+
onOpenChange={node.onOpenChange}
|
|
66
133
|
onClick={onSelect ? (event) => onSelect(node, event) : undefined}
|
|
67
134
|
>
|
|
68
|
-
{
|
|
69
|
-
? node.children.map((child) => <Row key={child.id} node={child} selected={selected} onSelect={onSelect} />)
|
|
70
|
-
: undefined}
|
|
135
|
+
{under}
|
|
71
136
|
</NavItem>
|
|
72
137
|
)
|
|
73
138
|
}
|
|
74
139
|
|
|
140
|
+
function Group({ group, selected, onSelect, storageKey }: { group: NavTreeGroup } & Pick<NavTreeProps, 'selected' | 'onSelect' | 'storageKey'>) {
|
|
141
|
+
return (
|
|
142
|
+
<NavTreeSection
|
|
143
|
+
id={group.id}
|
|
144
|
+
title={group.title}
|
|
145
|
+
message={group.message}
|
|
146
|
+
loading={group.nodes === null}
|
|
147
|
+
trailing={group.trailing}
|
|
148
|
+
actions={group.actions}
|
|
149
|
+
defaultOpen={group.defaultOpen ?? holds(group.nodes, selected)}
|
|
150
|
+
open={group.open}
|
|
151
|
+
onOpenChange={group.onOpenChange}
|
|
152
|
+
storageKey={storageKey ? `${storageKey}.${group.id}` : undefined}
|
|
153
|
+
onIntent={group.onIntent}
|
|
154
|
+
>
|
|
155
|
+
{group.nodes?.map((node) => <Row key={node.id} node={node} selected={selected} onSelect={onSelect} />)}
|
|
156
|
+
</NavTreeSection>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface NavTreeSectionProps {
|
|
161
|
+
title: string
|
|
162
|
+
/** Marks the group's box as `data-nav-group`, for tests and photo runs to find it. */
|
|
163
|
+
id?: string
|
|
164
|
+
/** Said in the rows' place, level with them. The caller's words. */
|
|
165
|
+
message?: string
|
|
166
|
+
/** Beside the title, always shown: an `UnreadDot`, typically. */
|
|
167
|
+
trailing?: ReactNode
|
|
168
|
+
/** Beside the title, shown on hover or focus: "Open", typically. */
|
|
169
|
+
actions?: SectionAction[]
|
|
170
|
+
/** Open unless told otherwise. */
|
|
171
|
+
defaultOpen?: boolean
|
|
172
|
+
/** The caller owns whether the group is open. */
|
|
173
|
+
open?: boolean
|
|
174
|
+
onOpenChange?: (open: boolean) => void
|
|
175
|
+
/** The pointer rests on the group, or the keyboard reaches it: read its rows now. */
|
|
176
|
+
onIntent?: () => void
|
|
177
|
+
/** Its rows are being read: a skeleton in their place. */
|
|
178
|
+
loading?: boolean
|
|
179
|
+
/** Remember it open or closed in this browser, under this key. */
|
|
180
|
+
storageKey?: string
|
|
181
|
+
/** The rows: `NavItem`s. */
|
|
182
|
+
children?: ReactNode
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* One group of a `NavTree`, for an app that draws its own: the standard
|
|
187
|
+
* heading folding its rows, lightly indented under its title, with the
|
|
188
|
+
* group's line, skeleton, actions and `onIntent`. `NavTree` draws these from
|
|
189
|
+
* `groups`; an app whose groups each read their own data draws them itself,
|
|
190
|
+
* as `NavTree`'s children.
|
|
191
|
+
*/
|
|
192
|
+
export function NavTreeSection({ id, title, message, loading = false, trailing, actions, defaultOpen = true, open, onOpenChange, storageKey, onIntent, children }: NavTreeSectionProps) {
|
|
193
|
+
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
194
|
+
useEffect(() => () => clearTimeout(timer.current), [])
|
|
195
|
+
const intent = onIntent
|
|
196
|
+
return (
|
|
197
|
+
<div
|
|
198
|
+
className="shrink-0"
|
|
199
|
+
data-nav-group={id}
|
|
200
|
+
onPointerEnter={intent ? () => { clearTimeout(timer.current); timer.current = setTimeout(intent, NAV_TREE_INTENT_MS) } : undefined}
|
|
201
|
+
onPointerLeave={intent ? () => clearTimeout(timer.current) : undefined}
|
|
202
|
+
onFocus={intent}
|
|
203
|
+
>
|
|
204
|
+
<CollapsibleSection
|
|
205
|
+
title={title}
|
|
206
|
+
indent
|
|
207
|
+
defaultOpen={defaultOpen}
|
|
208
|
+
open={open}
|
|
209
|
+
onOpenChange={onOpenChange}
|
|
210
|
+
storageKey={storageKey}
|
|
211
|
+
trailing={trailing}
|
|
212
|
+
actions={actions}
|
|
213
|
+
className="mt-2 shrink-0"
|
|
214
|
+
contentClassName="gap-px"
|
|
215
|
+
>
|
|
216
|
+
{message !== undefined ? <Line message={message} /> : loading ? <SkeletonList rows={3} /> : children}
|
|
217
|
+
</CollapsibleSection>
|
|
218
|
+
</div>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
75
222
|
/**
|
|
76
223
|
* A sidebar's tree, from one nested list (Katerina, 2 October: Leaf's folder
|
|
77
|
-
* tree): a quiet label, then each
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
224
|
+
* tree, then Peek's and Ship's): a quiet label with its actions, then each
|
|
225
|
+
* group as the standard heading folding its rows, lightly indented under its
|
|
226
|
+
* title; rows under rows to any depth, each row a link whose icon becomes the
|
|
227
|
+
* arrow that folds what is under it. Groups and rows can be read when they
|
|
228
|
+
* open. Only the group that holds the current row starts open, unless the
|
|
229
|
+
* caller owns it.
|
|
81
230
|
*/
|
|
82
|
-
export function NavTree({ title, groups, selected, onSelect, emptyMessage, storageKey }: NavTreeProps) {
|
|
231
|
+
export function NavTree({ title, titleActions, groups, children, selected, onSelect, emptyMessage = '', storageKey }: NavTreeProps) {
|
|
83
232
|
return (
|
|
84
233
|
<>
|
|
85
|
-
{title && <SectionHeader title={title} look="quiet" className="mt-4 shrink-0" />}
|
|
86
|
-
{groups ===
|
|
234
|
+
{title && <SectionHeader title={title} look="quiet" showActions="always" actions={titleActions} className="mt-4 shrink-0" />}
|
|
235
|
+
{groups === undefined ? (
|
|
236
|
+
children
|
|
237
|
+
) : groups === null ? (
|
|
87
238
|
<SkeletonList rows={5} />
|
|
88
239
|
) : groups.length === 0 ? (
|
|
89
|
-
//
|
|
90
|
-
<
|
|
91
|
-
<EmptyState scope="section" message={emptyMessage} />
|
|
92
|
-
</div>
|
|
240
|
+
// No words, no line: an app that says nothing when it has no groups.
|
|
241
|
+
emptyMessage ? <Line message={emptyMessage} /> : null
|
|
93
242
|
) : (
|
|
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
|
-
))
|
|
243
|
+
groups.map((group) => <Group key={group.id} group={group} selected={selected} onSelect={onSelect} storageKey={storageKey} />)
|
|
109
244
|
)}
|
|
110
245
|
</>
|
|
111
246
|
)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { NavItem } from './NavItem'
|
|
2
3
|
import { SectionLabel } from './SectionLabel'
|
|
3
4
|
|
|
4
5
|
/** Every section title renders through this span — a style change is one edit. Under Signal it becomes the mono uppercase micro-label. */
|
|
@@ -22,9 +23,7 @@ export const HeadingAList: Story = {
|
|
|
22
23
|
<SectionLabel>Recent</SectionLabel>
|
|
23
24
|
</div>
|
|
24
25
|
{['Quarterly plan', 'Reading list', 'Archive'].map((row) => (
|
|
25
|
-
<
|
|
26
|
-
{row}
|
|
27
|
-
</div>
|
|
26
|
+
<NavItem key={row} href="#" label={row} />
|
|
28
27
|
))}
|
|
29
28
|
</div>
|
|
30
29
|
),
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as UnreadDotStories from './UnreadDot.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={UnreadDotStories} />
|
|
5
|
+
|
|
6
|
+
# UnreadDot
|
|
7
|
+
|
|
8
|
+
The accent dot that says "something new here": a 6px disc centred in a 24px
|
|
9
|
+
slot, so it sits exactly where a control would and the row does not shift
|
|
10
|
+
when one replaces the other.
|
|
11
|
+
|
|
12
|
+
<Canvas of={UnreadDotStories.InARow} />
|
|
13
|
+
|
|
14
|
+
## When
|
|
15
|
+
|
|
16
|
+
- On any row or card that can be unread: a place in a sidebar, a
|
|
17
|
+
conversation, a reply.
|
|
18
|
+
- Anywhere a control appears on hover and the dot is what is there at rest.
|
|
19
|
+
The 24px slot keeps the two the same size.
|
|
20
|
+
- In a sidebar row, pass **NavItem** `unread` rather than placing it: the row
|
|
21
|
+
draws it, and brightens its words.
|
|
22
|
+
|
|
23
|
+
## When not
|
|
24
|
+
|
|
25
|
+
- As the only thing that says "unread". A coloured disc reads as nothing to a
|
|
26
|
+
screen reader, so the row that draws it must say "unread" in its own
|
|
27
|
+
accessible name. This part carries no label of its own.
|
|
28
|
+
- For a count. It says that there is something new, never how much: a count
|
|
29
|
+
is **NavItem** `count` or a **Chip**.
|
|
30
|
+
|
|
31
|
+
## How
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
import { UnreadDot } from '@estiva-app/ui'
|
|
35
|
+
|
|
36
|
+
<div className="flex items-center">
|
|
37
|
+
<span>{title}</span>
|
|
38
|
+
{isUnread && <UnreadDot />}
|
|
39
|
+
</div>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- It renders unconditionally, so the caller decides whether there is
|
|
43
|
+
anything new.
|
|
44
|
+
|
|
45
|
+
## What it owns
|
|
46
|
+
|
|
47
|
+
It does these by itself. Write none of them around it.
|
|
48
|
+
|
|
49
|
+
| It does | So you never write | Refused when written by hand |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| The 6px disc in a 24px slot | a sized box around a round div | |
|
|
52
|
+
| `data-unread`, the attribute tests and photo runs select on | a marker of your own | |
|
|
53
|
+
| Keeps its size in a tight row | `shrink-0` | |
|
|
54
|
+
|
|
55
|
+
## Props
|
|
56
|
+
|
|
57
|
+
<Controls of={UnreadDotStories.Default} />
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { UnreadDot } from './UnreadDot'
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Primitives/UnreadDot',
|
|
6
|
+
component: UnreadDot,
|
|
7
|
+
} satisfies Meta<typeof UnreadDot>
|
|
8
|
+
|
|
9
|
+
export default meta
|
|
10
|
+
type Story = StoryObj<typeof meta>
|
|
11
|
+
|
|
12
|
+
export const Default: Story = {}
|
|
13
|
+
|
|
14
|
+
/** Beside a line of text, as a list row draws it: the slot keeps the row's height. */
|
|
15
|
+
export const InARow: Story = {
|
|
16
|
+
render: (args) => (
|
|
17
|
+
<div className="flex w-64 items-center gap-1 text-body-2 text-text-primary">
|
|
18
|
+
<span className="min-w-0 flex-1 truncate">A row with something new</span>
|
|
19
|
+
<UnreadDot {...args} />
|
|
20
|
+
</div>
|
|
21
|
+
),
|
|
22
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/** What the page claims: the 6px disc in a 24px slot, marked for tests and photo runs. */
|
|
3
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
4
|
+
import { cleanup, render } from '@testing-library/react'
|
|
5
|
+
import { UnreadDot } from './UnreadDot'
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup)
|
|
8
|
+
|
|
9
|
+
describe('UnreadDot', () => {
|
|
10
|
+
it('is a 24px slot holding a 6px disc, marked data-unread', () => {
|
|
11
|
+
const { container } = render(<UnreadDot />)
|
|
12
|
+
const slot = container.firstElementChild as HTMLElement
|
|
13
|
+
expect(slot.hasAttribute('data-unread')).toBe(true)
|
|
14
|
+
expect(slot.className).toContain('w-6')
|
|
15
|
+
expect((slot.firstElementChild as HTMLElement).className).toContain('w-1.5')
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { cn } from './cn'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The accent dot that says "something new here": a 6px disc centred in a
|
|
5
|
+
* 24px slot, so it sits where a control would and the row does not shift when
|
|
6
|
+
* one replaces the other (Peek's UnreadDot, moved in on 2 October, Katerina).
|
|
7
|
+
*
|
|
8
|
+
* Purely visual: the row that draws it says "unread" in its own accessible
|
|
9
|
+
* name, because a coloured disc reads as nothing to a screen reader.
|
|
10
|
+
*/
|
|
11
|
+
export function UnreadDot({ className }: { className?: string }) {
|
|
12
|
+
return (
|
|
13
|
+
<div className={cn('flex h-6 w-6 shrink-0 items-center justify-center', className)} data-unread>
|
|
14
|
+
<div className="h-1.5 w-1.5 rounded-full bg-accent-primary" />
|
|
15
|
+
</div>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
@@ -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', 'NavTree', 'Panel', 'SectionHeader'] as const
|
|
11
|
+
export const PARTS_WITH_A_TITLE = ['CollapsibleSection', 'ConfirmDialog', 'ContainerHeader', 'DialogShell', 'ListColumn', 'NavTree', 'NavTreeSection', '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 =>
|
|
@@ -77,6 +77,14 @@ describe('create-estiva-app', () => {
|
|
|
77
77
|
expect(files['docs/GATES-DEBT.md']).toContain('Nothing.')
|
|
78
78
|
})
|
|
79
79
|
|
|
80
|
+
it('keeps the dev server on its registered port rather than moving to the next one', () => {
|
|
81
|
+
expect(files['vite.config.ts']).toContain('server: { strictPort: true }')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('runs its tests with no sign-in and no relay, whatever .env.local holds', () => {
|
|
85
|
+
expect(files['vite.config.ts']).toContain("env: { VITE_ESTIVA_ID_ORIGIN: '', VITE_ESTIVA_ID_CLIENT_ID: '', VITE_RELAY_URL: '' }")
|
|
86
|
+
})
|
|
87
|
+
|
|
80
88
|
it('never points at the real Estiva ID by itself', () => {
|
|
81
89
|
expect(files['.env.example']).toContain('VITE_ESTIVA_ID_ORIGIN=\n')
|
|
82
90
|
expect(Object.values(files).join('\n')).not.toContain('id.estiva.app')
|
package/src/gates/create-app.ts
CHANGED
|
@@ -207,8 +207,15 @@ export default defineConfig({
|
|
|
207
207
|
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
|
208
208
|
dedupe: ['react', 'react-dom'],
|
|
209
209
|
},
|
|
210
|
+
// Sign-in and the relay both name this app by its exact origin,
|
|
211
|
+
// http://localhost:5173. On a busy port Vite would otherwise move to :5174,
|
|
212
|
+
// which both refuse, and the failure would surface at sign-in instead of here.
|
|
213
|
+
server: { strictPort: true },
|
|
210
214
|
test: {
|
|
211
215
|
environment: 'jsdom',
|
|
216
|
+
// Vitest reads .env.local like the dev server does. Without this, the tests
|
|
217
|
+
// would run signed in to the real relay as soon as .env.local is filled in.
|
|
218
|
+
env: { VITE_ESTIVA_ID_ORIGIN: '', VITE_ESTIVA_ID_CLIENT_ID: '', VITE_RELAY_URL: '' },
|
|
212
219
|
},
|
|
213
220
|
})
|
|
214
221
|
`,
|
package/src/index.ts
CHANGED
|
@@ -70,7 +70,8 @@ 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 {
|
|
73
|
+
export { UnreadDot } from './UnreadDot'
|
|
74
|
+
export { NavTree, NavTreeSection, type NavTreeGroup, type NavTreeNode, type NavTreeProps, type NavTreeSectionProps } from './NavTree'
|
|
74
75
|
export { Popover, type PopoverProps } from './Popover'
|
|
75
76
|
export { SuggestionMenu, type SuggestionMenuHandle, type SuggestionMenuProps, type SuggestionMenuRow, type SuggestionMenuSection } from './SuggestionMenu'
|
|
76
77
|
export { ScrollArea, type ScrollAreaProps } from './ScrollArea'
|
package/src/looks.ts
CHANGED
|
@@ -44,7 +44,8 @@ export const CHIP_TEXT_CLASSES = 'text-chip signal:font-mono signal:text-small s
|
|
|
44
44
|
export const BARE_INPUT_CLASSES = 'bg-transparent text-text-primary placeholder:text-text-muted outline-none'
|
|
45
45
|
|
|
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
|
+
// 8px corners, a MenuItem row's (Katerina, 2 October).
|
|
48
|
+
export const SIDEBAR_ROW_CLASSES = 'rounded-lg transition-colors'
|
|
48
49
|
export const SIDEBAR_ROW_TEXT_CLASSES = 'text-text-secondary hover:text-text-primary'
|
|
49
50
|
|
|
50
51
|
/**
|
|
@@ -48,20 +48,20 @@ 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 122 names over 67 files: 98 components, 23 helpers and 1 hook (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October)', () => {
|
|
52
52
|
// The reconciliation GATES.md §24 explains. If this changes, that changes.
|
|
53
53
|
// The ten `EDITOR_EXTENSIONS` count as components by their names; they are
|
|
54
54
|
// editor extensions, documented on SelectionToolbar's, BlockNodes' and
|
|
55
55
|
// ComposerTriggers' pages.
|
|
56
56
|
const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
|
|
57
|
-
expect(kinds).toEqual({ component:
|
|
58
|
-
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(
|
|
57
|
+
expect(kinds).toEqual({ component: 98, helper: 23, hook: 1 })
|
|
58
|
+
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(67)
|
|
59
59
|
})
|
|
60
60
|
|
|
61
61
|
it('gives every entry a purpose, from its own page or from the comment above it', () => {
|
|
62
62
|
expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
|
|
63
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(
|
|
64
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(
|
|
63
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(63)
|
|
64
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(59)
|
|
65
65
|
})
|
|
66
66
|
|
|
67
67
|
it('takes a name with a page of its own from the page, and a name without one from the code', () => {
|
package/stories/Choosing.mdx
CHANGED
|
@@ -82,6 +82,7 @@ fork freely, owe nothing back, mention it on the package's ticket.
|
|
|
82
82
|
| The bar across the top of a column: its title, and the buttons that act on the whole column | **ContainerHeader** — inside the package's columns; in an app, the header comes with **Panel** or **ListColumn** |
|
|
83
83
|
| A box that can hold more than fits — a list that grows with its data counts, even when today's data fits | **ScrollArea** — its bar is drawn over the content and takes no width |
|
|
84
84
|
| A status, a count or a category, in a small pill | **Chip** — six colour types; the colour is the message, the label makes it legible |
|
|
85
|
+
| Something new on a row or a card, no count | **UnreadDot** — the 6px dot in a 24px slot; in a sidebar row, **NavItem** `unread` draws it |
|
|
85
86
|
| One thing — a conversation, a project, an object — as a box you can pick | **Card** — the fill by what it sits on; `href` for a link, `hover="fill"` in a feed |
|
|
86
87
|
| A section heading, chevron, hover actions | **SectionHeader** |
|
|
87
88
|
| A group of rows that opens and closes, and stays how you left it | **CollapsibleSection** — `storageKey` remembers |
|
package/tailwind-preset.js
CHANGED
|
@@ -149,6 +149,7 @@ export default {
|
|
|
149
149
|
'bg-hover': 'var(--bg-hover)',
|
|
150
150
|
'bg-selected': 'var(--bg-selected)',
|
|
151
151
|
'bg-active': 'var(--bg-active)',
|
|
152
|
+
'bg-nav-active': 'var(--bg-nav-active)',
|
|
152
153
|
'bg-disabled': 'var(--bg-disabled)',
|
|
153
154
|
'bg-tooltip': 'var(--bg-tooltip)',
|
|
154
155
|
'bg-tooltip-key': 'var(--bg-tooltip-key)',
|
package/tokens.css
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
--bg-hover: #E9E9EB;
|
|
30
30
|
--bg-selected: #E4E4E7;
|
|
31
31
|
--bg-active: #D4D4D8;
|
|
32
|
+
--bg-nav-active: #D4D4D8; /* the current row in a sidebar */
|
|
32
33
|
--bg-disabled: #F4F4F5;
|
|
33
34
|
|
|
34
35
|
/* text */
|
|
@@ -113,6 +114,7 @@
|
|
|
113
114
|
--bg-hover: #232326;
|
|
114
115
|
--bg-selected: #1F1F22;
|
|
115
116
|
--bg-active: #2D2D31;
|
|
117
|
+
--bg-nav-active: #2D2D31; /* the current row in a sidebar */
|
|
116
118
|
--bg-disabled: #1C1C1F;
|
|
117
119
|
|
|
118
120
|
/* text */
|
|
@@ -198,6 +200,7 @@
|
|
|
198
200
|
--bg-hover: rgba(255, 255, 255, 0.05);
|
|
199
201
|
--bg-selected: rgba(86, 200, 255, 0.11);
|
|
200
202
|
--bg-active: rgba(255, 255, 255, 0.08);
|
|
203
|
+
--bg-nav-active: rgba(86, 200, 255, 0.11); /* the current row in a sidebar: Peek's selected row (Katerina, 2 October) */
|
|
201
204
|
--bg-disabled: #191d24;
|
|
202
205
|
|
|
203
206
|
/* text */
|
|
@@ -285,6 +288,7 @@
|
|
|
285
288
|
--bg-hover: rgba(255, 255, 255, 0.05);
|
|
286
289
|
--bg-selected: rgba(75, 88, 212, 0.16);
|
|
287
290
|
--bg-active: rgba(255, 255, 255, 0.08);
|
|
291
|
+
--bg-nav-active: rgba(255, 255, 255, 0.08); /* the current row in a sidebar */
|
|
288
292
|
--bg-disabled: #16181f;
|
|
289
293
|
|
|
290
294
|
/* text */
|
|
@@ -390,6 +394,7 @@
|
|
|
390
394
|
--bg-hover: rgba(0, 0, 0, 0.064);
|
|
391
395
|
--bg-selected: #e5e5e6; /* --color-bg-tertiary */
|
|
392
396
|
--bg-active: #e1e1e2; /* the selected sidebar row (lch 89.54%) */
|
|
397
|
+
--bg-nav-active: #e1e1e2; /* the current row in a sidebar */
|
|
393
398
|
--bg-disabled: #efeff0; /* the base, as light has it */
|
|
394
399
|
|
|
395
400
|
/* text */
|