@estiva-app/ui 0.57.4 → 0.59.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/CommandPalette.d.ts.map +1 -1
- package/dist/ComposerTriggers.d.ts +13 -1
- package/dist/ComposerTriggers.d.ts.map +1 -1
- package/dist/ContainerHeader.d.ts.map +1 -1
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/Kbd.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.map +1 -1
- package/dist/SectionHeader.d.ts +7 -1
- package/dist/SectionHeader.d.ts.map +1 -1
- package/dist/ShowMore.d.ts +27 -0
- package/dist/ShowMore.d.ts.map +1 -0
- package/dist/Tabs.d.ts +2 -0
- package/dist/Tabs.d.ts.map +1 -1
- package/dist/editor.js +4 -4
- package/dist/editor.js.map +2 -2
- package/dist/eslint/index.js +1 -0
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1150 -994
- package/dist/index.js.map +4 -4
- package/dist/looks.d.ts +4 -0
- package/dist/looks.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +212 -8
- 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/ContainerHeader.mdx +1 -1
- package/src/ContainerHeader.tsx +3 -2
- package/src/DialogShell.tsx +2 -0
- package/src/Kbd.tsx +4 -2
- package/src/ListColumn.mdx +1 -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.tsx +3 -2
- package/src/SectionHeader.mdx +6 -4
- package/src/SectionHeader.stories.tsx +7 -2
- package/src/SectionHeader.test.tsx +6 -0
- package/src/SectionHeader.tsx +10 -2
- package/src/ShowMore.mdx +62 -0
- package/src/ShowMore.stories.tsx +39 -0
- package/src/ShowMore.test.tsx +44 -0
- package/src/ShowMore.tsx +63 -0
- 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/eslint/no-restyled-part.ts +1 -0
- package/src/index.ts +3 -0
- package/src/looks.ts +6 -0
- package/src/registry/registry.test.ts +4 -4
- package/stories/Choosing.mdx +4 -1
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { MembersDialog } from './MembersDialog'
|
|
3
|
+
|
|
4
|
+
const people = [
|
|
5
|
+
{ id: 'ana', label: 'Ana Duarte' },
|
|
6
|
+
{ id: 'ravi', label: 'Ravi Mehta' },
|
|
7
|
+
{ id: 'marta', label: 'Marta Silva' },
|
|
8
|
+
{ id: 'jonas', label: 'Jonas Weber' },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
const meta = {
|
|
12
|
+
title: 'Primitives/MembersDialog',
|
|
13
|
+
component: MembersDialog,
|
|
14
|
+
args: {
|
|
15
|
+
members: [
|
|
16
|
+
{ id: 'me', name: 'You', caption: 'Product designer' },
|
|
17
|
+
{ id: 'ana', name: 'Ana Duarte', caption: 'Assignee' },
|
|
18
|
+
],
|
|
19
|
+
self: { action: 'leave', onToggle: () => {} },
|
|
20
|
+
candidates: people,
|
|
21
|
+
onAdd: () => {},
|
|
22
|
+
onClose: () => {},
|
|
23
|
+
},
|
|
24
|
+
} satisfies Meta<typeof MembersDialog>
|
|
25
|
+
|
|
26
|
+
export default meta
|
|
27
|
+
type Story = StoryObj<typeof meta>
|
|
28
|
+
|
|
29
|
+
/** A member: Leave and "Add members" on top. */
|
|
30
|
+
export const Member: Story = {}
|
|
31
|
+
|
|
32
|
+
/** Not a member yet: Join on top, and nothing to add until you are in. */
|
|
33
|
+
export const NotAMember: Story = {
|
|
34
|
+
args: {
|
|
35
|
+
members: [{ id: 'ana', name: 'Ana Duarte', caption: 'Assignee' }],
|
|
36
|
+
self: { action: 'join', onToggle: () => {} },
|
|
37
|
+
onAdd: undefined,
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Membership still being read: no row on top rather than a wrong one. */
|
|
42
|
+
export const Unknown: Story = {
|
|
43
|
+
args: { self: undefined, onAdd: undefined },
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Someone who left but still holds a role, and a line about what membership covers. */
|
|
47
|
+
export const WithNote: Story = {
|
|
48
|
+
args: {
|
|
49
|
+
members: [
|
|
50
|
+
{ id: 'me', name: 'You' },
|
|
51
|
+
{ id: 'ravi', name: 'Ravi Mehta', caption: 'Assignee · not following' },
|
|
52
|
+
],
|
|
53
|
+
count: 1,
|
|
54
|
+
note: 'Leaving covers this page only; the items inside it still tell you about their comments.',
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** More rows than fit: the roster scrolls inside the dialog. */
|
|
59
|
+
export const Long: Story = {
|
|
60
|
+
args: {
|
|
61
|
+
members: Array.from({ length: 14 }, (_, i) => ({ id: String(i), name: `Person ${i + 1}`, caption: i % 3 === 0 ? 'Owner' : undefined })),
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Opened straight on the add layer. */
|
|
66
|
+
export const AddLayer: Story = {
|
|
67
|
+
args: { initialView: 'add' },
|
|
68
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/** What the MembersDialog page claims, pinned. */
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
5
|
+
import userEvent from '@testing-library/user-event'
|
|
6
|
+
import { MembersDialog } from './MembersDialog'
|
|
7
|
+
import { MembersPill } from './MembersPill'
|
|
8
|
+
|
|
9
|
+
afterEach(cleanup)
|
|
10
|
+
|
|
11
|
+
const members = [
|
|
12
|
+
{ id: 'me', name: 'You' },
|
|
13
|
+
{ id: 'ana', name: 'Ana Duarte', caption: 'Assignee' },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
describe('MembersDialog', () => {
|
|
17
|
+
it('lists every member with their caption, and counts them in the title', async () => {
|
|
18
|
+
render(<MembersDialog members={members} onClose={() => {}} />)
|
|
19
|
+
const dialog = await screen.findByRole('dialog', { name: /Members/ })
|
|
20
|
+
expect(dialog.textContent).toContain('Ana Duarte')
|
|
21
|
+
expect(dialog.textContent).toContain('Assignee')
|
|
22
|
+
expect(dialog.textContent).toContain('2')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('offers no Join, Leave or Add when none is given', async () => {
|
|
26
|
+
render(<MembersDialog members={members} onClose={() => {}} />)
|
|
27
|
+
await screen.findByRole('dialog')
|
|
28
|
+
expect(screen.queryByRole('button', { name: /^(Join|Leave|Add members)$/ })).toBeNull()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('says Join to a non-member and Leave to a member, and calls onToggle once', async () => {
|
|
32
|
+
const user = userEvent.setup()
|
|
33
|
+
const onToggle = vi.fn()
|
|
34
|
+
const { rerender } = render(<MembersDialog members={members} self={{ action: 'join', onToggle }} onClose={() => {}} />)
|
|
35
|
+
await user.click(await screen.findByRole('button', { name: 'Join' }))
|
|
36
|
+
expect(onToggle).toHaveBeenCalledTimes(1)
|
|
37
|
+
rerender(<MembersDialog members={members} self={{ action: 'leave', onToggle }} onClose={() => {}} />)
|
|
38
|
+
expect(await screen.findByRole('button', { name: 'Leave' })).toBeTruthy()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('goes to the add layer and back, and Invite waits for a choice', async () => {
|
|
42
|
+
const user = userEvent.setup()
|
|
43
|
+
render(<MembersDialog members={members} candidates={[{ id: 'ravi', label: 'Ravi Mehta' }]} onAdd={() => {}} onClose={() => {}} />)
|
|
44
|
+
await user.click(await screen.findByRole('button', { name: 'Add members' }))
|
|
45
|
+
expect((await screen.findByRole('button', { name: 'Invite' })).hasAttribute('disabled')).toBe(true)
|
|
46
|
+
await user.click(screen.getByRole('button', { name: 'Back to members' }))
|
|
47
|
+
expect(await screen.findByRole('button', { name: 'Add members' })).toBeTruthy()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('stays on the add layer when onAdd returns false', async () => {
|
|
51
|
+
const user = userEvent.setup()
|
|
52
|
+
const onAdd = vi.fn(() => false)
|
|
53
|
+
render(<MembersDialog members={members} candidates={[{ id: 'ravi', label: 'Ravi Mehta' }]} onAdd={onAdd} initialView="add" onClose={() => {}} />)
|
|
54
|
+
await user.type(await screen.findByRole('combobox'), 'Ravi')
|
|
55
|
+
await user.click(await screen.findByRole('option', { name: /Ravi Mehta/ }))
|
|
56
|
+
await user.click(screen.getByRole('button', { name: 'Invite' }))
|
|
57
|
+
expect(onAdd).toHaveBeenCalledWith([{ id: 'ravi', label: 'Ravi Mehta' }])
|
|
58
|
+
expect(screen.getByRole('button', { name: 'Invite' })).toBeTruthy()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('MembersPill', () => {
|
|
63
|
+
it('names the total, and draws nothing for none', () => {
|
|
64
|
+
const { container, rerender } = render(<MembersPill members={[{ name: 'Ana Duarte' }]} count={4} onClick={() => {}} />)
|
|
65
|
+
expect(screen.getByRole('button', { name: '4 members' })).toBeTruthy()
|
|
66
|
+
rerender(<MembersPill members={[]} />)
|
|
67
|
+
expect(container.textContent).toBe('')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -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.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { useId, useState, type ComponentPropsWithoutRef, type ReactNode } from '
|
|
|
2
2
|
import { Collapsible } from '@base-ui/react/collapsible'
|
|
3
3
|
import { IconAlertSquareRounded, IconChevronRight, IconDotsVertical } from '@tabler/icons-react'
|
|
4
4
|
import { cn } from './cn'
|
|
5
|
-
import { COLLAPSIBLE_PANEL_CLASSES, SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
|
|
5
|
+
import { COLLAPSIBLE_PANEL_CLASSES, COUNT_TEXT_CLASSES, SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
|
|
6
6
|
import { IconButton } from './IconButton'
|
|
7
7
|
import { Menu } from './Menu'
|
|
8
8
|
import { UnreadDot } from './UnreadDot'
|
|
@@ -160,7 +160,8 @@ export function NavItem({
|
|
|
160
160
|
2026-09-09). A three-digit count grows the box leftwards. */}
|
|
161
161
|
<span
|
|
162
162
|
className={cn(
|
|
163
|
-
'min-w-4 shrink-0 text-center
|
|
163
|
+
'min-w-4 shrink-0 text-center',
|
|
164
|
+
COUNT_TEXT_CLASSES,
|
|
164
165
|
// With a menu and no mark after it, the ⋮ takes this place while it shows.
|
|
165
166
|
!mark && stepsAside,
|
|
166
167
|
)}
|
package/src/SectionHeader.mdx
CHANGED
|
@@ -66,10 +66,12 @@ import { IconPlus } from '@tabler/icons-react'
|
|
|
66
66
|
- An action is `{ icon, tooltip, onClick }` — the tooltip doubles as the
|
|
67
67
|
action's accessible name. The actions sit beside the title button, not
|
|
68
68
|
inside it, so a click on one never also toggles the section.
|
|
69
|
-
- `
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
- `count` is how many there are, 8px after the title, in a tab's count
|
|
70
|
+
style: a heading over a list says its number beside its name.
|
|
71
|
+
- `trailing` sits at the row's end, before the actions, and stays on screen
|
|
72
|
+
while they come and go: a chip, or an unread dot. It is outside the title
|
|
73
|
+
button, so it is neither part of the toggle's name nor part of its hit
|
|
74
|
+
target.
|
|
73
75
|
- `render` swaps the title's element in Base UI's manner; it is how
|
|
74
76
|
CollapsibleSection makes the title a `Collapsible.Trigger`.
|
|
75
77
|
|
|
@@ -10,7 +10,7 @@ const meta = {
|
|
|
10
10
|
component: SectionHeader,
|
|
11
11
|
decorators: [(Story) => <div className="w-[280px]"><Story /></div>],
|
|
12
12
|
args: { title: 'Section', showActions: 'hover' },
|
|
13
|
-
argTypes: { showActions: { control: 'inline-radio', options: ['hover', 'always'] }, chevron: { control: false }, isExpanded: { control: false }, trailing: { control: false } },
|
|
13
|
+
argTypes: { showActions: { control: 'inline-radio', options: ['hover', 'always'] }, chevron: { control: false }, isExpanded: { control: false }, trailing: { control: false }, count: { control: 'number' } },
|
|
14
14
|
} satisfies Meta<typeof SectionHeader>
|
|
15
15
|
|
|
16
16
|
export default meta
|
|
@@ -67,7 +67,12 @@ export const WithMenu: Story = {
|
|
|
67
67
|
},
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/**
|
|
70
|
+
/** `count`: how many, right after the title. */
|
|
71
|
+
export const WithCount: Story = {
|
|
72
|
+
args: { count: 2, hover: 'none' },
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `trailing`: a chip at the row's end, held on screen while the actions come and go. */
|
|
71
76
|
export const WithTrailing: Story = {
|
|
72
77
|
args: {
|
|
73
78
|
trailing: <Chip type="brand" label="2" />,
|
|
@@ -160,4 +160,10 @@ describe('SectionHeader, the trailing slot', () => {
|
|
|
160
160
|
const { container } = render(<SectionHeader title="Section" trailing={<UnreadDot />} />)
|
|
161
161
|
expect(container.querySelector('[data-unread]')!.parentElement!.className.split(' ')).toContain('has-[[data-unread]]:-mr-1')
|
|
162
162
|
})
|
|
163
|
+
|
|
164
|
+
it('a count sits right after the title, inside the title, not at the row end', () => {
|
|
165
|
+
render(<SectionHeader title="Section" count={2} />)
|
|
166
|
+
const count = screen.getByText('2')
|
|
167
|
+
expect(count.previousElementSibling?.textContent).toBe('Section')
|
|
168
|
+
})
|
|
163
169
|
})
|
package/src/SectionHeader.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
|
|
|
2
2
|
import { IconChevronRight, IconDotsVertical } from '@tabler/icons-react'
|
|
3
3
|
import { useRender } from '@base-ui/react/use-render'
|
|
4
4
|
import { cn } from './cn'
|
|
5
|
-
import { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
|
|
5
|
+
import { COUNT_TEXT_CLASSES, SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
|
|
6
6
|
import { IconButton } from './IconButton'
|
|
7
7
|
import { Menu } from './Menu'
|
|
8
8
|
import { SectionLabel } from './SectionLabel'
|
|
@@ -66,6 +66,12 @@ export interface SectionHeaderProps {
|
|
|
66
66
|
* app for want of the slot (ADOPTION B23).
|
|
67
67
|
*/
|
|
68
68
|
trailing?: ReactNode
|
|
69
|
+
/**
|
|
70
|
+
* How many there are, 8px after the title — not at the row's end, where
|
|
71
|
+
* `trailing` goes (Katerina, 7 October: a heading over a list says its
|
|
72
|
+
* count beside its name, as a tab does).
|
|
73
|
+
*/
|
|
74
|
+
count?: number
|
|
69
75
|
/** Right-aligned, in the order given. */
|
|
70
76
|
actions?: SectionAction[]
|
|
71
77
|
/**
|
|
@@ -102,7 +108,7 @@ export interface SectionHeaderProps {
|
|
|
102
108
|
className?: string
|
|
103
109
|
}
|
|
104
110
|
|
|
105
|
-
export function SectionHeader({ title, chevron = false, icon, isExpanded = true, onToggle, trailing, actions, menu, menuLabel, showActions = 'hover', hover = 'fill', look = 'heading', render, className }: SectionHeaderProps) {
|
|
111
|
+
export function SectionHeader({ title, chevron = false, icon, isExpanded = true, onToggle, trailing, count, actions, menu, menuLabel, showActions = 'hover', hover = 'fill', look = 'heading', render, className }: SectionHeaderProps) {
|
|
106
112
|
const row = look === 'row'
|
|
107
113
|
/*
|
|
108
114
|
One arrow for everything that folds, a NavItem's 16px, and the title 8px
|
|
@@ -179,6 +185,8 @@ export function SectionHeader({ title, chevron = false, icon, isExpanded = true,
|
|
|
179
185
|
) : (
|
|
180
186
|
<SectionLabel folds={chevron}>{title}</SectionLabel>
|
|
181
187
|
)}
|
|
188
|
+
{/* 8px after the title with the 4px gap, in a row's count style. */}
|
|
189
|
+
{count != null && <span className={cn('ml-1 shrink-0', COUNT_TEXT_CLASSES)}>{count}</span>}
|
|
182
190
|
</>
|
|
183
191
|
),
|
|
184
192
|
},
|
package/src/ShowMore.mdx
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as ShowMoreStories from './ShowMore.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={ShowMoreStories} />
|
|
5
|
+
|
|
6
|
+
# ShowMore
|
|
7
|
+
|
|
8
|
+
Long content cut at a height behind a fade, with "Show more" under it.
|
|
9
|
+
|
|
10
|
+
<Canvas of={ShowMoreStories.Long} />
|
|
11
|
+
|
|
12
|
+
## When
|
|
13
|
+
|
|
14
|
+
- A block of text that can run long beside other things the reader needs:
|
|
15
|
+
a description above controls, a summary above a list. Cut, it keeps what
|
|
16
|
+
is under it in view.
|
|
17
|
+
- Content whose length you do not know. It measures: what fits is drawn
|
|
18
|
+
whole, with no fade and no button.
|
|
19
|
+
|
|
20
|
+
<Canvas of={ShowMoreStories.Short} />
|
|
21
|
+
|
|
22
|
+
## When not
|
|
23
|
+
|
|
24
|
+
- A whole section that opens and closes under its heading → **CollapsibleSection**.
|
|
25
|
+
- A message or reply in a conversation: read it whole.
|
|
26
|
+
- Content the reader came for, the page's own subject: show it all.
|
|
27
|
+
- A preview of something that lives elsewhere, on hover → **PreviewCard**.
|
|
28
|
+
|
|
29
|
+
## How
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { ShowMore } from '@estiva-app/ui'
|
|
33
|
+
|
|
34
|
+
<ShowMore surface="inset">
|
|
35
|
+
<p className="text-caption text-text-secondary">{description}</p>
|
|
36
|
+
</ShowMore>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- Wrap only the part that runs long. Keep titles and controls outside it, so
|
|
40
|
+
they are never the part that is cut.
|
|
41
|
+
- `surface` is what the content sits on, for the fade to end in: `surface`
|
|
42
|
+
(a panel, the default) or `inset` (inside an inset card). A fade into the
|
|
43
|
+
wrong colour shows as a band.
|
|
44
|
+
- `maxHeight` is in px, 96 by default.
|
|
45
|
+
|
|
46
|
+
<Canvas of={ShowMoreStories.InACard} />
|
|
47
|
+
|
|
48
|
+
## What it owns
|
|
49
|
+
|
|
50
|
+
It does these by itself. Write none of them around it.
|
|
51
|
+
|
|
52
|
+
| It does | So you never write | Refused when written by hand |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| Measures the content, and again whenever it changes size, to decide whether to cut | a length check on the text, or a `line-clamp` | |
|
|
55
|
+
| Draws the fade and the "Show more" / "Show less" button, which says whether it is open (`aria-expanded`) | a gradient and a toggle of your own | |
|
|
56
|
+
| Opens when focus arrives inside the cut part: a field or link there is never used out of sight | a focus handler that opens it | |
|
|
57
|
+
|
|
58
|
+
The cut content stays in the page, so assistive technology reads all of it.
|
|
59
|
+
|
|
60
|
+
## Props
|
|
61
|
+
|
|
62
|
+
<Controls of={ShowMoreStories.Long} />
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { Card } from './Card'
|
|
3
|
+
import { ShowMore } from './ShowMore'
|
|
4
|
+
|
|
5
|
+
const LONG = Array.from(
|
|
6
|
+
{ length: 6 },
|
|
7
|
+
(_, i) => `Paragraph ${i + 1}. A long description written out in full, the way a real brief is, so that it runs past the height the box allows and needs cutting.`,
|
|
8
|
+
).join(' ')
|
|
9
|
+
|
|
10
|
+
/** Long content cut behind a fade, with "Show more" under it. Short content is drawn whole. */
|
|
11
|
+
const meta = {
|
|
12
|
+
title: 'Layout/ShowMore',
|
|
13
|
+
component: ShowMore,
|
|
14
|
+
decorators: [(Story) => <div className="w-[320px]"><Story /></div>],
|
|
15
|
+
args: { children: <p className="text-caption text-text-secondary">{LONG}</p> },
|
|
16
|
+
argTypes: { children: { control: false } },
|
|
17
|
+
} satisfies Meta<typeof ShowMore>
|
|
18
|
+
|
|
19
|
+
export default meta
|
|
20
|
+
type Story = StoryObj<typeof meta>
|
|
21
|
+
|
|
22
|
+
/** Taller than 96px: cut, faded into the surface, "Show more" under it. */
|
|
23
|
+
export const Long: Story = {}
|
|
24
|
+
|
|
25
|
+
/** No taller than the limit: drawn whole, no fade, no button. */
|
|
26
|
+
export const Short: Story = {
|
|
27
|
+
args: { children: <p className="text-caption text-text-secondary">One line of description.</p> },
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Inside an inset card: `surface="inset"`, so the fade ends in the card's fill. */
|
|
31
|
+
export const InACard: Story = {
|
|
32
|
+
args: { surface: 'inset' },
|
|
33
|
+
render: (args) => (
|
|
34
|
+
<Card fill="inset" className="p-3">
|
|
35
|
+
<p className="text-body-2-strong text-text-primary">Title</p>
|
|
36
|
+
<ShowMore {...args} className="mt-1" />
|
|
37
|
+
</Card>
|
|
38
|
+
),
|
|
39
|
+
}
|