@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.
Files changed (103) hide show
  1. package/dist/AppProvider.d.ts +47 -0
  2. package/dist/AppProvider.d.ts.map +1 -0
  3. package/dist/AppShell.d.ts +6 -2
  4. package/dist/AppShell.d.ts.map +1 -1
  5. package/dist/Avatar.d.ts +1 -1
  6. package/dist/Avatar.d.ts.map +1 -1
  7. package/dist/Chip.d.ts.map +1 -1
  8. package/dist/CommandPalette.d.ts.map +1 -1
  9. package/dist/ComposerTriggers.d.ts +13 -1
  10. package/dist/ComposerTriggers.d.ts.map +1 -1
  11. package/dist/IconButton.d.ts.map +1 -1
  12. package/dist/InlineChip.d.ts.map +1 -1
  13. package/dist/Kbd.d.ts.map +1 -1
  14. package/dist/Link.d.ts +1 -1
  15. package/dist/Link.d.ts.map +1 -1
  16. package/dist/MembersDialog.d.ts +57 -0
  17. package/dist/MembersDialog.d.ts.map +1 -0
  18. package/dist/MembersPill.d.ts +22 -0
  19. package/dist/MembersPill.d.ts.map +1 -0
  20. package/dist/NavItem.d.ts +1 -1
  21. package/dist/NavItem.d.ts.map +1 -1
  22. package/dist/RailItem.d.ts +1 -1
  23. package/dist/RailItem.d.ts.map +1 -1
  24. package/dist/SearchInput.d.ts +3 -3
  25. package/dist/Tabs.d.ts +2 -0
  26. package/dist/Tabs.d.ts.map +1 -1
  27. package/dist/TopBar.d.ts +14 -3
  28. package/dist/TopBar.d.ts.map +1 -1
  29. package/dist/editor.js +4 -4
  30. package/dist/editor.js.map +2 -2
  31. package/dist/index.d.ts +3 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +1332 -1143
  34. package/dist/index.js.map +4 -4
  35. package/dist/looks.d.ts +2 -0
  36. package/dist/looks.d.ts.map +1 -1
  37. package/dist/registry/{app-LDGOH6UW.js → app-X2GI4WTU.js} +2 -2
  38. package/dist/registry/app.d.ts.map +1 -1
  39. package/dist/registry/{chunk-TQ6UQER3.js → chunk-V67F474L.js} +8 -3
  40. package/dist/registry/chunk-V67F474L.js.map +7 -0
  41. package/dist/registry/cli.js +1 -1
  42. package/dist/registry/index.js +1 -1
  43. package/package.json +1 -1
  44. package/registry.json +234 -14
  45. package/skill/estiva-ui.md +2 -1
  46. package/src/AppProvider.mdx +69 -0
  47. package/src/AppProvider.stories.tsx +60 -0
  48. package/src/AppProvider.test.tsx +152 -0
  49. package/src/AppProvider.tsx +76 -0
  50. package/src/AppShell.mdx +4 -1
  51. package/src/AppShell.stories.tsx +5 -5
  52. package/src/AppShell.tsx +7 -3
  53. package/src/Avatar.tsx +7 -3
  54. package/src/Breadcrumb.mdx +2 -2
  55. package/src/Card.mdx +1 -1
  56. package/src/Chip.mdx +2 -2
  57. package/src/Chip.tsx +3 -1
  58. package/src/CommandPalette.tsx +2 -1
  59. package/src/ComposerTriggers.mdx +7 -0
  60. package/src/ComposerTriggers.stories.tsx +43 -4
  61. package/src/ComposerTriggers.test.tsx +33 -0
  62. package/src/ComposerTriggers.tsx +17 -5
  63. package/src/IconButton.mdx +2 -2
  64. package/src/IconButton.tsx +4 -1
  65. package/src/InlineChip.mdx +3 -3
  66. package/src/InlineChip.tsx +3 -1
  67. package/src/Kbd.tsx +4 -2
  68. package/src/Link.mdx +5 -3
  69. package/src/Link.tsx +4 -1
  70. package/src/MembersDialog.mdx +69 -0
  71. package/src/MembersDialog.stories.tsx +68 -0
  72. package/src/MembersDialog.test.tsx +69 -0
  73. package/src/MembersDialog.tsx +184 -0
  74. package/src/MembersPill.mdx +45 -0
  75. package/src/MembersPill.stories.tsx +38 -0
  76. package/src/MembersPill.tsx +35 -0
  77. package/src/NavItem.mdx +3 -3
  78. package/src/NavItem.tsx +4 -0
  79. package/src/NavTree.mdx +3 -2
  80. package/src/RailItem.mdx +3 -3
  81. package/src/RailItem.tsx +4 -1
  82. package/src/SearchInput.mdx +3 -4
  83. package/src/SearchInput.tsx +3 -3
  84. package/src/Sidebar.mdx +2 -2
  85. package/src/Tabs.mdx +5 -0
  86. package/src/Tabs.stories.tsx +15 -0
  87. package/src/Tabs.test.tsx +8 -0
  88. package/src/Tabs.tsx +12 -0
  89. package/src/TopBar.mdx +18 -9
  90. package/src/TopBar.stories.tsx +22 -4
  91. package/src/TopBar.test.tsx +97 -0
  92. package/src/TopBar.tsx +45 -4
  93. package/src/index.ts +3 -0
  94. package/src/looks.ts +3 -0
  95. package/src/pages.test.ts +3 -1
  96. package/src/registry/app.test.ts +24 -6
  97. package/src/registry/app.ts +21 -2
  98. package/src/registry/contract.test.ts +1 -1
  99. package/src/registry/gated.test.ts +1 -0
  100. package/src/registry/registry.test.ts +4 -4
  101. package/stories/Choosing.mdx +4 -0
  102. package/dist/registry/chunk-TQ6UQER3.js.map +0 -7
  103. /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 `search` pass through to the TopBar's slots.
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.
@@ -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" search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />} identity={identity} nav={sidebar}>
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" search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />} identity={identity} nav={sidebar}>
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" search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />} identity={identity} nav={sidebar}>
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
- search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />}
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 — a search field, when there is one. */
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. Until it arrives, and if it fails, the initials show. */
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
- {src && (
94
+ {picture && (
91
95
  <BaseAvatar.Image
92
- src={src}
96
+ src={picture}
93
97
  alt=""
94
98
  /*
95
99
  `keepMounted`: the <img> is in the tile from the first render, as it
@@ -48,8 +48,8 @@ import { Breadcrumb } from '@estiva-app/ui'
48
48
  />
49
49
  ```
50
50
 
51
- - A crumb is a plain anchor. In a router app pass `onClick` per crumb and
52
- navigate in place there — NavItem's rule — and keep the `href` a real
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 a router app takes the click and the address stays real.
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. A router app passes
42
- `onClick`, as with **Link**. Inside a row whose **Link** has `cover`, it
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={onClick} className={cn('relative', classes)}>{inside}</a>
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
  }
@@ -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="flex h-9 shrink-0 items-center gap-4 border-t border-border-subtle px-5 text-caption text-text-secondary signal:font-mono signal:text-small signal:text-text-muted leaf:font-mono leaf:text-small">
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">
@@ -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`).