@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
|
@@ -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/IconButton.mdx
CHANGED
|
@@ -55,8 +55,8 @@ import { IconPencil } from '@tabler/icons-react'
|
|
|
55
55
|
and shows the reason in place of the tooltip — **on keyboard focus as
|
|
56
56
|
well as on hover**.
|
|
57
57
|
- **`href`** makes it a link that looks like the button: an anchor, so the
|
|
58
|
-
address stays real for a modified click or a new tab.
|
|
59
|
-
|
|
58
|
+
address stays real for a modified click or a new tab. It changes page as
|
|
59
|
+
**Link** does. While it cannot be used it
|
|
60
60
|
is the button — a link cannot be disabled.
|
|
61
61
|
- `tooltip` costs no wrapper: the button is the tooltip's trigger, so
|
|
62
62
|
this component's root is the `<button>` whether it carries one or not,
|
package/src/IconButton.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ComponentPropsWithRef, ReactNode } from 'react'
|
|
2
2
|
import { Button as BaseButton } from '@base-ui/react/button'
|
|
3
|
+
import { useInAppClick } from './AppProvider'
|
|
3
4
|
import { cn } from './cn'
|
|
4
5
|
import { MUTED_CONTROL_CLASSES } from './looks'
|
|
5
6
|
import { useFormBusy } from './formBusy'
|
|
@@ -67,6 +68,8 @@ export function IconButton({
|
|
|
67
68
|
}: IconButtonProps) {
|
|
68
69
|
// Inside a busy Form: switched off, and looking it (formBusy.ts).
|
|
69
70
|
const formBusy = useFormBusy()
|
|
71
|
+
const anchor = props as unknown as ComponentPropsWithRef<'a'>
|
|
72
|
+
const inApp = useInAppClick(href, anchor.onClick)
|
|
70
73
|
const unusable = disabled || !!disabledReason || formBusy
|
|
71
74
|
const look = (off: boolean) =>
|
|
72
75
|
cn(
|
|
@@ -104,7 +107,7 @@ export function IconButton({
|
|
|
104
107
|
)
|
|
105
108
|
const button =
|
|
106
109
|
href && !unusable ? (
|
|
107
|
-
<a href={href} className={look(false)} {...
|
|
110
|
+
<a href={href} className={look(false)} {...anchor} onClick={inApp}>
|
|
108
111
|
{children}
|
|
109
112
|
</a>
|
|
110
113
|
) : (
|
package/src/InlineChip.mdx
CHANGED
|
@@ -42,9 +42,9 @@ import { InlineChip } from '@estiva-app/ui'
|
|
|
42
42
|
- It belongs inside running text at the body size. `h-[1.4em]` is one line of
|
|
43
43
|
that text, and `align-top` pins the chip to the line, so a line with a chip
|
|
44
44
|
is the same height as a line without one.
|
|
45
|
-
- It renders a plain anchor when it has an `href
|
|
46
|
-
|
|
47
|
-
address for a modified click or a new tab. Inside something that opens on
|
|
45
|
+
- It renders a plain anchor when it has an `href`, and changes page as
|
|
46
|
+
**Link** does: through **AppProvider**'s `navigate`, or a router app's `onClick`.
|
|
47
|
+
The `href` stays a real address for a modified click or a new tab. Inside something that opens on
|
|
48
48
|
click, stop the event there so the chip's click is not also the container's.
|
|
49
49
|
- A long label: pass `className="max-w-[24ch]"` and wrap the text in
|
|
50
50
|
`<span className="truncate">`.
|
package/src/InlineChip.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ComponentPropsWithRef, ReactNode } from 'react'
|
|
2
|
+
import { useInAppClick } from './AppProvider'
|
|
2
3
|
import { cn } from './cn'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -67,6 +68,7 @@ export interface InlineChipProps extends Omit<ComponentPropsWithRef<'a'>, 'child
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
export function InlineChip({ tone = 'neutral', icon, href, className, children, ...props }: InlineChipProps) {
|
|
71
|
+
const inApp = useInAppClick(href, props.onClick)
|
|
70
72
|
const body = (
|
|
71
73
|
<>
|
|
72
74
|
{icon && <span className="flex size-4 shrink-0 items-center justify-center text-text-secondary">{icon}</span>}
|
|
@@ -83,7 +85,7 @@ export function InlineChip({ tone = 'neutral', icon, href, className, children,
|
|
|
83
85
|
)
|
|
84
86
|
}
|
|
85
87
|
return (
|
|
86
|
-
<a href={href} className={classes} {...props}>
|
|
88
|
+
<a href={href} className={classes} {...props} onClick={inApp}>
|
|
87
89
|
{body}
|
|
88
90
|
</a>
|
|
89
91
|
)
|
package/src/Link.mdx
CHANGED
|
@@ -60,9 +60,11 @@ import { Link } from '@estiva-app/ui'
|
|
|
60
60
|
</h3>
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
- It renders a plain anchor and passes every anchor prop through.
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
- It renders a plain anchor and passes every anchor prop through. Under
|
|
64
|
+
**AppProvider**'s `navigate`, a plain click on an address in the app changes page
|
|
65
|
+
without a reload; without it, a router app passes `onClick`, prevents the
|
|
66
|
+
default and navigates. The `href` stays a real address, so a modified click
|
|
67
|
+
or a new tab still works.
|
|
66
68
|
- `quiet` and `underlined` set no size and no colour at rest: put them inside
|
|
67
69
|
the text whose look they should take.
|
|
68
70
|
- `plain` wraps a block: give the Link `className="block"` and let the child
|
package/src/Link.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ComponentPropsWithRef, ReactNode } from 'react'
|
|
2
|
+
import { useInAppClick } from './AppProvider'
|
|
2
3
|
import { cn } from './cn'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -70,13 +71,15 @@ export interface LinkProps extends ComponentPropsWithRef<'a'> {
|
|
|
70
71
|
children: ReactNode
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
export function Link({ href, variant = 'text', external = false, truncate = false, cover = false, className, children, ...props }: LinkProps) {
|
|
74
|
+
export function Link({ href, variant = 'text', external = false, truncate = false, cover = false, className, children, onClick, ...props }: LinkProps) {
|
|
75
|
+
const inApp = useInAppClick(href, onClick)
|
|
74
76
|
return (
|
|
75
77
|
<a
|
|
76
78
|
href={href}
|
|
77
79
|
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
|
78
80
|
className={cn(VARIANT_CLASSES[variant], truncate && 'truncate', cover && COVER_CLASSES, className)}
|
|
79
81
|
{...props}
|
|
82
|
+
onClick={inApp}
|
|
80
83
|
>
|
|
81
84
|
{children}
|
|
82
85
|
</a>
|
package/src/NavItem.mdx
CHANGED
|
@@ -71,9 +71,9 @@ import { NavItem } from '@estiva-app/ui'
|
|
|
71
71
|
/>
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
- It renders a plain anchor
|
|
75
|
-
`
|
|
76
|
-
|
|
74
|
+
- It renders a plain anchor, and every anchor prop passes through. Under
|
|
75
|
+
**AppProvider**'s `navigate` a click changes page without a reload, so no
|
|
76
|
+
wrapper is needed: compute `active` from the location where you place it.
|
|
77
77
|
- The label truncates; the count never gives up its width.
|
|
78
78
|
- `menu` is the items only: `menu={<MenuItem label="Move to Folder…" onClick={move} />}`.
|
|
79
79
|
The ⋮'s name is `More options for {label}` unless `menuLabel` says otherwise.
|
package/src/NavItem.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useId, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react'
|
|
2
2
|
import { Collapsible } from '@base-ui/react/collapsible'
|
|
3
3
|
import { IconAlertSquareRounded, IconChevronRight, IconDotsVertical } from '@tabler/icons-react'
|
|
4
|
+
import { useInAppClick } from './AppProvider'
|
|
4
5
|
import { cn } from './cn'
|
|
5
6
|
import { COLLAPSIBLE_PANEL_CLASSES, COUNT_TEXT_CLASSES, SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
|
|
6
7
|
import { IconButton } from './IconButton'
|
|
@@ -92,8 +93,10 @@ export function NavItem({
|
|
|
92
93
|
open: openProp,
|
|
93
94
|
onOpenChange,
|
|
94
95
|
className,
|
|
96
|
+
onClick,
|
|
95
97
|
...props
|
|
96
98
|
}: NavItemProps) {
|
|
99
|
+
const inApp = useInAppClick(href, onClick)
|
|
97
100
|
const [openState, setOpenState] = useState(defaultOpen)
|
|
98
101
|
const panelId = useId()
|
|
99
102
|
const hasRows = children != null && children !== false
|
|
@@ -135,6 +138,7 @@ export function NavItem({
|
|
|
135
138
|
className,
|
|
136
139
|
)}
|
|
137
140
|
{...props}
|
|
141
|
+
onClick={inApp}
|
|
138
142
|
>
|
|
139
143
|
{(icon || hasRows) && (
|
|
140
144
|
<span
|
package/src/NavTree.mdx
CHANGED
|
@@ -92,8 +92,9 @@ import { NavTree, Sidebar } from '@estiva-app/ui'
|
|
|
92
92
|
</Sidebar>
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
- Each row is a **NavItem**, so a row is a link
|
|
96
|
-
|
|
95
|
+
- Each row is a **NavItem**, so a row is a link, and changes page as one
|
|
96
|
+
does: through **AppProvider**'s `navigate`, or `onSelect` preventing the
|
|
97
|
+
default and navigating.
|
|
97
98
|
- `storageKey` remembers each group open or closed in this browser, under
|
|
98
99
|
the key and the group's id. The rows under a row are not remembered yet.
|
|
99
100
|
- The words in `emptyMessage` are the caller's.
|
package/src/RailItem.mdx
CHANGED
|
@@ -40,9 +40,9 @@ import { Rail, RailItem } from '@estiva-app/ui'
|
|
|
40
40
|
</Rail>
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
- Both render plain anchors and a `nav` region.
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
- Both render plain anchors and a `nav` region. Under **AppProvider**'s `navigate`
|
|
44
|
+
a click changes page without a reload; compute `active` from the location
|
|
45
|
+
where you place each item.
|
|
46
46
|
- Icons are 16px at stroke 1.5.
|
|
47
47
|
|
|
48
48
|
- A tile is 64px wide and 48px tall: the 16px icon in its 32px square, over its
|
package/src/RailItem.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
|
|
2
|
+
import { useInAppClick } from './AppProvider'
|
|
2
3
|
import { cn } from './cn'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -22,13 +23,15 @@ export interface RailItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href
|
|
|
22
23
|
className?: string
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
export function RailItem({ href, icon, label, active = false, className, ...props }: RailItemProps) {
|
|
26
|
+
export function RailItem({ href, icon, label, active = false, className, onClick, ...props }: RailItemProps) {
|
|
27
|
+
const inApp = useInAppClick(href, onClick)
|
|
26
28
|
return (
|
|
27
29
|
<a
|
|
28
30
|
href={href}
|
|
29
31
|
aria-current={active ? 'page' : undefined}
|
|
30
32
|
className={cn('group flex h-12 w-full shrink-0 flex-col items-center gap-0.5 px-2 py-0.5', className)}
|
|
31
33
|
{...props}
|
|
34
|
+
onClick={inApp}
|
|
32
35
|
>
|
|
33
36
|
<div
|
|
34
37
|
className={cn(
|
package/src/SearchInput.mdx
CHANGED
|
@@ -13,12 +13,11 @@ focus, and an optional keyboard hint at the right edge.
|
|
|
13
13
|
## When
|
|
14
14
|
|
|
15
15
|
- Filtering a list, live, above the list it filters.
|
|
16
|
-
- As a **launcher affordance**: keep the input `pointer-events-none` and
|
|
17
|
-
open your command surface from a click on the surround — the component
|
|
18
|
-
is the same either way, and `shortcut` shows the way in ("Ctrl+K").
|
|
19
16
|
|
|
20
17
|
## When not
|
|
21
18
|
|
|
19
|
+
- The way into a launcher from the top bar → **TopBar**'s `onSearch`. The
|
|
20
|
+
bar draws this look as a button, 290px, and answers Ctrl+K itself.
|
|
22
21
|
- General text entry → **TextInput**.
|
|
23
22
|
- Picking entries from a directory → **ChipInput** or **Select**.
|
|
24
23
|
|
|
@@ -35,7 +34,7 @@ import { SearchInput } from '@estiva-app/ui'
|
|
|
35
34
|
`Input` since stage 3; the border, the focus-within rule and the `Kbd`
|
|
36
35
|
hint are this component's.
|
|
37
36
|
- **Give it an `aria-label` ("Search")** — the placeholder is not a name,
|
|
38
|
-
and this usually stands alone
|
|
37
|
+
and this usually stands alone above its list rather than in a `Field`.
|
|
39
38
|
Inside one, the label names it and you add nothing.
|
|
40
39
|
|
|
41
40
|
## What it owns
|