@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,69 @@
|
|
|
1
|
+
import { Meta, Canvas } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as AppProviderStories from './AppProvider.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={AppProviderStories} />
|
|
5
|
+
|
|
6
|
+
# AppProvider
|
|
7
|
+
|
|
8
|
+
What the parts need to know about the app they are in, said once at its root:
|
|
9
|
+
where a person's picture comes from, and how to change page without a reload.
|
|
10
|
+
|
|
11
|
+
<Canvas of={AppProviderStories.Pictures} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- Every app, once, around everything it draws — beside `ToastProvider`.
|
|
16
|
+
- `pictureFor`: the app keeps people's pictures somewhere the package cannot
|
|
17
|
+
know. Every face handed no picture asks it: **Avatar**, and so **Person**,
|
|
18
|
+
**AvatarGroup**, **MembersPill**, **MembersDialog**, **PersonTrigger** and
|
|
19
|
+
**IdentityMenu**.
|
|
20
|
+
- `navigate`: the app changes page with a router. A plain click on a link to
|
|
21
|
+
the app's own address then goes through it: **Link**, **Card** and
|
|
22
|
+
**Breadcrumb**'s crumbs, **NavItem** and **NavTree**'s rows, **RailItem**,
|
|
23
|
+
**Chip**, **InlineChip**, **IconButton** and **ToolbarLink**.
|
|
24
|
+
|
|
25
|
+
<Canvas of={AppProviderStories.ChangingPage} />
|
|
26
|
+
|
|
27
|
+
## When not
|
|
28
|
+
|
|
29
|
+
- A wrapper around a part that only looks a picture up, or only stops a
|
|
30
|
+
reload → this, once. Peek and Ship each kept their own Avatar, Person or
|
|
31
|
+
NavItem for one of these two jobs (Katerina, 8 October).
|
|
32
|
+
- One face with a picture you already have → pass it to **Avatar**'s `src`
|
|
33
|
+
or **Person**'s `picture`. A picture handed in always wins.
|
|
34
|
+
- One **Link** that does something other than change page → give it its own
|
|
35
|
+
`onClick` and `preventDefault()`; the provider leaves it alone.
|
|
36
|
+
|
|
37
|
+
## How
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
import { AppProvider } from '@estiva-app/ui'
|
|
41
|
+
|
|
42
|
+
function Root({ children }) {
|
|
43
|
+
const pictureFor = usePictureLookup() // the app's own
|
|
44
|
+
const navigate = useNavigate() // the app's router
|
|
45
|
+
return (
|
|
46
|
+
<AppProvider pictureFor={pictureFor} navigate={navigate}>
|
|
47
|
+
{children}
|
|
48
|
+
</AppProvider>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- Both are optional. A part with no provider above it draws as it always
|
|
54
|
+
did, which is also what a story or a test sees without one.
|
|
55
|
+
- `pictureFor` gets the person's name and returns an address, or nothing for
|
|
56
|
+
the initials.
|
|
57
|
+
- `navigate` gets the link's `href` exactly as written. The browser keeps
|
|
58
|
+
every other click: one already handled (`preventDefault`), one with Ctrl,
|
|
59
|
+
Cmd, Shift or Alt, the middle button, a link that opens a new tab or
|
|
60
|
+
downloads, and an address on another site or in another scheme
|
|
61
|
+
(`folder:`, `mailto:`).
|
|
62
|
+
- A link's own `onClick` runs first, so an app that still passes its router
|
|
63
|
+
click per link keeps working while it moves over.
|
|
64
|
+
- `active` is still the caller's: say which row is the current page where you
|
|
65
|
+
place it.
|
|
66
|
+
|
|
67
|
+
## What it owns
|
|
68
|
+
|
|
69
|
+
Nothing. It draws nothing; it holds two answers for the parts below it.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { IconFolder, IconMessage } from '@tabler/icons-react'
|
|
4
|
+
import { AppProvider } from './AppProvider'
|
|
5
|
+
import { Link } from './Link'
|
|
6
|
+
import { MembersPill } from './MembersPill'
|
|
7
|
+
import { NavItem } from './NavItem'
|
|
8
|
+
import { Person } from './Person'
|
|
9
|
+
|
|
10
|
+
/** One picture the app "knows": Ana's. Inline, so the story needs nothing from anywhere. */
|
|
11
|
+
const ANA = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="%2346c08a"/><circle cx="5" cy="4" r="2" fill="%23fff"/></svg>'
|
|
12
|
+
const pictureFor = (name: string) => (name === 'Ana Duarte' ? ANA : undefined)
|
|
13
|
+
|
|
14
|
+
const meta = {
|
|
15
|
+
title: 'Frame/AppProvider',
|
|
16
|
+
component: AppProvider,
|
|
17
|
+
args: { children: null },
|
|
18
|
+
argTypes: { children: { control: false }, pictureFor: { control: false }, navigate: { control: false } },
|
|
19
|
+
} satisfies Meta<typeof AppProvider>
|
|
20
|
+
|
|
21
|
+
export default meta
|
|
22
|
+
type Story = StoryObj<typeof meta>
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The app knows Ana's picture and nobody else's. No part below is handed a
|
|
26
|
+
* picture: each face asks the app, and Ravi, whom it does not know, keeps
|
|
27
|
+
* his initials.
|
|
28
|
+
*/
|
|
29
|
+
export const Pictures: Story = {
|
|
30
|
+
render: () => (
|
|
31
|
+
<AppProvider pictureFor={pictureFor}>
|
|
32
|
+
<div className="flex flex-col items-start gap-3 text-body-2 text-text-primary">
|
|
33
|
+
<Person name="Ana Duarte" />
|
|
34
|
+
<Person name="Ravi Mehta" />
|
|
35
|
+
<MembersPill members={[{ name: 'Ana Duarte' }, { name: 'Ravi Mehta' }]} onClick={() => {}} />
|
|
36
|
+
</div>
|
|
37
|
+
</AppProvider>
|
|
38
|
+
),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A click on a row or a link goes through the app's `navigate`, and the page
|
|
43
|
+
* is not loaded again. The line under them says where the last click went.
|
|
44
|
+
*/
|
|
45
|
+
export const ChangingPage: Story = {
|
|
46
|
+
render: function ChangingPage() {
|
|
47
|
+
const [at, setAt] = useState('/topics')
|
|
48
|
+
return (
|
|
49
|
+
<AppProvider navigate={setAt}>
|
|
50
|
+
<div className="flex w-60 flex-col gap-px">
|
|
51
|
+
<NavItem href="/topics" label="Topics" icon={<IconMessage size={16} stroke={1.5} />} active={at === '/topics'} />
|
|
52
|
+
<NavItem href="/folders" label="Folders" icon={<IconFolder size={16} stroke={1.5} />} active={at === '/folders'} />
|
|
53
|
+
</div>
|
|
54
|
+
<p className="mt-3 text-body-2 text-text-secondary">
|
|
55
|
+
<Link href="/people" variant="underlined">People</Link> · now at <span className="font-mono">{at}</span>
|
|
56
|
+
</p>
|
|
57
|
+
</AppProvider>
|
|
58
|
+
)
|
|
59
|
+
},
|
|
60
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What AppProvider promises, pinned: a face handed no picture asks the app for
|
|
4
|
+
* one, and a picture handed in still wins; a plain click on a link to the
|
|
5
|
+
* app's own address changes page through the app instead of loading it, and
|
|
6
|
+
* every other click is left to the browser; without a provider, nothing changes.
|
|
7
|
+
*/
|
|
8
|
+
import { type ReactNode } from 'react'
|
|
9
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
10
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
11
|
+
import userEvent from '@testing-library/user-event'
|
|
12
|
+
import { AppProvider } from './AppProvider'
|
|
13
|
+
import { Avatar } from './Avatar'
|
|
14
|
+
import { Chip } from './Chip'
|
|
15
|
+
import { IconButton } from './IconButton'
|
|
16
|
+
import { InlineChip } from './InlineChip'
|
|
17
|
+
import { Link } from './Link'
|
|
18
|
+
import { NavItem } from './NavItem'
|
|
19
|
+
import { Person } from './Person'
|
|
20
|
+
import { RailItem } from './RailItem'
|
|
21
|
+
|
|
22
|
+
afterEach(cleanup)
|
|
23
|
+
|
|
24
|
+
const imgOf = (container: HTMLElement) => container.querySelector('img')
|
|
25
|
+
|
|
26
|
+
describe('pictures', () => {
|
|
27
|
+
const pictureFor = (name: string) => (name === 'Ana Duarte' ? 'https://pictures.test/ana.png' : null)
|
|
28
|
+
|
|
29
|
+
it("asks the app for a face's picture when none is handed in", () => {
|
|
30
|
+
const { container } = render(
|
|
31
|
+
<AppProvider pictureFor={pictureFor}>
|
|
32
|
+
<Avatar name="Ana Duarte" />
|
|
33
|
+
</AppProvider>,
|
|
34
|
+
)
|
|
35
|
+
expect(imgOf(container)?.getAttribute('src')).toBe('https://pictures.test/ana.png')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('keeps a picture that is handed in', () => {
|
|
39
|
+
const { container } = render(
|
|
40
|
+
<AppProvider pictureFor={pictureFor}>
|
|
41
|
+
<Avatar name="Ana Duarte" src="https://pictures.test/own.png" />
|
|
42
|
+
</AppProvider>,
|
|
43
|
+
)
|
|
44
|
+
expect(imgOf(container)?.getAttribute('src')).toBe('https://pictures.test/own.png')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('draws the initials when the app has no picture either', () => {
|
|
48
|
+
const { container } = render(
|
|
49
|
+
<AppProvider pictureFor={pictureFor}>
|
|
50
|
+
<Avatar name="Ravi Mehta" />
|
|
51
|
+
</AppProvider>,
|
|
52
|
+
)
|
|
53
|
+
expect(imgOf(container)).toBeNull()
|
|
54
|
+
expect(container.textContent).toBe('RM')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('reaches a face drawn through another part', () => {
|
|
58
|
+
const { container } = render(
|
|
59
|
+
<AppProvider pictureFor={pictureFor}>
|
|
60
|
+
<Person name="Ana Duarte" />
|
|
61
|
+
</AppProvider>,
|
|
62
|
+
)
|
|
63
|
+
expect(imgOf(container)?.getAttribute('src')).toBe('https://pictures.test/ana.png')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('asks nothing without a provider', () => {
|
|
67
|
+
const { container } = render(<Avatar name="Ana Duarte" />)
|
|
68
|
+
expect(imgOf(container)).toBeNull()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('changing page', () => {
|
|
73
|
+
const inApp = (navigate: (href: string) => void, children: ReactNode) => render(<AppProvider navigate={navigate}>{children}</AppProvider>)
|
|
74
|
+
|
|
75
|
+
it.each([
|
|
76
|
+
['Link', <Link href="/topics">Topics</Link>],
|
|
77
|
+
['NavItem', <NavItem href="/topics" label="Topics" />],
|
|
78
|
+
['RailItem', <RailItem href="/topics" icon={<i />} label="Topics" />],
|
|
79
|
+
['Chip', <Chip href="/topics" label="Topics" />],
|
|
80
|
+
['InlineChip', <InlineChip href="/topics">Topics</InlineChip>],
|
|
81
|
+
['IconButton', <IconButton href="/topics" aria-label="Topics" tooltip="Topics"><i /></IconButton>],
|
|
82
|
+
])('%s: a plain click goes through the app', async (_name, part) => {
|
|
83
|
+
const navigate = vi.fn()
|
|
84
|
+
inApp(navigate, part)
|
|
85
|
+
await userEvent.click(screen.getByRole('link'))
|
|
86
|
+
expect(navigate).toHaveBeenCalledWith('/topics')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("runs the caller's own onClick first", async () => {
|
|
90
|
+
const order: string[] = []
|
|
91
|
+
inApp(() => order.push('navigate'), <Link href="/topics" onClick={() => order.push('caller')}>Topics</Link>)
|
|
92
|
+
await userEvent.click(screen.getByRole('link'))
|
|
93
|
+
expect(order).toEqual(['caller', 'navigate'])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('leaves a click the caller already handled', async () => {
|
|
97
|
+
const navigate = vi.fn()
|
|
98
|
+
inApp(navigate, <Link href="/topics" onClick={(event) => event.preventDefault()}>Topics</Link>)
|
|
99
|
+
await userEvent.click(screen.getByRole('link'))
|
|
100
|
+
expect(navigate).not.toHaveBeenCalled()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it.each([
|
|
104
|
+
['Ctrl', { ctrlKey: true }],
|
|
105
|
+
['Cmd', { metaKey: true }],
|
|
106
|
+
['Shift', { shiftKey: true }],
|
|
107
|
+
['Alt', { altKey: true }],
|
|
108
|
+
['the middle button', { button: 1 }],
|
|
109
|
+
])('leaves a click with %s to the browser', (_name, init) => {
|
|
110
|
+
const navigate = vi.fn()
|
|
111
|
+
inApp(navigate, <Link href="/topics">Topics</Link>)
|
|
112
|
+
const link = screen.getByRole('link')
|
|
113
|
+
const event = new MouseEvent('click', { bubbles: true, cancelable: true, ...init })
|
|
114
|
+
link.dispatchEvent(event)
|
|
115
|
+
expect(navigate).not.toHaveBeenCalled()
|
|
116
|
+
expect(event.defaultPrevented).toBe(false)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it.each([
|
|
120
|
+
['another site', <Link href="https://elsewhere.test/topics">Topics</Link>],
|
|
121
|
+
['another scheme', <Link href="folder:1234">Topics</Link>],
|
|
122
|
+
['a new tab', <Link href="/topics" external>Topics</Link>],
|
|
123
|
+
['a download', <Link href="/file.pdf" download>File</Link>],
|
|
124
|
+
])('leaves a link to %s to the browser', (_name, part) => {
|
|
125
|
+
const navigate = vi.fn()
|
|
126
|
+
inApp(navigate, part)
|
|
127
|
+
const link = screen.getByRole('link')
|
|
128
|
+
const event = new MouseEvent('click', { bubbles: true, cancelable: true })
|
|
129
|
+
link.dispatchEvent(event)
|
|
130
|
+
expect(navigate).not.toHaveBeenCalled()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('stops the page load it replaces', () => {
|
|
134
|
+
inApp(() => {}, <Link href="/topics">Topics</Link>)
|
|
135
|
+
const event = new MouseEvent('click', { bubbles: true, cancelable: true })
|
|
136
|
+
screen.getByRole('link').dispatchEvent(event)
|
|
137
|
+
expect(event.defaultPrevented).toBe(true)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('changes nothing without a provider', () => {
|
|
141
|
+
render(<Link href="/topics">Topics</Link>)
|
|
142
|
+
const event = new MouseEvent('click', { bubbles: true, cancelable: true })
|
|
143
|
+
screen.getByRole('link').dispatchEvent(event)
|
|
144
|
+
expect(event.defaultPrevented).toBe(false)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('keeps the address on the link, for a new tab', () => {
|
|
148
|
+
inApp(() => {}, <NavItem href="/topics" label="Topics" />)
|
|
149
|
+
expect(screen.getByRole('link').getAttribute('href')).toBe('/topics')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo, type MouseEvent, type MouseEventHandler, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface AppProviderProps {
|
|
4
|
+
/**
|
|
5
|
+
* Where this app keeps a person's picture. `Avatar` asks it, with the
|
|
6
|
+
* person's name, whenever it is handed no picture — and so does every part
|
|
7
|
+
* that draws a face through it: `Person`, `AvatarGroup`, `MembersPill`,
|
|
8
|
+
* `MembersDialog`, `PersonTrigger`, `IdentityMenu`.
|
|
9
|
+
*/
|
|
10
|
+
pictureFor?: (name: string) => string | null | undefined
|
|
11
|
+
/**
|
|
12
|
+
* How this app changes page without reloading. A plain click on a link to
|
|
13
|
+
* this app's own address calls it with the link's `href` instead of loading
|
|
14
|
+
* the page again: `Link` (and so `Card` and `Breadcrumb`'s crumbs),
|
|
15
|
+
* `NavItem` (and so `NavTree`'s rows), `RailItem`, `Chip`, `InlineChip`, and
|
|
16
|
+
* `IconButton` (and so `ToolbarLink`).
|
|
17
|
+
*/
|
|
18
|
+
navigate?: (href: string) => void
|
|
19
|
+
children: ReactNode
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type AppSetup = Omit<AppProviderProps, 'children'>
|
|
23
|
+
|
|
24
|
+
const Setup = createContext<AppSetup>({})
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What every part needs to know about the app it is in, said once at the top:
|
|
28
|
+
* where a person's picture comes from, and how to change page.
|
|
29
|
+
*
|
|
30
|
+
* Without it each app wrapped the parts that needed either: Peek's own
|
|
31
|
+
* `Avatar` and `Person` only looked a picture up, and Peek's and Ship's own
|
|
32
|
+
* `NavItem` only stopped a reload (Katerina, 8 October: "we would handle these
|
|
33
|
+
* in estiva-ui so we don't keep copies"). Both are optional, and a part
|
|
34
|
+
* without a provider above it draws exactly as it did.
|
|
35
|
+
*/
|
|
36
|
+
export function AppProvider({ pictureFor, navigate, children }: AppProviderProps) {
|
|
37
|
+
const value = useMemo(() => ({ pictureFor, navigate }), [pictureFor, navigate])
|
|
38
|
+
return <Setup.Provider value={value}>{children}</Setup.Provider>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The app's picture lookup, or nothing outside an `AppProvider`. */
|
|
42
|
+
export function usePictureFor(): AppSetup['pictureFor'] {
|
|
43
|
+
return useContext(Setup).pictureFor
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A link's click, kept inside the app: the caller's own `onClick` first, then,
|
|
48
|
+
* on a plain left click to this app's own address, the app's `navigate` in
|
|
49
|
+
* place of a page load.
|
|
50
|
+
*
|
|
51
|
+
* Left to the browser, as an `<a>` always is: a click the caller already
|
|
52
|
+
* handled (`preventDefault`), a click with a modifier or another button (a new
|
|
53
|
+
* tab), a link that opens elsewhere (`target`) or downloads, and an address on
|
|
54
|
+
* another site or in another scheme (`folder:`, `mailto:`). So an app that
|
|
55
|
+
* still passes its own router `onClick`, as Ship's `linkTo` does, is unchanged.
|
|
56
|
+
*/
|
|
57
|
+
export function useInAppClick<E extends HTMLElement>(href: string | undefined, onClick?: MouseEventHandler<E>): MouseEventHandler<E> | undefined {
|
|
58
|
+
const navigate = useContext(Setup).navigate
|
|
59
|
+
if (!navigate || href === undefined) return onClick
|
|
60
|
+
return (event: MouseEvent<E>) => {
|
|
61
|
+
onClick?.(event)
|
|
62
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
|
63
|
+
const link = event.currentTarget
|
|
64
|
+
const target = link.getAttribute('target')
|
|
65
|
+
if ((target && target !== '_self') || link.hasAttribute('download')) return
|
|
66
|
+
let url: URL
|
|
67
|
+
try {
|
|
68
|
+
url = new URL(href, window.location.href)
|
|
69
|
+
} catch {
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
if (url.origin !== window.location.origin) return
|
|
73
|
+
event.preventDefault()
|
|
74
|
+
navigate(href)
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/AppShell.mdx
CHANGED
|
@@ -69,6 +69,7 @@ import { AppShell, Banner, IdentityMenu, Rail, RailItem } from '@estiva-app/ui'
|
|
|
69
69
|
variant="floating"
|
|
70
70
|
menu={menuButton}
|
|
71
71
|
logo="Estiva"
|
|
72
|
+
onSearch={openLauncher}
|
|
72
73
|
identity={<IdentityMenu me={me} signedIn={signedIn} />}
|
|
73
74
|
banner={notice && <Banner tone={notice.tone}>{notice.text}</Banner>}
|
|
74
75
|
nav={<Rail>…</Rail>}
|
|
@@ -79,7 +80,9 @@ import { AppShell, Banner, IdentityMenu, Rail, RailItem } from '@estiva-app/ui'
|
|
|
79
80
|
|
|
80
81
|
- Give it a height: it fills `h-full` and manages `min-h-0` internally so
|
|
81
82
|
navigation and content scroll apart.
|
|
82
|
-
- `menu`, `logo` and `
|
|
83
|
+
- `menu`, `logo`, `search`, `onSearch` and `searchPlaceholder` pass through to
|
|
84
|
+
the **TopBar**: with `onSearch` the bar draws the search field and answers
|
|
85
|
+
Ctrl+K.
|
|
83
86
|
- In the floating manner the card owns `overflow-hidden` — your content
|
|
84
87
|
manages its own scrolling inside it.
|
|
85
88
|
- Desktop only for now — no narrow-screen behaviour is provided.
|
package/src/AppShell.stories.tsx
CHANGED
|
@@ -8,7 +8,6 @@ import { NavItem } from './NavItem'
|
|
|
8
8
|
import { PersonTrigger } from './PersonTrigger'
|
|
9
9
|
import { Rail } from './Rail'
|
|
10
10
|
import { RailItem } from './RailItem'
|
|
11
|
-
import { SearchInput } from './SearchInput'
|
|
12
11
|
import { Sidebar } from './Sidebar'
|
|
13
12
|
|
|
14
13
|
const meta = {
|
|
@@ -27,6 +26,7 @@ const meta = {
|
|
|
27
26
|
menu: { control: false },
|
|
28
27
|
logo: { control: false },
|
|
29
28
|
search: { control: false },
|
|
29
|
+
onSearch: { control: false },
|
|
30
30
|
identity: { control: false },
|
|
31
31
|
banner: { control: false },
|
|
32
32
|
nav: { control: false },
|
|
@@ -65,7 +65,7 @@ const rail = (
|
|
|
65
65
|
/** The structured frame: solid bar, sidebar, content beside it. */
|
|
66
66
|
export const Solid: Story = {
|
|
67
67
|
render: (args) => (
|
|
68
|
-
<AppShell {...args} logo="Estiva"
|
|
68
|
+
<AppShell {...args} logo="Estiva" onSearch={() => {}} identity={identity} nav={sidebar}>
|
|
69
69
|
<EmptyState message="Nothing here yet." />
|
|
70
70
|
</AppShell>
|
|
71
71
|
),
|
|
@@ -75,7 +75,7 @@ export const Solid: Story = {
|
|
|
75
75
|
/** A page taller than the frame: it scrolls in the frame's own bar — the region every page passes through — never a native one. */
|
|
76
76
|
export const SolidScrolls: Story = {
|
|
77
77
|
render: (args) => (
|
|
78
|
-
<AppShell {...args} logo="Estiva"
|
|
78
|
+
<AppShell {...args} logo="Estiva" onSearch={() => {}} identity={identity} nav={sidebar}>
|
|
79
79
|
<div className="flex flex-col gap-px px-6 py-5">
|
|
80
80
|
{Array.from({ length: 60 }, (_, i) => (
|
|
81
81
|
<p key={i} className="rounded-md px-2 py-1.5 text-body-2 text-text-primary">
|
|
@@ -99,7 +99,7 @@ export const SolidWithBanner: Story = {
|
|
|
99
99
|
export const Inset: Story = {
|
|
100
100
|
args: { variant: 'inset' },
|
|
101
101
|
render: (args) => (
|
|
102
|
-
<AppShell {...args} logo="Estiva"
|
|
102
|
+
<AppShell {...args} logo="Estiva" onSearch={() => {}} identity={identity} nav={sidebar}>
|
|
103
103
|
<EmptyState message="Nothing here yet." />
|
|
104
104
|
</AppShell>
|
|
105
105
|
),
|
|
@@ -122,7 +122,7 @@ export const Floating: Story = {
|
|
|
122
122
|
variant="floating"
|
|
123
123
|
menu={menuButton}
|
|
124
124
|
logo="Estiva"
|
|
125
|
-
|
|
125
|
+
onSearch={() => {}}
|
|
126
126
|
identity={identity}
|
|
127
127
|
nav={rail}
|
|
128
128
|
>
|
package/src/AppShell.tsx
CHANGED
|
@@ -34,8 +34,12 @@ export interface AppShellProps {
|
|
|
34
34
|
menu?: ReactNode
|
|
35
35
|
/** The top bar's left: the app's logo mark, or its name as text. */
|
|
36
36
|
logo?: ReactNode
|
|
37
|
-
/** The top bar's centre
|
|
37
|
+
/** The top bar's centre: something of the caller's own. */
|
|
38
38
|
search?: ReactNode
|
|
39
|
+
/** What searching opens: the top bar then draws its search field and answers Ctrl+K (`TopBar`'s `onSearch`). */
|
|
40
|
+
onSearch?: () => void
|
|
41
|
+
/** The words in the top bar's search field. */
|
|
42
|
+
searchPlaceholder?: string
|
|
39
43
|
/** The top bar's right cluster — an `IdentityMenu`. */
|
|
40
44
|
identity?: ReactNode
|
|
41
45
|
/** A `Banner`, or nothing — drawn at the top of the content area. */
|
|
@@ -45,8 +49,8 @@ export interface AppShellProps {
|
|
|
45
49
|
children: ReactNode
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
export function AppShell({ variant = 'solid', menu, logo, search, identity, banner, nav, children }: AppShellProps) {
|
|
49
|
-
const bar = <TopBar variant={variant} menu={menu} logo={logo} search={search} right={identity} />
|
|
52
|
+
export function AppShell({ variant = 'solid', menu, logo, search, onSearch, searchPlaceholder, identity, banner, nav, children }: AppShellProps) {
|
|
53
|
+
const bar = <TopBar variant={variant} menu={menu} logo={logo} search={search} onSearch={onSearch} searchPlaceholder={searchPlaceholder} right={identity} />
|
|
50
54
|
const inset = variant === 'inset'
|
|
51
55
|
|
|
52
56
|
if (variant === 'floating') {
|
package/src/Avatar.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Avatar as BaseAvatar } from '@base-ui/react/avatar'
|
|
2
2
|
import { IconUserFilled } from '@tabler/icons-react'
|
|
3
|
+
import { usePictureFor } from './AppProvider'
|
|
3
4
|
import { cn } from './cn'
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -51,7 +52,7 @@ export const initialsFor = (name: string) => {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
export interface AvatarProps {
|
|
54
|
-
/** The picture URL, resolved by the caller
|
|
55
|
+
/** The picture URL, resolved by the caller — or, when none is given, by the app's `AppProvider`. Until it arrives, and if it fails, the initials show. */
|
|
55
56
|
src?: string
|
|
56
57
|
/** The person's name — where the initials and the colour come from. */
|
|
57
58
|
name?: string
|
|
@@ -76,6 +77,9 @@ export interface AvatarProps {
|
|
|
76
77
|
|
|
77
78
|
export function Avatar({ src, name, alt = '', size = 36, label: spoken, className }: AvatarProps) {
|
|
78
79
|
const label = name || alt
|
|
80
|
+
// No picture handed in: the app's own, when it has said where pictures live (`AppProvider`).
|
|
81
|
+
const pictureFor = usePictureFor()
|
|
82
|
+
const picture = src ?? (label && pictureFor ? (pictureFor(label) ?? undefined) : undefined)
|
|
79
83
|
return (
|
|
80
84
|
<BaseAvatar.Root
|
|
81
85
|
// A div, as it was drawn: Base UI's default is a span.
|
|
@@ -87,9 +91,9 @@ export function Avatar({ src, name, alt = '', size = 36, label: spoken, classNam
|
|
|
87
91
|
className={cn('rounded-sm overflow-hidden shrink-0 bg-bg-inset', className)}
|
|
88
92
|
style={{ width: size, height: size }}
|
|
89
93
|
>
|
|
90
|
-
{
|
|
94
|
+
{picture && (
|
|
91
95
|
<BaseAvatar.Image
|
|
92
|
-
src={
|
|
96
|
+
src={picture}
|
|
93
97
|
alt=""
|
|
94
98
|
/*
|
|
95
99
|
`keepMounted`: the <img> is in the tile from the first render, as it
|
package/src/Breadcrumb.mdx
CHANGED
|
@@ -48,8 +48,8 @@ import { Breadcrumb } from '@estiva-app/ui'
|
|
|
48
48
|
/>
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
- A crumb is a
|
|
52
|
-
navigate
|
|
51
|
+
- A crumb is a **Link**, so it changes page as Link does: through
|
|
52
|
+
**AppProvider**'s `navigate`, or an `onClick` per crumb. The `href` stays a real
|
|
53
53
|
address, so the link can still be copied or opened in a new tab.
|
|
54
54
|
- Truncation is re-measured as the trail resizes; the tooltip appears and
|
|
55
55
|
disappears with the room the trail actually has. Give the trail a
|
package/src/Card.mdx
CHANGED
|
@@ -62,7 +62,7 @@ import { Card } from '@estiva-app/ui'
|
|
|
62
62
|
- It draws no padding and no layout of its own: put them in `className`, or let
|
|
63
63
|
the content pad itself.
|
|
64
64
|
- With `href` it renders `Link`'s anchor, with every anchor prop passed through,
|
|
65
|
-
so
|
|
65
|
+
so it changes page as **Link** does and the address stays real.
|
|
66
66
|
- Without `href`, a card that responds to a click gets `cursor-pointer` from its
|
|
67
67
|
`hover` and your `onClick` — the selected one too, since it can open again;
|
|
68
68
|
the one being changed does not. Controls inside it should stop their own
|
package/src/Chip.mdx
CHANGED
|
@@ -38,8 +38,8 @@ import { Chip } from '@estiva-app/ui'
|
|
|
38
38
|
it never rides the text baseline.
|
|
39
39
|
- A count alone works too — `label="3"`, no icon.
|
|
40
40
|
- `href` makes the chip a link to the thing it names: a real anchor with the
|
|
41
|
-
chip's round shape, so the keyboard ring is round too.
|
|
42
|
-
|
|
41
|
+
chip's round shape, so the keyboard ring is round too. It changes page as
|
|
42
|
+
**Link** does. Inside a row whose **Link** has `cover`, it
|
|
43
43
|
stays clickable on its own: the row opens one thing, the chip another.
|
|
44
44
|
|
|
45
45
|
```tsx
|
package/src/Chip.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { MouseEventHandler, ReactNode } from 'react'
|
|
2
|
+
import { useInAppClick } from './AppProvider'
|
|
2
3
|
import { cn } from './cn'
|
|
3
4
|
import { CHIP_TEXT_CLASSES } from './looks'
|
|
4
5
|
|
|
@@ -41,6 +42,7 @@ const typeStyles: Record<ChipType, string> = {
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export function Chip({ type = 'neutral', label, leadingIcon, trailingIcon, href, onClick, className }: ChipProps) {
|
|
45
|
+
const inApp = useInAppClick(href, onClick)
|
|
44
46
|
const classes = cn('inline-flex min-w-0 items-center justify-center gap-1.5 rounded-full max-h-[20px] px-2 py-1', typeStyles[type], className)
|
|
45
47
|
const inside = (
|
|
46
48
|
<>
|
|
@@ -60,6 +62,6 @@ export function Chip({ type = 'neutral', label, leadingIcon, trailingIcon, href,
|
|
|
60
62
|
</>
|
|
61
63
|
)
|
|
62
64
|
// `relative` lifts a linked chip above a covering link that comes before it.
|
|
63
|
-
if (href) return <a href={href} onClick={
|
|
65
|
+
if (href) return <a href={href} onClick={inApp} className={cn('relative', classes)}>{inside}</a>
|
|
64
66
|
return <div className={classes}>{inside}</div>
|
|
65
67
|
}
|
package/src/CommandPalette.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import { Autocomplete } from '@base-ui/react/autocomplete'
|
|
|
15
15
|
import type { BaseUIEvent } from '@base-ui/react/types'
|
|
16
16
|
import { IconLoader2, IconSearch } from '@tabler/icons-react'
|
|
17
17
|
import { cn } from './cn'
|
|
18
|
+
import { MONO_CAPTION_CLASSES } from './looks'
|
|
18
19
|
import { Button } from './Button'
|
|
19
20
|
import { Card } from './Card'
|
|
20
21
|
import { InputChip } from './ChipInput'
|
|
@@ -176,7 +177,7 @@ type Key = [key: string, word: string]
|
|
|
176
177
|
function Footer({ keys }: { keys: Key[] }) {
|
|
177
178
|
const { where } = usePalette('The footer')
|
|
178
179
|
return (
|
|
179
|
-
<div className=
|
|
180
|
+
<div className={cn('flex h-9 shrink-0 items-center gap-4 border-t border-border-subtle px-5', MONO_CAPTION_CLASSES, 'signal:text-text-muted leaf:font-mono leaf:text-small')}>
|
|
180
181
|
<span className="min-w-0 flex-1 truncate">{where}</span>
|
|
181
182
|
{keys.map(([key, word]) => (
|
|
182
183
|
<span key={key} className="flex shrink-0 items-center gap-1.5">
|
package/src/ComposerTriggers.mdx
CHANGED
|
@@ -92,6 +92,13 @@ const urgent = editor ? urgentPubkeys(editor) : undefined
|
|
|
92
92
|
the same query; nothing waits for it. A row's `description` is its second
|
|
93
93
|
line, which the chip does not wear. A row the person has arrowed to stays
|
|
94
94
|
highlighted when the list is drawn again.
|
|
95
|
+
- **Several kinds of thing in one `[` list:** a row's `icon` is drawn in
|
|
96
|
+
place of the list's, and the chip's `iconFor(uri)` picks the chip's from
|
|
97
|
+
what it points at, so the chip wears what its row wore. A row with no `icon`,
|
|
98
|
+
or a `uri` `iconFor` answers `undefined` for, keeps the one icon.
|
|
99
|
+
|
|
100
|
+
<Canvas of={ComposerTriggersStories.ReferencesOfSeveralKinds} />
|
|
101
|
+
|
|
95
102
|
- **An app's own node name:** `CaptionedReference.extend({ name: 'yourName' })`
|
|
96
103
|
and `ReferenceTrigger.configure({ nodeName: 'yourName' })`. The chip's HTML
|
|
97
104
|
attribute follows the name (`data-your-name`).
|