@estiva-app/ui 0.58.0 → 0.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AppProvider.d.ts +47 -0
- package/dist/AppProvider.d.ts.map +1 -0
- package/dist/AppShell.d.ts +6 -2
- package/dist/AppShell.d.ts.map +1 -1
- package/dist/Avatar.d.ts +1 -1
- package/dist/Avatar.d.ts.map +1 -1
- package/dist/Chip.d.ts.map +1 -1
- package/dist/CommandPalette.d.ts.map +1 -1
- package/dist/ComposerTriggers.d.ts +13 -1
- package/dist/ComposerTriggers.d.ts.map +1 -1
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/InlineChip.d.ts.map +1 -1
- package/dist/Kbd.d.ts.map +1 -1
- package/dist/Link.d.ts +1 -1
- package/dist/Link.d.ts.map +1 -1
- package/dist/MembersDialog.d.ts +57 -0
- package/dist/MembersDialog.d.ts.map +1 -0
- package/dist/MembersPill.d.ts +22 -0
- package/dist/MembersPill.d.ts.map +1 -0
- package/dist/NavItem.d.ts +1 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/RailItem.d.ts +1 -1
- package/dist/RailItem.d.ts.map +1 -1
- package/dist/SearchInput.d.ts +3 -3
- package/dist/Tabs.d.ts +2 -0
- package/dist/Tabs.d.ts.map +1 -1
- package/dist/TopBar.d.ts +14 -3
- package/dist/TopBar.d.ts.map +1 -1
- package/dist/editor.js +4 -4
- package/dist/editor.js.map +2 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1332 -1143
- package/dist/index.js.map +4 -4
- package/dist/looks.d.ts +2 -0
- package/dist/looks.d.ts.map +1 -1
- package/dist/registry/{app-LDGOH6UW.js → app-X2GI4WTU.js} +2 -2
- package/dist/registry/app.d.ts.map +1 -1
- package/dist/registry/{chunk-TQ6UQER3.js → chunk-V67F474L.js} +8 -3
- package/dist/registry/chunk-V67F474L.js.map +7 -0
- package/dist/registry/cli.js +1 -1
- package/dist/registry/index.js +1 -1
- package/package.json +1 -1
- package/registry.json +234 -14
- package/skill/estiva-ui.md +2 -1
- package/src/AppProvider.mdx +69 -0
- package/src/AppProvider.stories.tsx +60 -0
- package/src/AppProvider.test.tsx +152 -0
- package/src/AppProvider.tsx +76 -0
- package/src/AppShell.mdx +4 -1
- package/src/AppShell.stories.tsx +5 -5
- package/src/AppShell.tsx +7 -3
- package/src/Avatar.tsx +7 -3
- package/src/Breadcrumb.mdx +2 -2
- package/src/Card.mdx +1 -1
- package/src/Chip.mdx +2 -2
- package/src/Chip.tsx +3 -1
- package/src/CommandPalette.tsx +2 -1
- package/src/ComposerTriggers.mdx +7 -0
- package/src/ComposerTriggers.stories.tsx +43 -4
- package/src/ComposerTriggers.test.tsx +33 -0
- package/src/ComposerTriggers.tsx +17 -5
- package/src/IconButton.mdx +2 -2
- package/src/IconButton.tsx +4 -1
- package/src/InlineChip.mdx +3 -3
- package/src/InlineChip.tsx +3 -1
- package/src/Kbd.tsx +4 -2
- package/src/Link.mdx +5 -3
- package/src/Link.tsx +4 -1
- package/src/MembersDialog.mdx +69 -0
- package/src/MembersDialog.stories.tsx +68 -0
- package/src/MembersDialog.test.tsx +69 -0
- package/src/MembersDialog.tsx +184 -0
- package/src/MembersPill.mdx +45 -0
- package/src/MembersPill.stories.tsx +38 -0
- package/src/MembersPill.tsx +35 -0
- package/src/NavItem.mdx +3 -3
- package/src/NavItem.tsx +4 -0
- package/src/NavTree.mdx +3 -2
- package/src/RailItem.mdx +3 -3
- package/src/RailItem.tsx +4 -1
- package/src/SearchInput.mdx +3 -4
- package/src/SearchInput.tsx +3 -3
- package/src/Sidebar.mdx +2 -2
- package/src/Tabs.mdx +5 -0
- package/src/Tabs.stories.tsx +15 -0
- package/src/Tabs.test.tsx +8 -0
- package/src/Tabs.tsx +12 -0
- package/src/TopBar.mdx +18 -9
- package/src/TopBar.stories.tsx +22 -4
- package/src/TopBar.test.tsx +97 -0
- package/src/TopBar.tsx +45 -4
- package/src/index.ts +3 -0
- package/src/looks.ts +3 -0
- package/src/pages.test.ts +3 -1
- package/src/registry/app.test.ts +24 -6
- package/src/registry/app.ts +21 -2
- package/src/registry/contract.test.ts +1 -1
- package/src/registry/gated.test.ts +1 -0
- package/src/registry/registry.test.ts +4 -4
- package/stories/Choosing.mdx +4 -0
- package/dist/registry/chunk-TQ6UQER3.js.map +0 -7
- /package/dist/registry/{app-LDGOH6UW.js.map → app-X2GI4WTU.js.map} +0 -0
|
@@ -0,0 +1,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
|
|
75
|
-
`
|
|
76
|
-
|
|
74
|
+
- It renders a plain anchor, and every anchor prop passes through. Under
|
|
75
|
+
**AppProvider**'s `navigate` a click changes page without a reload, so no
|
|
76
|
+
wrapper is needed: compute `active` from the location where you place it.
|
|
77
77
|
- The label truncates; the count never gives up its width.
|
|
78
78
|
- `menu` is the items only: `menu={<MenuItem label="Move to Folder…" onClick={move} />}`.
|
|
79
79
|
The ⋮'s name is `More options for {label}` unless `menuLabel` says otherwise.
|
package/src/NavItem.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useId, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react'
|
|
2
2
|
import { Collapsible } from '@base-ui/react/collapsible'
|
|
3
3
|
import { IconAlertSquareRounded, IconChevronRight, IconDotsVertical } from '@tabler/icons-react'
|
|
4
|
+
import { useInAppClick } from './AppProvider'
|
|
4
5
|
import { cn } from './cn'
|
|
5
6
|
import { COLLAPSIBLE_PANEL_CLASSES, COUNT_TEXT_CLASSES, SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
|
|
6
7
|
import { IconButton } from './IconButton'
|
|
@@ -92,8 +93,10 @@ export function NavItem({
|
|
|
92
93
|
open: openProp,
|
|
93
94
|
onOpenChange,
|
|
94
95
|
className,
|
|
96
|
+
onClick,
|
|
95
97
|
...props
|
|
96
98
|
}: NavItemProps) {
|
|
99
|
+
const inApp = useInAppClick(href, onClick)
|
|
97
100
|
const [openState, setOpenState] = useState(defaultOpen)
|
|
98
101
|
const panelId = useId()
|
|
99
102
|
const hasRows = children != null && children !== false
|
|
@@ -135,6 +138,7 @@ export function NavItem({
|
|
|
135
138
|
className,
|
|
136
139
|
)}
|
|
137
140
|
{...props}
|
|
141
|
+
onClick={inApp}
|
|
138
142
|
>
|
|
139
143
|
{(icon || hasRows) && (
|
|
140
144
|
<span
|
package/src/NavTree.mdx
CHANGED
|
@@ -92,8 +92,9 @@ import { NavTree, Sidebar } from '@estiva-app/ui'
|
|
|
92
92
|
</Sidebar>
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
- Each row is a **NavItem**, so a row is a link
|
|
96
|
-
|
|
95
|
+
- Each row is a **NavItem**, so a row is a link, and changes page as one
|
|
96
|
+
does: through **AppProvider**'s `navigate`, or `onSelect` preventing the
|
|
97
|
+
default and navigating.
|
|
97
98
|
- `storageKey` remembers each group open or closed in this browser, under
|
|
98
99
|
the key and the group's id. The rows under a row are not remembered yet.
|
|
99
100
|
- The words in `emptyMessage` are the caller's.
|
package/src/RailItem.mdx
CHANGED
|
@@ -40,9 +40,9 @@ import { Rail, RailItem } from '@estiva-app/ui'
|
|
|
40
40
|
</Rail>
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
- Both render plain anchors and a `nav` region.
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
- Both render plain anchors and a `nav` region. Under **AppProvider**'s `navigate`
|
|
44
|
+
a click changes page without a reload; compute `active` from the location
|
|
45
|
+
where you place each item.
|
|
46
46
|
- Icons are 16px at stroke 1.5.
|
|
47
47
|
|
|
48
48
|
- A tile is 64px wide and 48px tall: the 16px icon in its 32px square, over its
|
package/src/RailItem.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
|
|
2
|
+
import { useInAppClick } from './AppProvider'
|
|
2
3
|
import { cn } from './cn'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -22,13 +23,15 @@ export interface RailItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href
|
|
|
22
23
|
className?: string
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
export function RailItem({ href, icon, label, active = false, className, ...props }: RailItemProps) {
|
|
26
|
+
export function RailItem({ href, icon, label, active = false, className, onClick, ...props }: RailItemProps) {
|
|
27
|
+
const inApp = useInAppClick(href, onClick)
|
|
26
28
|
return (
|
|
27
29
|
<a
|
|
28
30
|
href={href}
|
|
29
31
|
aria-current={active ? 'page' : undefined}
|
|
30
32
|
className={cn('group flex h-12 w-full shrink-0 flex-col items-center gap-0.5 px-2 py-0.5', className)}
|
|
31
33
|
{...props}
|
|
34
|
+
onClick={inApp}
|
|
32
35
|
>
|
|
33
36
|
<div
|
|
34
37
|
className={cn(
|
package/src/SearchInput.mdx
CHANGED
|
@@ -13,12 +13,11 @@ focus, and an optional keyboard hint at the right edge.
|
|
|
13
13
|
## When
|
|
14
14
|
|
|
15
15
|
- Filtering a list, live, above the list it filters.
|
|
16
|
-
- As a **launcher affordance**: keep the input `pointer-events-none` and
|
|
17
|
-
open your command surface from a click on the surround — the component
|
|
18
|
-
is the same either way, and `shortcut` shows the way in ("Ctrl+K").
|
|
19
16
|
|
|
20
17
|
## When not
|
|
21
18
|
|
|
19
|
+
- The way into a launcher from the top bar → **TopBar**'s `onSearch`. The
|
|
20
|
+
bar draws this look as a button, 290px, and answers Ctrl+K itself.
|
|
22
21
|
- General text entry → **TextInput**.
|
|
23
22
|
- Picking entries from a directory → **ChipInput** or **Select**.
|
|
24
23
|
|
|
@@ -35,7 +34,7 @@ import { SearchInput } from '@estiva-app/ui'
|
|
|
35
34
|
`Input` since stage 3; the border, the focus-within rule and the `Kbd`
|
|
36
35
|
hint are this component's.
|
|
37
36
|
- **Give it an `aria-label` ("Search")** — the placeholder is not a name,
|
|
38
|
-
and this usually stands alone
|
|
37
|
+
and this usually stands alone above its list rather than in a `Field`.
|
|
39
38
|
Inside one, the label names it and you add nothing.
|
|
40
39
|
|
|
41
40
|
## What it owns
|
package/src/SearchInput.tsx
CHANGED
|
@@ -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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
52
|
-
|
|
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
|
package/src/Tabs.stories.tsx
CHANGED
|
@@ -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,
|
|
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
|
-
-
|
|
31
|
-
|
|
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
|
|
53
|
+
import { TopBar, IdentityMenu } from '@estiva-app/ui'
|
|
46
54
|
|
|
47
55
|
<TopBar
|
|
48
56
|
logo="Estiva"
|
|
49
|
-
|
|
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
|
-
-
|
|
57
|
-
|
|
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
|
|
package/src/TopBar.stories.tsx
CHANGED
|
@@ -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
|
|
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"
|
|
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}
|
|
99
|
+
<TopBar {...args} variant="floating" menu={menuButton} onSearch={() => {}} right={face} />
|
|
82
100
|
</Behind>
|
|
83
101
|
),
|
|
84
102
|
}
|