import { useId, useState, type ReactNode } from 'react' import { IconArrowLeft, IconUserMinus, IconUserPlus } from '@tabler/icons-react' import { Avatar } from './Avatar' import { Button } from './Button' import { Chip } from './Chip' import { ChipInput, type ChipInputOption } from './ChipInput' import { DialogShell } from './DialogShell' import { Field } from './Field' import { Form } from './Form' import { IconButton } from './IconButton' import { MenuItem } from './Menu' /** One row of the roster: a face, a name, and an optional second line. */ export interface MembersDialogMember { id: string name: string /** A picture URL, resolved by the caller, as `Avatar` takes its `src`. */ picture?: string /** The line under the name: a role, a relation ("Assignee"), or a state. */ caption?: string } /** Somebody who can be added: a `ChipInput` option with a face. */ export interface MembersDialogCandidate extends ChipInputOption { picture?: string } /** What the viewer's own row offers: in when they are out, out when they are in. */ export type MembersDialogSelfAction = 'join' | 'leave' export interface MembersDialogProps { /** The roster, in the order to draw it. The viewer's own row is the caller's to name ("You"). */ members: MembersDialogMember[] /** The number in the title's chip. Defaults to how many rows there are. */ count?: number /** * What the viewer can do: "Leave" on their own row when they are in, which * is drawn first; a "Join" row on top when they are not. Absent while it is * not known, or where they cannot change it — no button is better than a * wrong one. */ self?: { action: MembersDialogSelfAction onToggle: () => void | Promise /** The viewer's id: the row in `members` that carries Leave and comes first. */ id: string } /** Who "Add members" may offer. Members are left out by id. */ candidates?: MembersDialogCandidate[] /** * Adds the people chosen. With it, the "Add members" row shows and opens the * add layer. Return `false` (or a promise of it) to stay on the add layer — * nothing was added; anything else goes back to the roster. */ onAdd?: (chosen: MembersDialogCandidate[]) => void | boolean | Promise /** One line under the roster — what membership does and does not cover. */ note?: ReactNode /** `add` opens straight on the add layer. */ initialView?: 'list' | 'add' onClose: () => void } // The icon in the accent as text: Ship's button blue read at 2.7:1 on its grey square (Katerina, 8 October). const LEADING_CLASSES = 'size-8 rounded-md bg-accent-muted flex items-center justify-center shrink-0 text-accent-text' /** * Who is in this, with joining, leaving and adding people — the list a * `MembersPill` opens. * * Moved from Peek's `MembersDialog` (2026-10-06; Katerina's design of 22 July, * with the row and inset rulings of 15 September). Two layers in one dialog: * the roster, with "Add members" (and Join, for a non-member) on top, and the * add layer, whose back arrow returns to the roster. Leave is a small red * button on the viewer's own row, which always comes first (Katerina, 8 * October: a Leave row on top looked like an invite, the same as Add members). Adding returns to the * roster so the new people are visible at once. */ export function MembersDialog({ members, count, self, candidates = [], onAdd, note, initialView = 'list', onClose }: MembersDialogProps) { const [view, setView] = useState<'list' | 'add'>(onAdd ? initialView : 'list') const [chosen, setChosen] = useState([]) const [toggling, setToggling] = useState(false) const formId = useId() const toggle = async () => { if (!self || toggling) return setToggling(true) try { await self.onToggle() } finally { setToggling(false) } } const add = async () => { if (!onAdd || chosen.length === 0) return if ((await onAdd(chosen)) === false) return setChosen([]) setView('list') } // Leave sits on the viewer's own row, drawn first. A viewer with no row of their own gets Leave on top, as Join is. const ownRow = self?.action === 'leave' ? members.find((m) => m.id === self.id) : undefined const roster = ownRow ? [ownRow, ...members.filter((m) => m !== ownRow)] : members const selfOnTop = self && !ownRow if (view === 'add' && onAdd) { return ( setView('list')}> Add members } bodyClassName="flex flex-col gap-6" footer={ <> } >
m.id)} chipLeading={(o) => } rowLeading={(o) => } autoFocus />
) } return ( Members } bodyMaxHeight="max-h-[400px]" bodyClassName="p-0 py-2" > {/* MenuItem outside a Menu is a plain button. Its fill sits 8px in from the dialog's sides, and its own 12px inset puts the square over the rows' faces, 20px in, at the same 48px. */} {(selfOnTop || onAdd) && (
{selfOnTop && ( void toggle()} leading={{self.action === 'join' ? : }} label={self.action === 'join' ? 'Join' : 'Leave'} /> )} {onAdd && ( setView('add')} leading={} label="Add members" /> )}
)} {roster.map((member) => (
{member.name}
{member.caption &&
{member.caption}
}
{member === ownRow && ( )}
))} {note &&
{note}
}
) }