Skip to content

Conversation list

<aparte-conversation-list>

Class: AparteConversationList

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. Acting on it is the host’s, unless manage hands the row menu’s writes to the registered conversation manager.

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 flat 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. Set manage and the menu’s write gestures are carried out on the conversation manager you registered, so those six listeners are no longer yours to write; the event still goes out first and preventDefault() takes the gesture back. The array is still not this element’s: the manager’s own change is what re-renders it. 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.

Width Open in a tab
The real element, in its own document so this site's CSS cannot reach it. It renders the same example printed below — every framework mounts this same element, so the rendering is what you get in all five; the code is what changes.
<!-- 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-conversation-select', (e) => load(e.detail.id));
list.addEventListener('aparte-conversation-rename', (e) => manager.updateTitle(e.detail.id, e.detail.title));
list.addEventListener('aparte-conversation-pin', (e) => manager.pin(e.detail.id));
list.addEventListener('aparte-conversation-delete', (e) => manager.delete(e.detail.id));
// Or let the list finish the gesture: register the manager once, add `manage`, and
// rename / pin / archive / delete are carried out for you. The events still fire.
aparteGlobalConfig.setConversationManager(manager);
list.setAttribute('manage', '');
AttributeDescription
active-idThe id of the conversation to render as selected.
flatRender the rows flat, in host order, with no date headings.
loadingThe list is on its way: six skeleton rows stand where the rows will be, the element is aria-busy and a visually hidden line names the wait. A list that is not there yet is not an empty list. Set it while your store answers, clear it when you assign conversations.
manageLet the list finish the gesture: rename, pin, unpin, archive, unarchive and delete are performed on the conversation manager registered with setConversationManager(), instead of leaving you six listeners to write. The event still fires first, and preventDefault() on it takes the gesture back. Off by default, and selecting stays yours either way.
PropertyTypeDescription
loadingbooleanThe list is on its way: rows of the kit’s skeleton stand where the rows will be, the element is aria-busy, and a visually hidden line names the wait. Reflected as the loading attribute, so a site sets it while its store answers and clears it when conversations is set. A list that is not there yet is not an empty list.
managebooleanLet the list finish the gesture through the registered conversation manager. Reflected as the manage attribute. The property is not a nicety here: this is the attribute that authorises a WRITE, and Vue and both Sveltes stringify a bound false onto an element that has no property of that name — manage="false", which hasAttribute reads as on. So turning management off turned it on, and a delete the host meant to handle itself was also done by the list. presenceOn, like loading above it.
conversationsAparteConversationListItem[]Set the list of conversations to display. Triggers a re-render.
EventTypeDescription
aparte-conversation-selectCustomEvent&lt;AparteConversationSelectDetail>A row’s title was activated; the host loads that conversation.
aparte-conversation-renameCustomEvent&lt;AparteConversationRenameDetail>A rename was committed with a new, non-empty title. Cancelable: preventDefault() takes the gesture back from manage.
aparte-conversation-pinCustomEvent&lt;AparteConversationPinDetail>The pin item was chosen on an unpinned row. Cancelable: preventDefault() takes the gesture back from manage.
aparte-conversation-unpinCustomEvent&lt;AparteConversationPinDetail>The same item on a pinned row; same detail shape, opposite intent. Cancelable, like the rest.
aparte-conversation-archiveCustomEvent&lt;AparteConversationArchiveDetail>The archive item was chosen on a live conversation. Cancelable: preventDefault() takes the gesture back from manage.
aparte-conversation-unarchiveCustomEvent&lt;AparteConversationArchiveDetail>The same item on an already-archived one; same detail shape, opposite intent. Cancelable, like the rest.
aparte-conversation-deleteCustomEvent&lt;AparteConversationDeleteDetail>The delete was confirmed. Cancelable: preventDefault() takes the gesture back from manage.

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.

VariableDefaultDescription
--aparte-conv-list-gapvar(--aparte-space-1)Vertical gap between rows, and between a group’s heading and its rows.
--aparte-conv-item-paddingvar(--aparte-space-4) var(--aparte-space-5)Padding of a row’s title button.
--aparte-conv-item-gapvar(--aparte-space-3)Gap between a row’s title and its ⋯ button.
--aparte-conv-item-radiusvar(--aparte-radius-md)Corner radius of a row.
--aparte-conv-item-font-sizevar(--aparte-font-size-md)Font size of a row’s title.
--aparte-conv-item-colorvar(--aparte-text)Title colour of an inactive row.
--aparte-conv-item-bg-hovervar(--aparte-surface-3)Row background on hover.
--aparte-conv-item-bg-activevar(--aparte-surface-3)Background of the row matching active-id.
--aparte-conv-item-color-activevar(--aparte-text)Title colour of the active row.
--aparte-conv-item-font-weight-activevar(--aparte-font-weight-medium)Title weight of the active row.
--aparte-conv-action-btn-sizevar(--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.

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 flat=""></aparte-conversation-list>
el.addEventListener('aparte-conversation-select', (e) => use(e.detail));

Installation and the framework-specific traps: React · Vue · Svelte · Angular.