@estiva-app/ui 0.59.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/IconButton.d.ts.map +1 -1
- package/dist/InlineChip.d.ts.map +1 -1
- package/dist/Link.d.ts +1 -1
- package/dist/Link.d.ts.map +1 -1
- 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/TopBar.d.ts +14 -3
- package/dist/TopBar.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +710 -642
- package/dist/index.js.map +4 -4
- 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 +87 -9
- 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/IconButton.mdx +2 -2
- package/src/IconButton.tsx +4 -1
- package/src/InlineChip.mdx +3 -3
- package/src/InlineChip.tsx +3 -1
- package/src/Link.mdx +5 -3
- package/src/Link.tsx +4 -1
- 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/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 +1 -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 +2 -0
- package/dist/registry/chunk-TQ6UQER3.js.map +0 -7
- /package/dist/registry/{app-LDGOH6UW.js.map → app-X2GI4WTU.js.map} +0 -0
package/src/SearchInput.tsx
CHANGED
|
@@ -13,9 +13,9 @@ import { BARE_INPUT_CLASSES, FIELD_SHELL_CLASSES } from './looks'
|
|
|
13
13
|
* The one product string — Peek's default placeholder "Search Peek..." —
|
|
14
14
|
* stays in Peek; the default here says only "Search…".
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* The top bar's way into a launcher is not this: `TopBar`'s `onSearch` draws
|
|
17
|
+
* this look as a button and answers Ctrl+K itself (8 October). Peek used to
|
|
18
|
+
* make this field inert and catch the click around it.
|
|
19
19
|
*
|
|
20
20
|
* On Base UI's `Input` since stage 3 of the migration (2026-09-07), so a
|
|
21
21
|
* search field inside a `Field` is labelled by it without the caller wiring
|
package/src/Sidebar.mdx
CHANGED
|
@@ -48,8 +48,8 @@ import { Sidebar, NavItem, CollapsibleSection, SectionHeader } from '@estiva-app
|
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
**Every row is a plain anchor, so in a router app each click would reload
|
|
51
|
-
the page
|
|
52
|
-
|
|
51
|
+
the page** — unless the app gives **AppProvider**'s `navigate`, which
|
|
52
|
+
changes page in place. See **NavItem**.
|
|
53
53
|
|
|
54
54
|
Anything you place directly in the column needs `shrink-0` (both headings
|
|
55
55
|
above take it in `className`):
|
package/src/TopBar.mdx
CHANGED
|
@@ -5,7 +5,7 @@ import * as TopBarStories from './TopBar.stories'
|
|
|
5
5
|
|
|
6
6
|
# TopBar
|
|
7
7
|
|
|
8
|
-
The 52px top bar: `menu` and `logo` on the left,
|
|
8
|
+
The 52px top bar: `menu` and `logo` on the left, the search field in the
|
|
9
9
|
centre, the identity cluster on the right. Three manners — **`solid`**,
|
|
10
10
|
in flow with a hairline and a surface; **`inset`**, in flow with neither,
|
|
11
11
|
standing on the ground; and **`floating`**, an overlay with neither, whose
|
|
@@ -27,8 +27,12 @@ bar ignores the pointer so the content underneath stays reachable.
|
|
|
27
27
|
- `menu` is the menu button, in an app whose navigation collapses — the
|
|
28
28
|
collapsing itself stays the caller's. `logo` sits beside it: the app's
|
|
29
29
|
name as text, or a mark.
|
|
30
|
-
-
|
|
31
|
-
|
|
30
|
+
- `onSearch` is what searching opens — your command launcher. With it the
|
|
31
|
+
bar draws the search field itself: **SearchInput**'s look, 290px, as a
|
|
32
|
+
button showing `searchPlaceholder` ("Search…") and the shortcut. A click on
|
|
33
|
+
it, and Ctrl+K (Cmd+K on a Mac) anywhere on the page, call `onSearch`.
|
|
34
|
+
- The centre holds its place even while empty, so search drops in later
|
|
35
|
+
without moving anything.
|
|
32
36
|
|
|
33
37
|
<Canvas of={TopBarStories.FloatingNoSearch} />
|
|
34
38
|
|
|
@@ -37,25 +41,29 @@ bar ignores the pointer so the content underneath stays reachable.
|
|
|
37
41
|
- Page-level headers inside the content → not this; this is the frame's
|
|
38
42
|
bar, once per app. The top of a column is **ContainerHeader**.
|
|
39
43
|
- Anything the bar would need to know — who you are, what is searched —
|
|
40
|
-
stays outside: pass composed slots
|
|
44
|
+
stays outside: pass composed slots, and what searching opens as `onSearch`.
|
|
45
|
+
- A search field of your own in the centre → only for something the bar's
|
|
46
|
+
does not do; pass it as `search`, which takes the centre (`onSearch` still
|
|
47
|
+
answers Ctrl+K). A field that filters a list sits above that list:
|
|
48
|
+
**SearchInput**.
|
|
41
49
|
|
|
42
50
|
## How
|
|
43
51
|
|
|
44
52
|
```tsx
|
|
45
|
-
import { TopBar, IdentityMenu
|
|
53
|
+
import { TopBar, IdentityMenu } from '@estiva-app/ui'
|
|
46
54
|
|
|
47
55
|
<TopBar
|
|
48
56
|
logo="Estiva"
|
|
49
|
-
|
|
57
|
+
onSearch={openLauncher}
|
|
58
|
+
searchPlaceholder="Search Estiva…"
|
|
50
59
|
right={<IdentityMenu me={me} signedIn={signedIn} />}
|
|
51
60
|
/>
|
|
52
61
|
```
|
|
53
62
|
|
|
54
63
|
- Geometry is fixed: 52px, `pl-5 pr-[26px]`, right cluster `gap-[6px]` —
|
|
55
64
|
don't restyle it per app.
|
|
56
|
-
-
|
|
57
|
-
|
|
58
|
-
`mousedown` (see SearchInput's page).
|
|
65
|
+
- `onSearch` is called on every Ctrl+K, open or not: to close the launcher
|
|
66
|
+
on a second Ctrl+K, toggle in your handler.
|
|
59
67
|
- The frame is desktop-only for now — there is no narrow-screen
|
|
60
68
|
behaviour; the menu button collapses panels, it does not open a drawer.
|
|
61
69
|
|
|
@@ -69,6 +77,7 @@ It does these by itself. Write none of them around it.
|
|
|
69
77
|
| It does | So you never write | Refused when written by hand |
|
|
70
78
|
|---|---|---|
|
|
71
79
|
| Keeps its 52px in a scrolling column | `shrink-0` | |
|
|
80
|
+
| Answers Ctrl+K (Cmd+K) anywhere on the page, given `onSearch` | a `keydown` listener on the window | |
|
|
72
81
|
|
|
73
82
|
## Props
|
|
74
83
|
|
package/src/TopBar.stories.tsx
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
2
|
import { IconMenu2 } from '@tabler/icons-react'
|
|
3
|
-
import type
|
|
3
|
+
import { useState, type ReactNode } from 'react'
|
|
4
4
|
import { IconButton } from './IconButton'
|
|
5
5
|
import { PersonTrigger } from './PersonTrigger'
|
|
6
|
-
import { SearchInput } from './SearchInput'
|
|
7
6
|
import { TopBar } from './TopBar'
|
|
8
7
|
|
|
9
8
|
/**
|
|
@@ -26,6 +25,7 @@ const meta = {
|
|
|
26
25
|
menu: { control: false },
|
|
27
26
|
logo: { control: false },
|
|
28
27
|
search: { control: false },
|
|
28
|
+
onSearch: { control: false },
|
|
29
29
|
right: { control: false },
|
|
30
30
|
},
|
|
31
31
|
} satisfies Meta<typeof TopBar>
|
|
@@ -60,11 +60,29 @@ const Behind = ({ children }: { children: ReactNode }) => (
|
|
|
60
60
|
export const Solid: Story = {
|
|
61
61
|
render: (args) => (
|
|
62
62
|
<Below>
|
|
63
|
-
<TopBar {...args} logo="Estiva"
|
|
63
|
+
<TopBar {...args} logo="Estiva" onSearch={() => {}} right={face} />
|
|
64
64
|
</Below>
|
|
65
65
|
),
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The bar's own search field: a click on it, or Ctrl+K (Cmd+K on a Mac)
|
|
70
|
+
* anywhere, calls `onSearch` — here it counts, where an app opens its launcher.
|
|
71
|
+
*/
|
|
72
|
+
export const Search: Story = {
|
|
73
|
+
render: function Search(args) {
|
|
74
|
+
const [opened, setOpened] = useState(0)
|
|
75
|
+
return (
|
|
76
|
+
<div className="flex h-40 flex-col overflow-hidden bg-bg-base">
|
|
77
|
+
<TopBar {...args} logo="Estiva" onSearch={() => setOpened((n) => n + 1)} searchPlaceholder="Search Estiva…" right={face} />
|
|
78
|
+
<div className="flex flex-1 items-center justify-center text-body-2 text-text-secondary">
|
|
79
|
+
{opened === 0 ? 'Click the field, or press Ctrl+K.' : `Search opened ${opened} ${opened === 1 ? 'time' : 'times'}.`}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
)
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
68
86
|
/** No search yet: the centre slot holds its place, so it drops in later without moving anything. */
|
|
69
87
|
export const SolidNoSearch: Story = {
|
|
70
88
|
render: (args) => (
|
|
@@ -78,7 +96,7 @@ export const SolidNoSearch: Story = {
|
|
|
78
96
|
export const Floating: Story = {
|
|
79
97
|
render: (args) => (
|
|
80
98
|
<Behind>
|
|
81
|
-
<TopBar {...args} variant="floating" menu={menuButton}
|
|
99
|
+
<TopBar {...args} variant="floating" menu={menuButton} onSearch={() => {}} right={face} />
|
|
82
100
|
</Behind>
|
|
83
101
|
),
|
|
84
102
|
}
|
|
@@ -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,6 +9,7 @@
|
|
|
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'
|
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
|
@@ -122,10 +122,12 @@ the **floating** TopBar goes with the **Rail** and the content card.
|
|
|
122
122
|
| The structured frame: solid bar, sidebar, content | **AppShell** `solid` |
|
|
123
123
|
| The floating frame: bar over the content, rail, rounded card | **AppShell** `floating` |
|
|
124
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 |
|
|
125
126
|
| A 240px navigation column | **Sidebar**, filled with **NavItem** rows |
|
|
126
127
|
| Groups of rows in that column, rows under rows, from one nested list | **NavTree**, inside the **Sidebar** |
|
|
127
128
|
| A 64px icon strip | **Rail**, filled with **RailItem** tiles |
|
|
128
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 |
|
|
129
131
|
| The list beside the thing that is open | **ListColumn** — 290px, its name in a **ContainerHeader**, the list under it scrolling |
|
|
130
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 |
|
|
131
133
|
|