@estiva-app/ui 0.58.0 → 0.60.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/AppProvider.d.ts +47 -0
- package/dist/AppProvider.d.ts.map +1 -0
- package/dist/AppShell.d.ts +6 -2
- package/dist/AppShell.d.ts.map +1 -1
- package/dist/Avatar.d.ts +1 -1
- package/dist/Avatar.d.ts.map +1 -1
- package/dist/Chip.d.ts.map +1 -1
- package/dist/CommandPalette.d.ts.map +1 -1
- package/dist/ComposerTriggers.d.ts +13 -1
- package/dist/ComposerTriggers.d.ts.map +1 -1
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/InlineChip.d.ts.map +1 -1
- package/dist/Kbd.d.ts.map +1 -1
- package/dist/Link.d.ts +1 -1
- package/dist/Link.d.ts.map +1 -1
- package/dist/MembersDialog.d.ts +57 -0
- package/dist/MembersDialog.d.ts.map +1 -0
- package/dist/MembersPill.d.ts +22 -0
- package/dist/MembersPill.d.ts.map +1 -0
- package/dist/NavItem.d.ts +1 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/RailItem.d.ts +1 -1
- package/dist/RailItem.d.ts.map +1 -1
- package/dist/SearchInput.d.ts +3 -3
- package/dist/Tabs.d.ts +2 -0
- package/dist/Tabs.d.ts.map +1 -1
- package/dist/TopBar.d.ts +14 -3
- package/dist/TopBar.d.ts.map +1 -1
- package/dist/editor.js +4 -4
- package/dist/editor.js.map +2 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1332 -1143
- package/dist/index.js.map +4 -4
- package/dist/looks.d.ts +2 -0
- package/dist/looks.d.ts.map +1 -1
- package/dist/registry/{app-LDGOH6UW.js → app-X2GI4WTU.js} +2 -2
- package/dist/registry/app.d.ts.map +1 -1
- package/dist/registry/{chunk-TQ6UQER3.js → chunk-V67F474L.js} +8 -3
- package/dist/registry/chunk-V67F474L.js.map +7 -0
- package/dist/registry/cli.js +1 -1
- package/dist/registry/index.js +1 -1
- package/package.json +1 -1
- package/registry.json +234 -14
- package/skill/estiva-ui.md +2 -1
- package/src/AppProvider.mdx +69 -0
- package/src/AppProvider.stories.tsx +60 -0
- package/src/AppProvider.test.tsx +152 -0
- package/src/AppProvider.tsx +76 -0
- package/src/AppShell.mdx +4 -1
- package/src/AppShell.stories.tsx +5 -5
- package/src/AppShell.tsx +7 -3
- package/src/Avatar.tsx +7 -3
- package/src/Breadcrumb.mdx +2 -2
- package/src/Card.mdx +1 -1
- package/src/Chip.mdx +2 -2
- package/src/Chip.tsx +3 -1
- package/src/CommandPalette.tsx +2 -1
- package/src/ComposerTriggers.mdx +7 -0
- package/src/ComposerTriggers.stories.tsx +43 -4
- package/src/ComposerTriggers.test.tsx +33 -0
- package/src/ComposerTriggers.tsx +17 -5
- package/src/IconButton.mdx +2 -2
- package/src/IconButton.tsx +4 -1
- package/src/InlineChip.mdx +3 -3
- package/src/InlineChip.tsx +3 -1
- package/src/Kbd.tsx +4 -2
- package/src/Link.mdx +5 -3
- package/src/Link.tsx +4 -1
- package/src/MembersDialog.mdx +69 -0
- package/src/MembersDialog.stories.tsx +68 -0
- package/src/MembersDialog.test.tsx +69 -0
- package/src/MembersDialog.tsx +184 -0
- package/src/MembersPill.mdx +45 -0
- package/src/MembersPill.stories.tsx +38 -0
- package/src/MembersPill.tsx +35 -0
- package/src/NavItem.mdx +3 -3
- package/src/NavItem.tsx +4 -0
- package/src/NavTree.mdx +3 -2
- package/src/RailItem.mdx +3 -3
- package/src/RailItem.tsx +4 -1
- package/src/SearchInput.mdx +3 -4
- package/src/SearchInput.tsx +3 -3
- package/src/Sidebar.mdx +2 -2
- package/src/Tabs.mdx +5 -0
- package/src/Tabs.stories.tsx +15 -0
- package/src/Tabs.test.tsx +8 -0
- package/src/Tabs.tsx +12 -0
- package/src/TopBar.mdx +18 -9
- package/src/TopBar.stories.tsx +22 -4
- package/src/TopBar.test.tsx +97 -0
- package/src/TopBar.tsx +45 -4
- package/src/index.ts +3 -0
- package/src/looks.ts +3 -0
- package/src/pages.test.ts +3 -1
- package/src/registry/app.test.ts +24 -6
- package/src/registry/app.ts +21 -2
- package/src/registry/contract.test.ts +1 -1
- package/src/registry/gated.test.ts +1 -0
- package/src/registry/registry.test.ts +4 -4
- package/stories/Choosing.mdx +4 -0
- package/dist/registry/chunk-TQ6UQER3.js.map +0 -7
- /package/dist/registry/{app-LDGOH6UW.js.map → app-X2GI4WTU.js.map} +0 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The bar's own search field, pinned: given `onSearch` it draws a button with
|
|
4
|
+
* the words and the shortcut, a click and Ctrl+K or Cmd+K anywhere call it,
|
|
5
|
+
* a `search` of the caller's own still takes the centre, and without either
|
|
6
|
+
* the bar draws nothing there and leaves the keys alone.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, 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 { AppShell } from './AppShell'
|
|
12
|
+
import { TopBar } from './TopBar'
|
|
13
|
+
|
|
14
|
+
afterEach(cleanup)
|
|
15
|
+
|
|
16
|
+
const press = (init: KeyboardEventInit) => {
|
|
17
|
+
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })
|
|
18
|
+
window.dispatchEvent(event)
|
|
19
|
+
return event
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('TopBar search', () => {
|
|
23
|
+
it('draws a search field that opens the search', async () => {
|
|
24
|
+
const onSearch = vi.fn()
|
|
25
|
+
render(<TopBar onSearch={onSearch} searchPlaceholder="Search Peek..." />)
|
|
26
|
+
const field = screen.getByRole('button', { name: /Search Peek\.\.\./ })
|
|
27
|
+
expect(field.textContent).toMatch(/Ctrl\+K|Cmd\+K/)
|
|
28
|
+
await userEvent.click(field)
|
|
29
|
+
expect(onSearch).toHaveBeenCalledTimes(1)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('says "Search…" when given no words', () => {
|
|
33
|
+
render(<TopBar onSearch={() => {}} />)
|
|
34
|
+
expect(screen.getByRole('button', { name: /Search…/ })).toBeTruthy()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it.each([
|
|
38
|
+
['Ctrl+K', { ctrlKey: true, key: 'k' }],
|
|
39
|
+
['Cmd+K', { metaKey: true, key: 'k' }],
|
|
40
|
+
['Ctrl+Shift+K', { ctrlKey: true, shiftKey: true, key: 'K' }],
|
|
41
|
+
])('opens the search on %s anywhere', (_name, init) => {
|
|
42
|
+
const onSearch = vi.fn()
|
|
43
|
+
render(<TopBar onSearch={onSearch} />)
|
|
44
|
+
const event = press(init)
|
|
45
|
+
expect(onSearch).toHaveBeenCalledTimes(1)
|
|
46
|
+
expect(event.defaultPrevented).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('calls the latest handler', () => {
|
|
50
|
+
const first = vi.fn()
|
|
51
|
+
const second = vi.fn()
|
|
52
|
+
const { rerender } = render(<TopBar onSearch={first} />)
|
|
53
|
+
rerender(<TopBar onSearch={second} />)
|
|
54
|
+
press({ ctrlKey: true, key: 'k' })
|
|
55
|
+
expect(first).not.toHaveBeenCalled()
|
|
56
|
+
expect(second).toHaveBeenCalledTimes(1)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('leaves K alone without a modifier, and the keys alone without onSearch', () => {
|
|
60
|
+
const onSearch = vi.fn()
|
|
61
|
+
const { unmount } = render(<TopBar onSearch={onSearch} />)
|
|
62
|
+
press({ key: 'k' })
|
|
63
|
+
expect(onSearch).not.toHaveBeenCalled()
|
|
64
|
+
unmount()
|
|
65
|
+
render(<TopBar />)
|
|
66
|
+
expect(press({ ctrlKey: true, key: 'k' }).defaultPrevented).toBe(false)
|
|
67
|
+
expect(screen.queryByRole('button')).toBeNull()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('stops listening once it is gone', () => {
|
|
71
|
+
const onSearch = vi.fn()
|
|
72
|
+
const { unmount } = render(<TopBar onSearch={onSearch} />)
|
|
73
|
+
unmount()
|
|
74
|
+
press({ ctrlKey: true, key: 'k' })
|
|
75
|
+
expect(onSearch).not.toHaveBeenCalled()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("draws the caller's own centre instead, and still answers the keys", () => {
|
|
79
|
+
const onSearch = vi.fn()
|
|
80
|
+
render(<TopBar onSearch={onSearch} search={<span>own</span>} />)
|
|
81
|
+
expect(screen.getByText('own')).toBeTruthy()
|
|
82
|
+
expect(screen.queryByRole('button')).toBeNull()
|
|
83
|
+
press({ ctrlKey: true, key: 'k' })
|
|
84
|
+
expect(onSearch).toHaveBeenCalledTimes(1)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('reaches the bar through AppShell', async () => {
|
|
88
|
+
const onSearch = vi.fn()
|
|
89
|
+
render(
|
|
90
|
+
<AppShell variant="floating" nav={<nav />} onSearch={onSearch} searchPlaceholder="Search Peek...">
|
|
91
|
+
<p>page</p>
|
|
92
|
+
</AppShell>,
|
|
93
|
+
)
|
|
94
|
+
await userEvent.click(screen.getByRole('button', { name: /Search Peek\.\.\./ }))
|
|
95
|
+
expect(onSearch).toHaveBeenCalledTimes(1)
|
|
96
|
+
})
|
|
97
|
+
})
|
package/src/TopBar.tsx
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useEffect, useRef, type ReactNode } from 'react'
|
|
2
|
+
import { Button as BaseButton } from '@base-ui/react/button'
|
|
2
3
|
import { cn } from './cn'
|
|
4
|
+
import { Kbd } from './Kbd'
|
|
5
|
+
import { FIELD_SHELL_CLASSES } from './looks'
|
|
6
|
+
import { shortcutLabel } from './shortcuts'
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* The 52px top bar, in three manners:
|
|
@@ -21,6 +25,13 @@ import { cn } from './cn'
|
|
|
21
25
|
* collapses) and `logo` (the mark or name beside it; Katerina,
|
|
22
26
|
* 2026-09-02). This component does not know who you are or what is being
|
|
23
27
|
* searched; it only knows where those go.
|
|
28
|
+
*
|
|
29
|
+
* **Search** (Katerina, 8 October: "topbar has a variant with a search input,
|
|
30
|
+
* I assumed it would use it"). Given `onSearch`, the bar draws the search
|
|
31
|
+
* field itself — `SearchInput`'s look, 290px, as a button that opens what the
|
|
32
|
+
* app searches with — and Ctrl+K (Cmd+K on a Mac) presses it from anywhere.
|
|
33
|
+
* Before, every app drew its own field and listened for the keys itself.
|
|
34
|
+
* A `search` of the caller's own still takes the centre.
|
|
24
35
|
*/
|
|
25
36
|
export interface TopBarProps {
|
|
26
37
|
variant?: 'solid' | 'floating' | 'inset'
|
|
@@ -28,15 +39,45 @@ export interface TopBarProps {
|
|
|
28
39
|
menu?: ReactNode
|
|
29
40
|
/** Beside the menu button: the app's logo mark, or its name as text. */
|
|
30
41
|
logo?: ReactNode
|
|
31
|
-
/** The centre:
|
|
42
|
+
/** The centre: something of the caller's own. Without it, and with `onSearch`, the bar draws its search field. */
|
|
32
43
|
search?: ReactNode
|
|
44
|
+
/** What searching opens — the app's command launcher. Called on a click on the search field, and on Ctrl+K (Cmd+K) anywhere. */
|
|
45
|
+
onSearch?: () => void
|
|
46
|
+
/** The words in the bar's own search field. Default "Search…". */
|
|
47
|
+
searchPlaceholder?: string
|
|
33
48
|
/** The right cluster — an `IdentityMenu` and its neighbours. */
|
|
34
49
|
right?: ReactNode
|
|
35
50
|
className?: string
|
|
36
51
|
}
|
|
37
52
|
|
|
38
|
-
export function TopBar({ variant = 'solid', menu, logo, search, right, className }: TopBarProps) {
|
|
53
|
+
export function TopBar({ variant = 'solid', menu, logo, search, onSearch, searchPlaceholder = 'Search…', right, className }: TopBarProps) {
|
|
39
54
|
const floating = variant === 'floating'
|
|
55
|
+
// The latest handler, so the key listener is set once rather than on every render.
|
|
56
|
+
const searchRef = useRef(onSearch)
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
searchRef.current = onSearch
|
|
59
|
+
})
|
|
60
|
+
const searches = onSearch !== undefined
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!searches) return
|
|
63
|
+
const onKey = (event: KeyboardEvent) => {
|
|
64
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
|
65
|
+
event.preventDefault()
|
|
66
|
+
searchRef.current?.()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// @estiva-escape(no-hand-rolled-behaviour): Ctrl+K opens search from anywhere on the page, which is a page-wide shortcut and not a floating part; no part listens for a key across the page
|
|
70
|
+
window.addEventListener('keydown', onKey)
|
|
71
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
72
|
+
}, [searches])
|
|
73
|
+
const centre =
|
|
74
|
+
search ??
|
|
75
|
+
(onSearch && (
|
|
76
|
+
<BaseButton type="button" onClick={onSearch} className={cn('flex w-[290px] cursor-pointer items-center gap-2 px-3 py-2', FIELD_SHELL_CLASSES)}>
|
|
77
|
+
<span className="min-w-0 flex-1 truncate text-left text-input-value text-text-muted">{searchPlaceholder}</span>
|
|
78
|
+
<Kbd>{shortcutLabel('Mod-K')}</Kbd>
|
|
79
|
+
</BaseButton>
|
|
80
|
+
))
|
|
40
81
|
return (
|
|
41
82
|
<header
|
|
42
83
|
className={cn(
|
|
@@ -53,7 +94,7 @@ export function TopBar({ variant = 'solid', menu, logo, search, right, className
|
|
|
53
94
|
{logo && <div className="flex items-center text-body-2-strong text-text-primary">{logo}</div>}
|
|
54
95
|
</div>
|
|
55
96
|
)}
|
|
56
|
-
<div className={cn('flex min-w-0 flex-1 items-center justify-center', floating && 'pointer-events-auto')}>{
|
|
97
|
+
<div className={cn('flex min-w-0 flex-1 items-center justify-center', floating && 'pointer-events-auto')}>{centre}</div>
|
|
57
98
|
<div className={cn('flex shrink-0 items-center gap-[6px]', floating && 'pointer-events-auto')}>{right}</div>
|
|
58
99
|
</header>
|
|
59
100
|
)
|
package/src/index.ts
CHANGED
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
* `src/` still ships in the tarball, so stepping into a component lands on the
|
|
10
10
|
* TypeScript that produced it.
|
|
11
11
|
*/
|
|
12
|
+
export { AppProvider, type AppProviderProps } from './AppProvider'
|
|
12
13
|
export { AppShell, type AppShellProps } from './AppShell'
|
|
13
14
|
export { AttachmentCard, type AttachmentCardProps, type AttachmentCardState } from './AttachmentCard'
|
|
14
15
|
export { Avatar, hueFor, initialsFor, type AvatarProps } from './Avatar'
|
|
15
16
|
export { AvatarGroup, type AvatarGroupMember, type AvatarGroupProps } from './AvatarGroup'
|
|
17
|
+
export { MembersPill, type MembersPillProps } from './MembersPill'
|
|
18
|
+
export { MembersDialog, type MembersDialogCandidate, type MembersDialogMember, type MembersDialogProps, type MembersDialogSelfAction } from './MembersDialog'
|
|
16
19
|
export { Banner, type BannerProps, type BannerTone } from './Banner'
|
|
17
20
|
export { BlockHandle, blockAtY, type BlockHandleProps, type BlockHandleEditing, type HandleBlock } from './BlockHandle'
|
|
18
21
|
export { Button, type ButtonProps, type ButtonSize, type ButtonVariant } from './Button'
|
package/src/looks.ts
CHANGED
|
@@ -43,6 +43,9 @@ export const COUNT_TEXT_CLASSES = 'font-mono text-caption tabular-nums text-text
|
|
|
43
43
|
/** A chip's words: Chip's label and Reaction's count. */
|
|
44
44
|
export const CHIP_TEXT_CLASSES = 'text-chip signal:font-mono signal:text-small signal:font-semibold signal:tabular-nums'
|
|
45
45
|
|
|
46
|
+
/** Small secondary words that turn mono under signal: Kbd's key, CommandPalette's footer and MembersPill's count. */
|
|
47
|
+
export const MONO_CAPTION_CLASSES = 'text-caption text-text-secondary signal:font-mono signal:text-small'
|
|
48
|
+
|
|
46
49
|
/** Words typed straight into an input with no box of its own: SearchInput and ChipInput. */
|
|
47
50
|
export const BARE_INPUT_CLASSES = 'bg-transparent text-text-primary placeholder:text-text-muted outline-none'
|
|
48
51
|
|
package/src/pages.test.ts
CHANGED
|
@@ -37,6 +37,8 @@ const NOT_A_ROW = ['base-ui']
|
|
|
37
37
|
const WAITING: string[] = []
|
|
38
38
|
|
|
39
39
|
const NOTHING = 'Nothing. It only draws. Clicks and keys are yours.'
|
|
40
|
+
/** A part that draws nothing at all says so instead: it only holds what the parts below it ask for. */
|
|
41
|
+
const HOLDS_ONLY: Record<string, string> = { AppProvider: 'Nothing. It draws nothing; it holds two answers for the parts below it.' }
|
|
40
42
|
|
|
41
43
|
const dir = new URL('.', import.meta.url)
|
|
42
44
|
const read = (file: string) => readFileSync(new URL(file, dir), 'utf8').replace(/\r\n/g, '\n')
|
|
@@ -134,7 +136,7 @@ describe('the page contract', () => {
|
|
|
134
136
|
const body = (section(t, 'What it owns') ?? '').trim()
|
|
135
137
|
if (!rows.length) {
|
|
136
138
|
expect(owned, `${page} owns ${owned.join(', ')}; the page says nothing`).toEqual([])
|
|
137
|
-
expect(body).toBe(NOTHING)
|
|
139
|
+
expect(body).toBe(HOLDS_ONLY[page] ?? NOTHING)
|
|
138
140
|
return
|
|
139
141
|
}
|
|
140
142
|
const said = rows.flatMap((r) => Object.entries(ROW).filter(([, re]) => re.test(r.does)).map(([id]) => ({ id, ticked: r.ticked, does: r.does })))
|
package/src/registry/app.test.ts
CHANGED
|
@@ -103,7 +103,10 @@ const FIXTURE: Record<string, string> = {
|
|
|
103
103
|
' return <li>{title}</li>',
|
|
104
104
|
'}',
|
|
105
105
|
'',
|
|
106
|
-
'/**
|
|
106
|
+
'/**',
|
|
107
|
+
' * A card that lists its rows and a button.',
|
|
108
|
+
" * @registry namesake: the fixture's own card",
|
|
109
|
+
' */',
|
|
107
110
|
'export function Card({ title }: CardProps) {',
|
|
108
111
|
' return <ul><CardRow title={title} /><Button>Go</Button></ul>',
|
|
109
112
|
'}',
|
|
@@ -128,7 +131,7 @@ const FIXTURE: Record<string, string> = {
|
|
|
128
131
|
'src/components/Shown.tsx': '/** A part only its story uses. */\nexport function Shown() {\n return <span />\n}\n',
|
|
129
132
|
'src/components/Shown.stories.tsx': "import { Shown } from './Shown'\nconst meta = { title: 'Shown', component: Shown }\nexport default meta\nexport const Plain = {}\n",
|
|
130
133
|
'src/components/Wide.tsx': '/**\n * A general strip of things.\n * @registry reusable: general; one screen uses it so far\n */\nexport function Wide() {\n return <div />\n}\n',
|
|
131
|
-
'src/components/EmptyState.tsx': "import { EmptyState as Shared } from '@estiva-app/ui'\n\n
|
|
134
|
+
'src/components/EmptyState.tsx': "import { EmptyState as Shared } from '@estiva-app/ui'\n\n/**\n * The package's empty state, with this app's own default words.\n * @registry namesake: the words are this app's\n */\nexport function EmptyState() {\n return <Shared message=\"Nothing here\" />\n}\n",
|
|
132
135
|
'src/pages/Lazy.tsx': '/** A page loaded only when it is opened. */\nexport default function Lazy() {\n return <main />\n}\n',
|
|
133
136
|
'src/pages/HomePage.tsx': [
|
|
134
137
|
"import { lazy } from 'react'",
|
|
@@ -287,7 +290,7 @@ describe('what the first descriptions found', () => {
|
|
|
287
290
|
const faults = buildAppRegistry({
|
|
288
291
|
root: app({
|
|
289
292
|
// Peek's Skeleton.tsx: imported from the package, then exported on a line of its own.
|
|
290
|
-
'src/Skeleton.tsx': "import { SkeletonBar, SkeletonRow, SkeletonList as Shared } from '@estiva-app/ui'\n\n
|
|
293
|
+
'src/Skeleton.tsx': "import { SkeletonBar, SkeletonRow, SkeletonList as Shared } from '@estiva-app/ui'\n\n/**\n * Grey rows in the shape of a list, while it loads.\n * @registry namesake: the fixture says so\n */\nexport function SkeletonList() {\n return <div><SkeletonBar /><SkeletonRow /></div>\n}\n\n/** Peek's name for the package's list placeholder. */\nexport const SkeletonSidebarList = Shared\n\nexport { SkeletonBar, SkeletonRow }\n",
|
|
291
294
|
// A file with its own description at the very top, and one on its part.
|
|
292
295
|
'src/Row.tsx': "/**\n * Everything about rows.\n */\nimport { useState } from 'react'\n\n/** One row of a list, with a hover state of its own. */\nexport function Row() {\n const [on] = useState(false)\n return <li>{String(on)}</li>\n}\n",
|
|
293
296
|
// A class, described above itself, with its props from what it extends.
|
|
@@ -397,10 +400,10 @@ describe('what the third review found', () => {
|
|
|
397
400
|
root: app({
|
|
398
401
|
'tsconfig.json': '{ "compilerOptions": { "baseUrl": "." } }\n',
|
|
399
402
|
'src/Choice.tsx': "/** A choice, taken as text or as a number. */\nexport function Choice(props: { value: string }): JSX.Element\nexport function Choice(props: { value: number }): JSX.Element\nexport function Choice(props: { value: string | number }) {\n return <span>{String(props.value)}</span>\n}\n",
|
|
400
|
-
'src/Kbd.tsx': '
|
|
403
|
+
'src/Kbd.tsx': '/**\n * One key, drawn as a keycap.\n * @registry namesake: the fixture says so\n */\nexport function Kbd() {\n return <kbd />\n}\n\n/** Keys in a row. */\nexport function KbdRow() {\n return <span><Kbd /></span>\n}\n',
|
|
401
404
|
'src/Guard.tsx': "import { Component } from 'react'\n\ninterface Props {\n /** What it guards. */\n label: string\n}\n\n/** Something else in the file. */\nexport function Other() {\n return <i />\n}\n\n/** Catches what crashes below it. */\nexport default class extends Component<Props> {\n render() {\n return <div>{this.props.label}</div>\n }\n}\n",
|
|
402
405
|
'src/List.tsx': "import { memo } from 'react'\n\n/** One row of the list. */\nexport function Row() {\n return <li />\n}\n\n/** The list, drawing its rows. */\nexport default memo(function List() {\n return <ul><Row /></ul>\n})\n",
|
|
403
|
-
'src/parts/Card.tsx': '
|
|
406
|
+
'src/parts/Card.tsx': '/**\n * A card.\n * @registry namesake: the fixture says so\n */\nexport function Card() {\n return <div />\n}\n',
|
|
404
407
|
'src/parts/index.ts': "export * as Parts from './Card'\n",
|
|
405
408
|
'src/UsesCard.tsx': "import { Parts } from './parts'\n\n/** Draws a card through a namespace. */\nexport function UsesCard() {\n return <Parts.Card />\n}\n",
|
|
406
409
|
'src/Badge.tsx': '/** A small badge. */\nexport function Badge() {\n return <b />\n}\n',
|
|
@@ -414,7 +417,7 @@ describe('what the third review found', () => {
|
|
|
414
417
|
// Found by the narrow pass on those fixes.
|
|
415
418
|
'src/routesTable.ts': 'export const routes: unknown[] = []\n',
|
|
416
419
|
'src/router.tsx': "import { createBrowserRouter } from 'react-router-dom'\nimport { routes } from './routesTable'\n\nexport default createBrowserRouter(routes)\n",
|
|
417
|
-
'src/Chip.tsx': "import { forwardRef, memo, type ComponentType } from 'react'\n\n
|
|
420
|
+
'src/Chip.tsx': "import { forwardRef, memo, type ComponentType } from 'react'\n\n/**\n * A chip nothing uses.\n * @registry namesake: the fixture says so\n */\nexport function Chip() {\n return <i />\n}\n\nexport default memo(forwardRef(Chip)) as ComponentType\n",
|
|
418
421
|
}),
|
|
419
422
|
repo: 'third',
|
|
420
423
|
packageRegistry,
|
|
@@ -513,6 +516,21 @@ describe('an app the catalogue refuses', () => {
|
|
|
513
516
|
expect(refused({ 'src/One.tsx': '/**\n * One.\n * @registry unused: gone\n */\nexport function One() {\n return <i />\n}\n' })).toMatch(/the kinds a person may write are reusable, one-off, promote-candidate/)
|
|
514
517
|
})
|
|
515
518
|
|
|
519
|
+
it("a part with a package part's name and no reason it is kept", () => {
|
|
520
|
+
const pill = "import { Button } from '@estiva-app/ui'\n\n/** Who is in this. */\nexport function EmptyState() {\n return <Button>3</Button>\n}\n"
|
|
521
|
+
expect(refused({ 'src/EmptyState.tsx': pill })).toContain(
|
|
522
|
+
`EmptyState in src/EmptyState.tsx has the name of the package's EmptyState. Use the package's, or write why this one is kept: "@registry namesake: <reason>" in its /** … */ comment.`,
|
|
523
|
+
)
|
|
524
|
+
// The reason, and nothing else, lets it through; a kind written beside it still counts.
|
|
525
|
+
const kept = pill.replace('/** Who is in this. */', '/**\n * Who is in this.\n * @registry reusable: general\n * @registry namesake: the words are this app\'s\n */')
|
|
526
|
+
expect(refused({ 'src/EmptyState.tsx': kept })).toBe('')
|
|
527
|
+
expect(refused({ 'src/EmptyState.tsx': pill.replace('Who is in this.', 'Who is in this. @registry namesake:') })).toMatch(/has the name of the package's EmptyState/)
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
it("not a pass-on of the package's own part", () => {
|
|
531
|
+
expect(refused({ 'src/EmptyState.tsx': "export { EmptyState } from '@estiva-app/ui'\n" })).toBe('')
|
|
532
|
+
})
|
|
533
|
+
|
|
516
534
|
it('export * from a package, which hides what it hands on', () => {
|
|
517
535
|
expect(refused({ 'src/All.tsx': "export * from '@estiva-app/ui'\n" })).toMatch(/hands on everything from '@estiva-app\/ui' with export \*/)
|
|
518
536
|
})
|
package/src/registry/app.ts
CHANGED
|
@@ -31,6 +31,18 @@
|
|
|
31
31
|
* * @registry reusable: general; one screen uses it so far
|
|
32
32
|
* *\/
|
|
33
33
|
*
|
|
34
|
+
* A part that shares its name with a package part is refused unless the reason
|
|
35
|
+
* it is kept is written beside it, the same way:
|
|
36
|
+
*
|
|
37
|
+
* /**
|
|
38
|
+
* * The package's `Avatar`, handed the picture this app knows about.
|
|
39
|
+
* * @registry namesake: the package cannot know where pictures come from
|
|
40
|
+
* *\/
|
|
41
|
+
*
|
|
42
|
+
* Peek's own MembersPill stayed in two headers for two days after the package
|
|
43
|
+
* gained one (0.58.0, 6 October) — built from the package's parts, so no rule
|
|
44
|
+
* saw it (Katerina, 8 October).
|
|
45
|
+
*
|
|
34
46
|
* TypeScript is the app's own install, as it is for the package's builder.
|
|
35
47
|
*/
|
|
36
48
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
@@ -54,7 +66,9 @@ export interface AppBuildOptions {
|
|
|
54
66
|
|
|
55
67
|
/** The kinds a person may write beside a part. A pass-on is a fact about its file; so is being used nowhere. */
|
|
56
68
|
const WRITABLE: EntryClass[] = ['reusable', 'one-off', 'promote-candidate']
|
|
57
|
-
const WRITTEN = /^@registry\s+([a-z-]+)\s*:\s*(.+)$/m
|
|
69
|
+
const WRITTEN = /^@registry\s+(?!namesake\b)([a-z-]+)\s*:\s*(.+)$/m
|
|
70
|
+
/** Why a part with a package part's name is kept: `@registry namesake: <reason>`. */
|
|
71
|
+
const NAMESAKE = /^@registry\s+namesake\s*:\s*\S/m
|
|
58
72
|
|
|
59
73
|
const isStory = (file: string) => /\.stories\.[cm]?[jt]sx?$/.test(file)
|
|
60
74
|
const isTest = (file: string) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(file) || /(^|\/)__(tests|mocks)__\//.test(file)
|
|
@@ -837,6 +851,11 @@ export function buildAppRegistry({ root = process.cwd(), repo, packageRegistry,
|
|
|
837
851
|
reason = `Used in ${usedIn.length} places, and everything it uses is already in the package.`
|
|
838
852
|
}
|
|
839
853
|
|
|
854
|
+
const namesake = namesakes.has(p.name) && !(pass && isPackageImport(pass.specifier) && pass.name === p.name) ? p.name : null
|
|
855
|
+
if (namesake && !(NAMESAKE.test(doc) || (onePart && NAMESAKE.test(header)))) {
|
|
856
|
+
problems.push(`${p.name} in ${p.file} has the name of the package's ${namesake}. Use the package's, or write why this one is kept: "@registry namesake: <reason>" in its /** … */ comment.`)
|
|
857
|
+
}
|
|
858
|
+
|
|
840
859
|
const shape: Resolved = declared ? module!.resolve(declared.propsType) : { props: [], variants: [] }
|
|
841
860
|
const facts_: AppFacts = {
|
|
842
861
|
class: cls,
|
|
@@ -845,7 +864,7 @@ export function buildAppRegistry({ root = process.cwd(), repo, packageRegistry,
|
|
|
845
864
|
handsOn: pass ? (isPackageImport(pass.specifier) ? pass.name : `${pass.name} from ${pass.specifier}`) : null,
|
|
846
865
|
usedIn,
|
|
847
866
|
tiedTo: ties,
|
|
848
|
-
packageNamesake:
|
|
867
|
+
packageNamesake: namesake,
|
|
849
868
|
defaultExport: p.defaultExport,
|
|
850
869
|
}
|
|
851
870
|
const found = pass ? { docsId: null, storyId: null } : storyOf(p)
|
|
@@ -70,7 +70,7 @@ const PAGE = [
|
|
|
70
70
|
].join('\n')
|
|
71
71
|
|
|
72
72
|
const part = (name: string, cls = 'reusable') =>
|
|
73
|
-
`/**\n * ${name}, for the fixture.\n * @registry ${cls}: the fixture says so\n */\nexport function ${name}() {\n return <span>${name}</span>\n}\n`
|
|
73
|
+
`/**\n * ${name}, for the fixture.\n * @registry ${cls}: the fixture says so\n * @registry namesake: the fixture says so\n */\nexport function ${name}() {\n return <span>${name}</span>\n}\n`
|
|
74
74
|
|
|
75
75
|
describe('a usage page', () => {
|
|
76
76
|
it('keeps the contract with an opening line and the four sections in order', () => {
|
|
@@ -38,6 +38,7 @@ const find = (dir: string) => gatedFindings(buildAppRegistry({ root: dir, repo:
|
|
|
38
38
|
const card = `/**
|
|
39
39
|
* A card.
|
|
40
40
|
* @registry reusable: the fixture says so
|
|
41
|
+
* @registry namesake: the fixture says so
|
|
41
42
|
*/
|
|
42
43
|
export function Card({ title, onReply, onShare }: { title: string; onReply?: () => void; onShare?: () => void }) {
|
|
43
44
|
return (
|
|
@@ -48,19 +48,19 @@ 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 134 names over 73 files: 104 components, 29 helpers and 1 hook (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October; BlockHandle and EditorBlockHandle (RIC-18) on 2 October, moveBlock and blockDropCursor, the drop in the gutter, on 5 October, MembersPill and MembersDialog, moved from Peek (SHI-31), on 6 October, ShowMore on 7 October, and AppProvider on 8 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: 104, helper: 29, hook: 1 })
|
|
58
|
+
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(73)
|
|
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(
|
|
63
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(69)
|
|
64
64
|
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(65)
|
|
65
65
|
})
|
|
66
66
|
|
package/stories/Choosing.mdx
CHANGED
|
@@ -51,6 +51,8 @@ fork freely, owe nothing back, mention it on the package's ticket.
|
|
|
51
51
|
|---|---|
|
|
52
52
|
| One face | **Avatar** |
|
|
53
53
|
| Several faces, overlapping | **AvatarGroup** |
|
|
54
|
+
| Who is in it, at a glance, opening the list | **MembersPill** |
|
|
55
|
+
| Who is in it, with Join, Leave and adding people | **MembersDialog** |
|
|
54
56
|
| Face and name in a row | **Person** |
|
|
55
57
|
| The face that opens a menu | **PersonTrigger** |
|
|
56
58
|
| Who you are, where you are, sign out | **IdentityMenu** — the account menu, ready-made |
|
|
@@ -120,10 +122,12 @@ the **floating** TopBar goes with the **Rail** and the content card.
|
|
|
120
122
|
| The structured frame: solid bar, sidebar, content | **AppShell** `solid` |
|
|
121
123
|
| The floating frame: bar over the content, rail, rounded card | **AppShell** `floating` |
|
|
122
124
|
| The 52px bar alone | **TopBar** — `solid` in flow, or `floating` over the content |
|
|
125
|
+
| A search field in the bar that opens your launcher, with Ctrl+K | the TopBar's `onSearch` — the bar draws the field |
|
|
123
126
|
| A 240px navigation column | **Sidebar**, filled with **NavItem** rows |
|
|
124
127
|
| Groups of rows in that column, rows under rows, from one nested list | **NavTree**, inside the **Sidebar** |
|
|
125
128
|
| A 64px icon strip | **Rail**, filled with **RailItem** tiles |
|
|
126
129
|
| The menu button and the logo | the TopBar's `menu` and `logo` slots — collapsing stays yours |
|
|
130
|
+
| Where pictures come from, and how to change page without a reload, said once | **AppProvider**, around the whole app |
|
|
127
131
|
| The list beside the thing that is open | **ListColumn** — 290px, its name in a **ContainerHeader**, the list under it scrolling |
|
|
128
132
|
| A panel of a page that is not a list — details, a thread, what is open — with a body that must never be cut off | **Panel** — a header, a body that scrolls whenever it is taller, and a `footer` that stays put |
|
|
129
133
|
|