Conversation list
<aparte-conversation-list>
Conversation-history sidebar — a framework-agnostic web component. The host sets
the conversations JS property and the active-id attribute; this renders the
rows and fires the user’s intent, never acting on it itself.
A row is two real buttons: the title, which selects, and a ⋯ that opens the row’s
menu — rename, pin or unpin, archive or unarchive, delete. Every item fires an event
and stops; the one exception is delete, which asks first, inline in the menu,
because it is the one action the host cannot undo. Rename swaps the title for an
input: Enter or leaving the field commits, Escape cancels, and an unchanged or
emptied title fires nothing. The rows are grouped by date — Pinned, Today,
Yesterday, Previous 7 days, Previous 30 days, then one heading per month — as soon
as any item carries updatedAt; set no-groups to render them flat.
Children are not a composition point: _render() assigns innerHTML from the
conversations array, so any light-DOM child a host writes inside the element is
discarded the next time the list renders — and switching this element’s locale is
enough to trigger one. Compose around the element, not inside it: it renders rows
and nothing else, with no header, no new-conversation button and no search field.
What it is not: a store. Selecting, renaming, pinning, archiving and deleting all
leave the array untouched — the events carry an id (and, for rename, the title) and
stop. A row’s text comes from the array, so it changes when the host assigns
conversations again; the exception is an empty title, which falls back to the
locale’s new-chat label and therefore follows a locale switch. An archived item is
still rendered (it gains aparte-conv-item--archived); filtering archived
conversations out of the list is the host’s decision, not this element’s. The
asymmetry between the two inputs is deliberate: active-id is an attribute because
moving the selection patches the rendered rows in place, while conversations is a
JS property because it is structured data an attribute cannot carry, and setting it
re-renders the whole list.
Example
Section titled “Example”<!-- It stores nothing and fetches nothing: an empty tag renders the empty state, and the list appears when the host assigns `conversations`. --><aparte-conversation-list active-id="c1" style="max-width: 20rem"></aparte-conversation-list>
<script> const day = 864e5; document.querySelector('aparte-conversation-list').conversations = [ { id: 'c0', title: 'Release checklist', updatedAt: Date.now() - 3 * day, pinnedAt: Date.now() }, { id: 'c1', title: 'Deploy checklist', updatedAt: Date.now() }, { id: 'c2', title: 'Rename the segment types', updatedAt: Date.now() - day }, { id: 'c3', title: 'Tokens, not selectors', updatedAt: Date.now() - 4 * day }, ];</script>// The host owns the data: set the `conversations` property, listen for the intent.const list = document.querySelector('aparte-conversation-list')!;list.conversations = [ { id: 'c1', title: 'Deploy checklist', updatedAt: Date.now() }, { id: 'c2', title: 'Old thread', updatedAt: 0, archivedAt: Date.now() },];list.setAttribute('active-id', 'c1');
list.addEventListener('aparte-select-conversation', (e) => load(e.detail.id));list.addEventListener('aparte-rename-conversation', (e) => manager.updateTitle(e.detail.id, e.detail.title));list.addEventListener('aparte-pin-conversation', (e) => manager.pin(e.detail.id));list.addEventListener('aparte-delete-conversation', (e) => manager.delete(e.detail.id));Attributes
Section titled “Attributes”| Attribute | Description |
|---|---|
active-id | The id of the conversation to render as selected. |
no-groups | Render the rows flat, in host order, with no date headings. |
Properties
Section titled “Properties”| Property | Type | Description |
|---|---|---|
conversations | AparteConversationListItem[] | Set the list of conversations to display. Triggers a re-render. |
Events
Section titled “Events”| Event | Type | Description |
|---|---|---|
aparte-unpin-conversation | CustomEvent<AparteConversationPinDetail> | The same item on a pinned row; same detail shape, opposite intent. |
aparte-pin-conversation | CustomEvent<AparteConversationPinDetail> | The pin item was chosen on an unpinned row. |
aparte-unarchive-conversation | CustomEvent<AparteConversationArchiveDetail> | The same item on an already-archived one; same detail shape, opposite intent. |
aparte-archive-conversation | CustomEvent<AparteConversationArchiveDetail> | The archive item was chosen on a live conversation. |
aparte-delete-conversation | CustomEvent<AparteConversationDeleteDetail> | The delete was confirmed. Nothing is removed here. |
aparte-rename-conversation | CustomEvent<AparteConversationRenameDetail> | A rename was committed with a new, non-empty title. Nothing is renamed here. |
aparte-select-conversation | CustomEvent<AparteConversationSelectDetail> | A row’s title was activated; the host loads that conversation. |
Theming
Section titled “Theming”Override any of these on :root, on a subtree, or on one instance — custom properties inherit downward. Some are this element’s own; others are site-wide tokens that also style it, and overriding one of those at :root moves everything that reads it. The full set is in the CSS variables reference.
<aparte-conversation-list>
Section titled “<aparte-conversation-list>”| Variable | Default | Description |
|---|---|---|
--aparte-conv-list-gap | var(--aparte-space-1) | Vertical gap between rows, and between a group’s heading and its rows. |
--aparte-conv-item-padding | var(--aparte-space-4) var(--aparte-space-5) | Padding of a row’s title button. |
--aparte-conv-item-gap | var(--aparte-space-3) | Gap between a row’s title and its ⋯ button. |
--aparte-conv-item-radius | var(--aparte-radius-md) | Corner radius of a row. |
--aparte-conv-item-font-size | var(--aparte-font-size-md) | Font size of a row’s title. |
--aparte-conv-item-color | var(--aparte-text-muted) | Title colour of an inactive row. |
--aparte-conv-item-bg-hover | var(--aparte-surface-3) | Row background on hover. |
--aparte-conv-item-bg-active | var(--aparte-surface-3) | Background of the row matching active-id. |
--aparte-conv-item-color-active | var(--aparte-text) | Title colour of the active row. |
--aparte-conv-item-font-weight-active | var(--aparte-font-weight-medium) | Title weight of the active row. |
--aparte-conv-action-btn-size | var(--aparte-btn-size-sm) | Square size of the ⋯ button (the recipe’s small step). Under (pointer: coarse) the stylesheet redeclares it as the touch target size on the button itself, so a value set on the element does not reach it there; the button also stays visible instead of appearing on hover. |
In a framework
Section titled “In a framework”The element is the same object everywhere — the tag does not change. What changes is how an attribute is written and how an event reaches you.
<aparte-conversation-list no-groups=""></aparte-conversation-list>el.addEventListener('aparte-unpin-conversation', (e) => use(e.detail));<aparte-conversation-list no-groups=""></aparte-conversation-list>The aparte-* tags are typed JSX intrinsics as soon as you import from @aparte/react. A presence attribute takes '', never true — React stringifies it, and no-groups={false} would render no-groups="false", which hasAttribute reads as on. Events reach you by ref, typed through the DOM.
<template> <aparte-conversation-list no-groups="" @aparte-unpin-conversation="(e) => use(e.detail)" ></aparte-conversation-list></template>Declared through Vue’s GlobalComponents, so vue-tsc checks the tag in any template. A presence attribute takes '' to set and null to remove, never false.
<aparte-conversation-list no-groups="" on:aparte-unpin-conversation={(e) => use(e.detail)}></aparte-conversation-list>Declared through SvelteHTMLElements, so svelte-check covers the attributes and the on: handlers. A presence attribute takes '', never false.
import { AparteConversationListDirective } from '@aparte/angular';<aparte-conversation-list [noGroups]="true" (unpinConversation)="use($event)"></aparte-conversation-list>A standalone directive whose selector IS the tag, so the real element sits in the template — @if, @for and content projection all reach it — and no CUSTOM_ELEMENTS_SCHEMA is needed.
Installation and the framework-specific traps: React · Vue · Svelte · Angular.