@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.
Files changed (71) 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/IconButton.d.ts.map +1 -1
  9. package/dist/InlineChip.d.ts.map +1 -1
  10. package/dist/Link.d.ts +1 -1
  11. package/dist/Link.d.ts.map +1 -1
  12. package/dist/NavItem.d.ts +1 -1
  13. package/dist/NavItem.d.ts.map +1 -1
  14. package/dist/RailItem.d.ts +1 -1
  15. package/dist/RailItem.d.ts.map +1 -1
  16. package/dist/SearchInput.d.ts +3 -3
  17. package/dist/TopBar.d.ts +14 -3
  18. package/dist/TopBar.d.ts.map +1 -1
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +710 -642
  22. package/dist/index.js.map +4 -4
  23. package/dist/registry/{app-LDGOH6UW.js → app-X2GI4WTU.js} +2 -2
  24. package/dist/registry/app.d.ts.map +1 -1
  25. package/dist/registry/{chunk-TQ6UQER3.js → chunk-V67F474L.js} +8 -3
  26. package/dist/registry/chunk-V67F474L.js.map +7 -0
  27. package/dist/registry/cli.js +1 -1
  28. package/dist/registry/index.js +1 -1
  29. package/package.json +1 -1
  30. package/registry.json +87 -9
  31. package/skill/estiva-ui.md +2 -1
  32. package/src/AppProvider.mdx +69 -0
  33. package/src/AppProvider.stories.tsx +60 -0
  34. package/src/AppProvider.test.tsx +152 -0
  35. package/src/AppProvider.tsx +76 -0
  36. package/src/AppShell.mdx +4 -1
  37. package/src/AppShell.stories.tsx +5 -5
  38. package/src/AppShell.tsx +7 -3
  39. package/src/Avatar.tsx +7 -3
  40. package/src/Breadcrumb.mdx +2 -2
  41. package/src/Card.mdx +1 -1
  42. package/src/Chip.mdx +2 -2
  43. package/src/Chip.tsx +3 -1
  44. package/src/IconButton.mdx +2 -2
  45. package/src/IconButton.tsx +4 -1
  46. package/src/InlineChip.mdx +3 -3
  47. package/src/InlineChip.tsx +3 -1
  48. package/src/Link.mdx +5 -3
  49. package/src/Link.tsx +4 -1
  50. package/src/NavItem.mdx +3 -3
  51. package/src/NavItem.tsx +4 -0
  52. package/src/NavTree.mdx +3 -2
  53. package/src/RailItem.mdx +3 -3
  54. package/src/RailItem.tsx +4 -1
  55. package/src/SearchInput.mdx +3 -4
  56. package/src/SearchInput.tsx +3 -3
  57. package/src/Sidebar.mdx +2 -2
  58. package/src/TopBar.mdx +18 -9
  59. package/src/TopBar.stories.tsx +22 -4
  60. package/src/TopBar.test.tsx +97 -0
  61. package/src/TopBar.tsx +45 -4
  62. package/src/index.ts +1 -0
  63. package/src/pages.test.ts +3 -1
  64. package/src/registry/app.test.ts +24 -6
  65. package/src/registry/app.ts +21 -2
  66. package/src/registry/contract.test.ts +1 -1
  67. package/src/registry/gated.test.ts +1 -0
  68. package/src/registry/registry.test.ts +4 -4
  69. package/stories/Choosing.mdx +2 -0
  70. package/dist/registry/chunk-TQ6UQER3.js.map +0 -7
  71. /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 `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
  }
@@ -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. A router app passes
59
- `onClick`, prevents the default and navigates. While it cannot be used it
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,
@@ -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)} {...(props as unknown as ComponentPropsWithRef<'a'>)}>
110
+ <a href={href} className={look(false)} {...anchor} onClick={inApp}>
108
111
  {children}
109
112
  </a>
110
113
  ) : (
@@ -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`. A router app passes
46
- `onClick`, prevents the default and navigates; the `href` stays a real
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">`.
@@ -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. A router app
64
- passes `onClick`, prevents the default and navigates; the `href` stays a real
65
- address, so a modified click or a new tab still works.
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. A router app keeps a thin wrapper: compute
75
- `active` from the location, intercept the click, navigate — every
76
- anchor prop passes through.
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: a router app prevents the
96
- default in `onSelect` and navigates.
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. A router app keeps a thin
44
- wrapper per item: compute `active` from the location, intercept the
45
- click, navigate.
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(
@@ -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 in a top bar rather than in a `Field`.
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