@estiva-app/ui 0.58.0 → 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.
@@ -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/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/index.ts CHANGED
@@ -13,6 +13,8 @@ export { AppShell, type AppShellProps } from './AppShell'
13
13
  export { AttachmentCard, type AttachmentCardProps, type AttachmentCardState } from './AttachmentCard'
14
14
  export { Avatar, hueFor, initialsFor, type AvatarProps } from './Avatar'
15
15
  export { AvatarGroup, type AvatarGroupMember, type AvatarGroupProps } from './AvatarGroup'
16
+ export { MembersPill, type MembersPillProps } from './MembersPill'
17
+ export { MembersDialog, type MembersDialogCandidate, type MembersDialogMember, type MembersDialogProps, type MembersDialogSelfAction } from './MembersDialog'
16
18
  export { Banner, type BannerProps, type BannerTone } from './Banner'
17
19
  export { BlockHandle, blockAtY, type BlockHandleProps, type BlockHandleEditing, type HandleBlock } from './BlockHandle'
18
20
  export { Button, type ButtonProps, type ButtonSize, type ButtonVariant } from './Button'
package/src/looks.ts CHANGED
@@ -43,6 +43,9 @@ export const COUNT_TEXT_CLASSES = 'font-mono text-caption tabular-nums text-text
43
43
  /** A chip's words: Chip's label and Reaction's count. */
44
44
  export const CHIP_TEXT_CLASSES = 'text-chip signal:font-mono signal:text-small signal:font-semibold signal:tabular-nums'
45
45
 
46
+ /** Small secondary words that turn mono under signal: Kbd's key, CommandPalette's footer and MembersPill's count. */
47
+ export const MONO_CAPTION_CLASSES = 'text-caption text-text-secondary signal:font-mono signal:text-small'
48
+
46
49
  /** Words typed straight into an input with no box of its own: SearchInput and ChipInput. */
47
50
  export const BARE_INPUT_CLASSES = 'bg-transparent text-text-primary placeholder:text-text-muted outline-none'
48
51
 
@@ -48,19 +48,19 @@ describe('the registry builds', () => {
48
48
  expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
49
49
  })
50
50
 
51
- it('counts 131 names over 70 files: 101 components, 29 helpers and 1 hook (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October; BlockHandle and EditorBlockHandle (RIC-18) on 2 October, moveBlock and blockDropCursor, the drop in the gutter, on 5 October, and ShowMore on 7 October)', () => {
51
+ it('counts 133 names over 72 files: 103 components, 29 helpers and 1 hook (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October; BlockHandle and EditorBlockHandle (RIC-18) on 2 October, moveBlock and blockDropCursor, the drop in the gutter, on 5 October, MembersPill and MembersDialog, moved from Peek (SHI-31), on 6 October, and ShowMore on 7 October)', () => {
52
52
  // The reconciliation GATES.md §24 explains. If this changes, that changes.
53
53
  // The ten `EDITOR_EXTENSIONS` count as components by their names; they are
54
54
  // editor extensions, documented on SelectionToolbar's, BlockNodes' and
55
55
  // ComposerTriggers' pages.
56
56
  const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
57
- expect(kinds).toEqual({ component: 101, helper: 29, hook: 1 })
58
- expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(70)
57
+ expect(kinds).toEqual({ component: 103, helper: 29, hook: 1 })
58
+ expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(72)
59
59
  })
60
60
 
61
61
  it('gives every entry a purpose, from its own page or from the comment above it', () => {
62
62
  expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
63
- expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(66)
63
+ expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(68)
64
64
  expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(65)
65
65
  })
66
66
 
@@ -51,6 +51,8 @@ fork freely, owe nothing back, mention it on the package's ticket.
51
51
  |---|---|
52
52
  | One face | **Avatar** |
53
53
  | Several faces, overlapping | **AvatarGroup** |
54
+ | Who is in it, at a glance, opening the list | **MembersPill** |
55
+ | Who is in it, with Join, Leave and adding people | **MembersDialog** |
54
56
  | Face and name in a row | **Person** |
55
57
  | The face that opens a menu | **PersonTrigger** |
56
58
  | Who you are, where you are, sign out | **IdentityMenu** — the account menu, ready-made |