@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,184 @@
1
+ import { useId, useState, type ReactNode } from 'react'
2
+ import { IconArrowLeft, IconUserMinus, IconUserPlus } from '@tabler/icons-react'
3
+ import { Avatar } from './Avatar'
4
+ import { Button } from './Button'
5
+ import { Chip } from './Chip'
6
+ import { ChipInput, type ChipInputOption } from './ChipInput'
7
+ import { DialogShell } from './DialogShell'
8
+ import { Field } from './Field'
9
+ import { Form } from './Form'
10
+ import { IconButton } from './IconButton'
11
+ import { MenuItem } from './Menu'
12
+
13
+ /** One row of the roster: a face, a name, and an optional second line. */
14
+ export interface MembersDialogMember {
15
+ id: string
16
+ name: string
17
+ /** A picture URL, resolved by the caller, as `Avatar` takes its `src`. */
18
+ picture?: string
19
+ /** The line under the name: a role, a relation ("Assignee"), or a state. */
20
+ caption?: string
21
+ }
22
+
23
+ /** Somebody who can be added: a `ChipInput` option with a face. */
24
+ export interface MembersDialogCandidate extends ChipInputOption {
25
+ picture?: string
26
+ }
27
+
28
+ /** What the viewer's own row offers: in when they are out, out when they are in. */
29
+ export type MembersDialogSelfAction = 'join' | 'leave'
30
+
31
+ export interface MembersDialogProps {
32
+ /** The roster, in the order to draw it. The viewer's own row is the caller's to name ("You"). */
33
+ members: MembersDialogMember[]
34
+ /** The number in the title's chip. Defaults to how many rows there are. */
35
+ count?: number
36
+ /**
37
+ * The viewer's own row on top: "Join" when they are not in, "Leave" when
38
+ * they are. Absent while it is not known, or where they cannot change it —
39
+ * no row is better than a wrong one.
40
+ */
41
+ self?: {
42
+ action: MembersDialogSelfAction
43
+ onToggle: () => void | Promise<unknown>
44
+ }
45
+ /** Who "Add members" may offer. Members are left out by id. */
46
+ candidates?: MembersDialogCandidate[]
47
+ /**
48
+ * Adds the people chosen. With it, the "Add members" row shows and opens the
49
+ * add layer. Return `false` (or a promise of it) to stay on the add layer —
50
+ * nothing was added; anything else goes back to the roster.
51
+ */
52
+ onAdd?: (chosen: MembersDialogCandidate[]) => void | boolean | Promise<void | boolean>
53
+ /** One line under the roster — what membership does and does not cover. */
54
+ note?: ReactNode
55
+ /** `add` opens straight on the add layer. */
56
+ initialView?: 'list' | 'add'
57
+ onClose: () => void
58
+ }
59
+
60
+ const LEADING_CLASSES = 'size-8 rounded-md bg-accent-muted flex items-center justify-center shrink-0 text-accent-primary'
61
+
62
+ /**
63
+ * Who is in this, with joining, leaving and adding people — the list a
64
+ * `MembersPill` opens.
65
+ *
66
+ * Moved from Peek's `MembersDialog` (2026-10-06; Katerina's design of 22 July,
67
+ * with the row and inset rulings of 15 September). Two layers in one dialog:
68
+ * the roster, with the viewer's Join or Leave and "Add members" on top, and the
69
+ * add layer, whose back arrow returns to the roster. Adding returns to the
70
+ * roster so the new people are visible at once.
71
+ */
72
+ export function MembersDialog({ members, count, self, candidates = [], onAdd, note, initialView = 'list', onClose }: MembersDialogProps) {
73
+ const [view, setView] = useState<'list' | 'add'>(onAdd ? initialView : 'list')
74
+ const [chosen, setChosen] = useState<MembersDialogCandidate[]>([])
75
+ const [toggling, setToggling] = useState(false)
76
+ const formId = useId()
77
+
78
+ const toggle = async () => {
79
+ if (!self || toggling) return
80
+ setToggling(true)
81
+ try {
82
+ await self.onToggle()
83
+ } finally {
84
+ setToggling(false)
85
+ }
86
+ }
87
+
88
+ const add = async () => {
89
+ if (!onAdd || chosen.length === 0) return
90
+ if ((await onAdd(chosen)) === false) return
91
+ setChosen([])
92
+ setView('list')
93
+ }
94
+
95
+ if (view === 'add' && onAdd) {
96
+ return (
97
+ <DialogShell
98
+ title="Add members"
99
+ onClose={onClose}
100
+ headerContent={
101
+ <div className="flex min-w-0 items-center gap-2">
102
+ <IconButton tooltip="Back to members" aria-label="Back to members" className="-ml-2" onClick={() => setView('list')}>
103
+ <IconArrowLeft size={16} stroke={1.5} />
104
+ </IconButton>
105
+ <span className="truncate text-h4 text-text-primary">Add members</span>
106
+ </div>
107
+ }
108
+ bodyClassName="flex flex-col gap-6"
109
+ footer={
110
+ <>
111
+ <Button variant="muted" onClick={onClose}>Cancel</Button>
112
+ <Button variant="primary" type="submit" form={formId} disabled={chosen.length === 0}>Invite</Button>
113
+ </>
114
+ }
115
+ >
116
+ <Form id={formId} onSubmit={add}>
117
+ <Field label="Invite people" required>
118
+ <ChipInput
119
+ value={chosen}
120
+ onChange={setChosen}
121
+ options={candidates}
122
+ placeholder="Search people..."
123
+ excludeIds={members.map((m) => m.id)}
124
+ chipLeading={(o) => <Avatar name={o.label} src={o.picture} size={16} />}
125
+ rowLeading={(o) => <Avatar name={o.label} src={o.picture} size={32} />}
126
+ autoFocus
127
+ />
128
+ </Field>
129
+ </Form>
130
+ </DialogShell>
131
+ )
132
+ }
133
+
134
+ return (
135
+ <DialogShell
136
+ title="Members"
137
+ onClose={onClose}
138
+ headerContent={
139
+ <div className="flex min-w-0 items-center gap-2">
140
+ <span className="truncate text-h4 text-text-primary">Members</span>
141
+ <Chip label={String(count ?? members.length)} />
142
+ </div>
143
+ }
144
+ bodyMaxHeight="max-h-[400px]"
145
+ bodyClassName="p-0 py-2"
146
+ >
147
+ {/* MenuItem outside a Menu is a plain button. Its fill sits 8px in from the dialog's sides,
148
+ and its own 12px inset puts the square over the rows' faces, 20px in, at the same 48px. */}
149
+ {(self || onAdd) && (
150
+ <div className="flex flex-col px-2">
151
+ {self && (
152
+ <MenuItem
153
+ size="tall"
154
+ className="py-2"
155
+ disabled={toggling}
156
+ onClick={() => void toggle()}
157
+ leading={<span className={LEADING_CLASSES}>{self.action === 'join' ? <IconUserPlus size={16} stroke={1.5} /> : <IconUserMinus size={16} stroke={1.5} />}</span>}
158
+ label={self.action === 'join' ? 'Join' : 'Leave'}
159
+ />
160
+ )}
161
+ {onAdd && (
162
+ <MenuItem
163
+ size="tall"
164
+ className="py-2"
165
+ onClick={() => setView('add')}
166
+ leading={<span className={LEADING_CLASSES}><IconUserPlus size={16} stroke={1.5} /></span>}
167
+ label="Add members"
168
+ />
169
+ )}
170
+ </div>
171
+ )}
172
+ {members.map((member) => (
173
+ <div key={member.id} className="flex h-12 items-center gap-3 px-5">
174
+ <Avatar size={32} name={member.name} src={member.picture} />
175
+ <div className="flex min-w-0 flex-1 flex-col justify-center gap-[2px]">
176
+ <div className="truncate text-body-2 text-text-primary">{member.name}</div>
177
+ {member.caption && <div className="truncate text-caption text-text-secondary">{member.caption}</div>}
178
+ </div>
179
+ </div>
180
+ ))}
181
+ {note && <div className="px-5 pb-2 pt-3 text-caption text-text-secondary">{note}</div>}
182
+ </DialogShell>
183
+ )
184
+ }
@@ -0,0 +1,45 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as MembersPillStories from './MembersPill.stories'
3
+
4
+ <Meta of={MembersPillStories} />
5
+
6
+ # MembersPill
7
+
8
+ Who is in this: up to three faces and the total, in a small outlined pill
9
+ that opens the full list.
10
+
11
+ <Canvas of={MembersPillStories.TwoMembers} />
12
+
13
+ ## When
14
+
15
+ - A page or conversation header that says, at a glance, who is in it, and
16
+ opens the list — a **MembersDialog** — on a press.
17
+
18
+ ## When not
19
+
20
+ - The faces alone, with nothing to open → **AvatarGroup**.
21
+ - The people need to be readable → **MembersDialog**, or a list of **Person** rows.
22
+
23
+ ## How
24
+
25
+ ```tsx
26
+ import { MembersPill } from '@estiva-app/ui'
27
+
28
+ const people: { name: string; picture?: string }[] = []
29
+ const [open, setOpen] = useState(false)
30
+
31
+ <MembersPill members={people} onClick={() => setOpen(true)} />
32
+ ```
33
+
34
+ - `members` is what **AvatarGroup** takes; the first three are drawn.
35
+ - `count` is the number beside the faces when it is not `members.length` —
36
+ pass three faces and the real total.
37
+ - No `onClick` disables it; no members draws nothing.
38
+
39
+ ## What it owns
40
+
41
+ Nothing. It only draws. Clicks and keys are yours.
42
+
43
+ ## Props
44
+
45
+ <Controls of={MembersPillStories.TwoMembers} />
@@ -0,0 +1,38 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { MembersPill } from './MembersPill'
3
+
4
+ const meta = {
5
+ title: 'Primitives/MembersPill',
6
+ component: MembersPill,
7
+ args: {
8
+ members: [{ name: 'Ana Duarte' }, { name: 'Ravi Mehta' }],
9
+ onClick: () => {},
10
+ },
11
+ } satisfies Meta<typeof MembersPill>
12
+
13
+ export default meta
14
+ type Story = StoryObj<typeof meta>
15
+
16
+ export const TwoMembers: Story = {}
17
+
18
+ export const One: Story = {
19
+ args: { members: [{ name: 'Ana Duarte' }] },
20
+ }
21
+
22
+ /** Twelve members, three faces: the count says the rest. */
23
+ export const Many: Story = {
24
+ args: {
25
+ members: [{ name: 'Ana Duarte' }, { name: 'Ravi Mehta' }, { name: 'Marta Silva' }],
26
+ count: 12,
27
+ },
28
+ }
29
+
30
+ /** Nothing to open — the pill is disabled, not another element. */
31
+ export const Disabled: Story = {
32
+ args: { onClick: undefined },
33
+ }
34
+
35
+ /** No members draws nothing at all. */
36
+ export const Empty: Story = {
37
+ args: { members: [] },
38
+ }
@@ -0,0 +1,35 @@
1
+ import { AvatarGroup, type AvatarGroupMember } from './AvatarGroup'
2
+ import { Button } from './Button'
3
+ import { cn } from './cn'
4
+ import { MONO_CAPTION_CLASSES } from './looks'
5
+
6
+ export interface MembersPillProps {
7
+ /** Who is in it, in order. Up to three faces are drawn. */
8
+ members: AvatarGroupMember[]
9
+ /** The number beside the faces. Defaults to how many members there are. */
10
+ count?: number
11
+ /** Opens the list — usually a `MembersDialog`. Without it the pill is disabled. */
12
+ onClick?: () => void
13
+ className?: string
14
+ }
15
+
16
+ /**
17
+ * Who is in this: up to three faces and the total, in a small outlined pill
18
+ * that opens the full list.
19
+ *
20
+ * Moved from Peek's `MembersPill` (2026-10-06), unchanged: the package's
21
+ * Button, outlined, at its default 32px — the faces sit 3px inside the
22
+ * hairline on the top, the bottom and the left, 8px from the count (Katerina's
23
+ * pick of 15 September). `disabled` rather than another element when there is
24
+ * nothing to press, so one element keeps one set of pixels.
25
+ */
26
+ export function MembersPill({ members, count, onClick, className }: MembersPillProps) {
27
+ const total = count ?? members.length
28
+ if (total === 0) return null
29
+ return (
30
+ <Button variant="outlined" aria-label={`${total} ${total === 1 ? 'member' : 'members'}`} disabled={!onClick} onClick={onClick} className={cn('gap-2 pl-[3px] pr-2', className)}>
31
+ <AvatarGroup members={members} />
32
+ <span className={cn(MONO_CAPTION_CLASSES, 'signal:tabular-nums')}>{total}</span>
33
+ </Button>
34
+ )
35
+ }
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
@@ -13,9 +13,9 @@ import { BARE_INPUT_CLASSES, FIELD_SHELL_CLASSES } from './looks'
13
13
  * The one product string — Peek's default placeholder "Search Peek..." —
14
14
  * stays in Peek; the default here says only "Search…".
15
15
  *
16
- * In Peek's top bar this is a launcher affordance rather than a live field:
17
- * the input is `pointer-events-none` and clicking the surround opens the
18
- * command launcher. The component is the same either way.
16
+ * The top bar's way into a launcher is not this: `TopBar`'s `onSearch` draws
17
+ * this look as a button and answers Ctrl+K itself (8 October). Peek used to
18
+ * make this field inert and catch the click around it.
19
19
  *
20
20
  * On Base UI's `Input` since stage 3 of the migration (2026-09-07), so a
21
21
  * search field inside a `Field` is labelled by it without the caller wiring
package/src/Sidebar.mdx CHANGED
@@ -48,8 +48,8 @@ import { Sidebar, NavItem, CollapsibleSection, SectionHeader } from '@estiva-app
48
48
  ```
49
49
 
50
50
  **Every row is a plain anchor, so in a router app each click would reload
51
- the page.** Keep NavItem's rule: a thin wrapper computes `active` from the
52
- location, intercepts the click and navigates in place — see **NavItem**.
51
+ the page** — unless the app gives **AppProvider**'s `navigate`, which
52
+ changes page in place. See **NavItem**.
53
53
 
54
54
  Anything you place directly in the column needs `shrink-0` (both headings
55
55
  above take it in `className`):
package/src/Tabs.mdx CHANGED
@@ -17,8 +17,13 @@ is a neutral fill, not the accent — selection is a place, not a highlight.
17
17
  baseline — the one way a number sits beside a label. A count of `0` is
18
18
  still drawn ("Assigned to me · 0" is an answer); an absent count draws
19
19
  nothing.
20
+ - A tab with `unread` draws the **UnreadDot** after its label (and count),
21
+ without the dot's 24px slot so the row keeps one height, and says "new" in
22
+ its accessible name.
20
23
  - Two sizes: `default` (14px) and `small` (12px, the denser geometry).
21
24
 
25
+ <Canvas of={TabsStories.WithUnread} />
26
+
22
27
  <Canvas of={TabsStories.WithAZero} />
23
28
 
24
29
  ## When not
@@ -74,6 +74,21 @@ export const WithAnIcon: Story = {
74
74
  ),
75
75
  }
76
76
 
77
+ /** `unread`: something new behind a tab you are not on — the accent dot after the label. */
78
+ export const WithUnread: Story = {
79
+ args: { tabs: [], active: 'overview', onChange: () => {} },
80
+ render: () => (
81
+ <Demo
82
+ initial="overview"
83
+ tabs={[
84
+ { id: 'overview', label: 'Overview' },
85
+ { id: 'activity', label: 'Activity', unread: true },
86
+ { id: 'all', label: 'All', count: 12, unread: true },
87
+ ]}
88
+ />
89
+ ),
90
+ }
91
+
77
92
  /** `small` is the 12px geometry. */
78
93
  export const Small: Story = {
79
94
  args: { tabs: [], active: 'conversations', onChange: () => {} },
package/src/Tabs.test.tsx CHANGED
@@ -123,6 +123,14 @@ describe('Tabs', () => {
123
123
  expect(list.getAttribute('aria-labelledby')).toBeNull()
124
124
  })
125
125
 
126
+ it('an unread tab draws the dot and says "new" in its name; the others draw neither', () => {
127
+ const tabs: TabDef<Id>[] = [{ id: 'one', label: 'One' }, { id: 'two', label: 'Two', count: 3, unread: true }, { id: 'three', label: 'Three' }]
128
+ render(<Tabs tabs={tabs} active="one" onChange={() => {}} />)
129
+ expect(screen.getByRole('tab', { name: /^Two.*\bnew$/ }).querySelector('[data-unread]')).not.toBeNull()
130
+ expect(screen.getByRole('tab', { name: 'One' }).querySelector('[data-unread]')).toBeNull()
131
+ expect(screen.getByRole('tab', { name: 'Three' }).querySelector('[data-unread]')).toBeNull()
132
+ })
133
+
126
134
  it('puts className on the outer box, around the row', () => {
127
135
  const { container } = render(<Tabs tabs={THREE} active="one" onChange={() => {}} className="mt-4" />)
128
136
  const outer = container.firstElementChild as HTMLElement
package/src/Tabs.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import { Tabs as BaseTabs } from '@base-ui/react/tabs'
3
3
  import { cn } from './cn'
4
+ import { UnreadDot } from './UnreadDot'
4
5
 
5
6
  /**
6
7
  * A row of tabs. Ship's Tabs (2026-09-01), which was Peek's TopicTabs with
@@ -26,6 +27,8 @@ export interface TabDef<T extends string> {
26
27
  count?: number
27
28
  /** 16px, stroke 1.5. */
28
29
  icon?: ReactNode
30
+ /** Something new behind this tab: the `UnreadDot` after the label, and "new" in the tab's accessible name. */
31
+ unread?: boolean
29
32
  }
30
33
 
31
34
  export interface TabsProps<T extends string> {
@@ -91,6 +94,15 @@ export function Tabs<T extends string>({ tabs, active, onChange, size = 'default
91
94
  <span className="ml-2.5 font-mono text-caption tabular-nums text-text-secondary">{tab.count}</span>
92
95
  ) : null}
93
96
  </span>
97
+ {/* The dot without its 24px slot: a tab is a line of text, and the
98
+ slot would make an unread tab taller than its neighbours. The
99
+ disc says nothing to a screen reader, so the name says "new". */}
100
+ {tab.unread ? (
101
+ <>
102
+ <UnreadDot className="h-auto w-auto" />
103
+ <span className="sr-only">new</span>
104
+ </>
105
+ ) : null}
94
106
  </BaseTabs.Tab>
95
107
  ))}
96
108
  </BaseTabs.List>
package/src/TopBar.mdx CHANGED
@@ -5,7 +5,7 @@ import * as TopBarStories from './TopBar.stories'
5
5
 
6
6
  # TopBar
7
7
 
8
- The 52px top bar: `menu` and `logo` on the left, a `search` slot in the
8
+ The 52px top bar: `menu` and `logo` on the left, the search field in the
9
9
  centre, the identity cluster on the right. Three manners — **`solid`**,
10
10
  in flow with a hairline and a surface; **`inset`**, in flow with neither,
11
11
  standing on the ground; and **`floating`**, an overlay with neither, whose
@@ -27,8 +27,12 @@ bar ignores the pointer so the content underneath stays reachable.
27
27
  - `menu` is the menu button, in an app whose navigation collapses — the
28
28
  collapsing itself stays the caller's. `logo` sits beside it: the app's
29
29
  name as text, or a mark.
30
- - The `search` slot holds its place even while empty, so a search field
31
- drops in later without moving anything.
30
+ - `onSearch` is what searching opens — your command launcher. With it the
31
+ bar draws the search field itself: **SearchInput**'s look, 290px, as a
32
+ button showing `searchPlaceholder` ("Search…") and the shortcut. A click on
33
+ it, and Ctrl+K (Cmd+K on a Mac) anywhere on the page, call `onSearch`.
34
+ - The centre holds its place even while empty, so search drops in later
35
+ without moving anything.
32
36
 
33
37
  <Canvas of={TopBarStories.FloatingNoSearch} />
34
38
 
@@ -37,25 +41,29 @@ bar ignores the pointer so the content underneath stays reachable.
37
41
  - Page-level headers inside the content → not this; this is the frame's
38
42
  bar, once per app. The top of a column is **ContainerHeader**.
39
43
  - Anything the bar would need to know — who you are, what is searched —
40
- stays outside: pass composed slots.
44
+ stays outside: pass composed slots, and what searching opens as `onSearch`.
45
+ - A search field of your own in the centre → only for something the bar's
46
+ does not do; pass it as `search`, which takes the centre (`onSearch` still
47
+ answers Ctrl+K). A field that filters a list sits above that list:
48
+ **SearchInput**.
41
49
 
42
50
  ## How
43
51
 
44
52
  ```tsx
45
- import { TopBar, IdentityMenu, SearchInput } from '@estiva-app/ui'
53
+ import { TopBar, IdentityMenu } from '@estiva-app/ui'
46
54
 
47
55
  <TopBar
48
56
  logo="Estiva"
49
- search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />}
57
+ onSearch={openLauncher}
58
+ searchPlaceholder="Search Estiva…"
50
59
  right={<IdentityMenu me={me} signedIn={signedIn} />}
51
60
  />
52
61
  ```
53
62
 
54
63
  - Geometry is fixed: 52px, `pl-5 pr-[26px]`, right cluster `gap-[6px]` —
55
64
  don't restyle it per app.
56
- - In `floating`, a launcher-affordance search is the caller's trick: keep
57
- the input `pointer-events-none` and open the launcher from a wrapper's
58
- `mousedown` (see SearchInput's page).
65
+ - `onSearch` is called on every Ctrl+K, open or not: to close the launcher
66
+ on a second Ctrl+K, toggle in your handler.
59
67
  - The frame is desktop-only for now — there is no narrow-screen
60
68
  behaviour; the menu button collapses panels, it does not open a drawer.
61
69
 
@@ -69,6 +77,7 @@ It does these by itself. Write none of them around it.
69
77
  | It does | So you never write | Refused when written by hand |
70
78
  |---|---|---|
71
79
  | Keeps its 52px in a scrolling column | `shrink-0` | |
80
+ | Answers Ctrl+K (Cmd+K) anywhere on the page, given `onSearch` | a `keydown` listener on the window | |
72
81
 
73
82
  ## Props
74
83
 
@@ -1,9 +1,8 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { IconMenu2 } from '@tabler/icons-react'
3
- import type { ReactNode } from 'react'
3
+ import { useState, type ReactNode } from 'react'
4
4
  import { IconButton } from './IconButton'
5
5
  import { PersonTrigger } from './PersonTrigger'
6
- import { SearchInput } from './SearchInput'
7
6
  import { TopBar } from './TopBar'
8
7
 
9
8
  /**
@@ -26,6 +25,7 @@ const meta = {
26
25
  menu: { control: false },
27
26
  logo: { control: false },
28
27
  search: { control: false },
28
+ onSearch: { control: false },
29
29
  right: { control: false },
30
30
  },
31
31
  } satisfies Meta<typeof TopBar>
@@ -60,11 +60,29 @@ const Behind = ({ children }: { children: ReactNode }) => (
60
60
  export const Solid: Story = {
61
61
  render: (args) => (
62
62
  <Below>
63
- <TopBar {...args} logo="Estiva" search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />} right={face} />
63
+ <TopBar {...args} logo="Estiva" onSearch={() => {}} right={face} />
64
64
  </Below>
65
65
  ),
66
66
  }
67
67
 
68
+ /**
69
+ * The bar's own search field: a click on it, or Ctrl+K (Cmd+K on a Mac)
70
+ * anywhere, calls `onSearch` — here it counts, where an app opens its launcher.
71
+ */
72
+ export const Search: Story = {
73
+ render: function Search(args) {
74
+ const [opened, setOpened] = useState(0)
75
+ return (
76
+ <div className="flex h-40 flex-col overflow-hidden bg-bg-base">
77
+ <TopBar {...args} logo="Estiva" onSearch={() => setOpened((n) => n + 1)} searchPlaceholder="Search Estiva…" right={face} />
78
+ <div className="flex flex-1 items-center justify-center text-body-2 text-text-secondary">
79
+ {opened === 0 ? 'Click the field, or press Ctrl+K.' : `Search opened ${opened} ${opened === 1 ? 'time' : 'times'}.`}
80
+ </div>
81
+ </div>
82
+ )
83
+ },
84
+ }
85
+
68
86
  /** No search yet: the centre slot holds its place, so it drops in later without moving anything. */
69
87
  export const SolidNoSearch: Story = {
70
88
  render: (args) => (
@@ -78,7 +96,7 @@ export const SolidNoSearch: Story = {
78
96
  export const Floating: Story = {
79
97
  render: (args) => (
80
98
  <Behind>
81
- <TopBar {...args} variant="floating" menu={menuButton} search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />} right={face} />
99
+ <TopBar {...args} variant="floating" menu={menuButton} onSearch={() => {}} right={face} />
82
100
  </Behind>
83
101
  ),
84
102
  }