uix(chat): v2 · chat-list — panel de conversaciones (compone Listbox)

Componente nuevo del bloque chat v2: el panel de lista de salas/DMs.
Compone el Listbox del ecosistema (doble registro id/ref compartido):
selección única + roving-tabindex + typeahead + role=option vienen de
Listbox; chat-list aporta la disposición de fila (avatar · título/preview ·
meta) y el estado visual data-unread (negrita + Badge compuesto).

- morfo: expression 'delegated', sin eventos propios (D.1 — el actor es el
  Listbox compuesto); parts provider/item(data-unread)/avatar/title/preview/meta.
- soma: ChatListProvider crea ListboxProvider dentro (value string <-> string[]);
  ChatListItemProvider per-instance (unread = boolean crudo); 4 clases de
  contenido explicitas.
- eidos: rejilla avatar | (titulo/preview) | meta, avatar y meta abarcan las
  dos filas; truncado; unread -> titulo en negrita. No neutraliza el arquetipo
  item (la conversacion ES una opcion seleccionable). Sin @keyframes; ejes
  logicos.
- demo: 5 salas, seleccion limpia unread, typing-en-lista (compone ChatTyping),
  Badge de no-leidos; tabs live/system/api/morfo/a11y.
- READMEs dobles (eidos con Passive justification, delegated); next-features 7
  marca chat-list SHIPPED.

Verificacion: check baseline 76 (0 nuevos) · component:audit PASS ·
eidos-lint invalid=0 · recipe-contract 30/30 · morfo:vocabulary 0 · Playwright
PASS (role=listbox, 5 salas, aria-selected, unread=2, badges=2, typing-en-lista,
click + teclado seleccionan).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 3 months ago
parent c9e7082213
commit f9889b0b18

@ -156,8 +156,11 @@ materialization doctrine worth feeding back to the source.
deferred surface, registered with disposition (each item also lives in its
component README §Gaps):
- **`chat-list`** — the conversation-list pane (rooms, unread badges,
typing-in-list). New component; composes `VirtualList` + `Avatar` + `Badge`.
- ~~**`chat-list`** — the conversation-list pane (rooms, unread badges,
typing-in-list).~~ **✅ SHIPPED 2026-07-19** — composes `Listbox`
(single-select + keyboard) with an avatar/title/preview/meta row; unread
bold + `Badge`, typing-in-list composes `ChatTyping`. Virtualization for
very long lists deferred (pairs with the VirtualList bidirectional item).
- **`emoji-picker`** — a real picker component (color-picker scale:
categories, search, skin tones). v1 ships the quick-set Popover in
`ChatMessage.ReactionAdd`.

@ -0,0 +1,80 @@
# ChatList (eidos)
Visual layer of the conversation-list pane. A vertical list of rows; each row
is a `Listbox.Item` (role=option), so **hover / selected / focus come from the
global `[data-archetype='item']` state-layer** — the recipe only lays out the
row and types the content. RTL-safe (all axes logical).
```svelte
<ChatList bind:value={active}>
{#each rooms as room (room.id)}
<ChatList.Item value={room.id} unread={room.unread > 0}>…</ChatList.Item>
{/each}
</ChatList>
```
## Passive justification
Passive **at the chat-list morfo level** — the morfo declares no events and
`expression: 'delegated'`. The component is genuinely interactive (select a
conversation, arrow-key navigation), but that behavior is **delegated to the
composed `Listbox`** (double registration): Listbox owns `role=listbox`,
roving-tabindex, typeahead, selection and its own `commit` events. chat-list
only adds the row layout + the `data-unread` visual state, so it declares no
interactive archetype or events of its own (D.1 — each actor declares its own
events; the composed Listbox is the actor here).
## Baseline
No Air baseline — `chat-list` is a new component (chat block v2).
## Visual API
Pass-throughs of the soma parts. The row grid is
`avatar | (title / preview) | meta`: avatar and meta span both text rows, the
middle column truncates. The app composes `Avatar` in `Avatar`, `Badge` in
`Meta`, and may compose `ChatTyping` in `Preview` (typing-in-list).
## Tokens (`--chat-list-*`)
Declared in `lib/recipes/base.ts`: shell (`bg`, `padding-inline/block`), item
(`item-gap`, `item-padding-inline/block`, `item-radius`, `item-row-gap` —
hover/selected are the archetype's, NOT re-declared), title
(`title-font-size/weight/color`, `title-unread-weight`), preview
(`preview-font-size/color`, `preview-unread-color`), meta
(`meta-gap/font-size/color`). No local keyframes; logical axes.
## Comparativa
| Feature | UIX | WhatsApp | Telegram | Slack |
| ------------------------------- | :-: | :------: | :------: | :---: |
| Single-select active + keyboard | ✅ (Listbox) | ✔ | ✔ | ✔ |
| Unread bold + count badge | ✅ | ✔ | ✔ | ✔ |
| Typing-in-list | ✅ (compose ChatTyping) | ✔ | ✔ | ✔ |
| Avatar · title/preview · meta | ✅ | ✔ | ✔ | ✔ |
| Row a11y = `option` (arrow nav) | ✅ | Parcial | Parcial | Parcial |
Composing the ecosystem `Listbox` gives the arrow-key/typeahead a11y that the
reference apps mostly lack.
## Decisiones
- **La fila NO neutraliza el arquetipo `item`** (al contrario que
`chat-message`): aquí la conversación ES una opción seleccionable, así que
el state-layer de hover/selected del arquetipo es justo lo que se quiere.
- **Rejilla 3×2**: avatar y meta abarcan las dos filas de texto (nombre /
preview) — la disposición canónica de lista de conversaciones.
- **Unread** = `data-unread` → nombre en negrita + preview menos apagado; el
contador lo aporta un `Badge` compuesto (accesible).
## Audit exceptions
- `D-1.2` — el demo usa 5 tabs (live/system/api/morfo/a11y) en vez del
template de 9: chat-list es una composición **delegada** sin sema ni motion
propios (los tabs Sema/Motion/Services estarían casi vacíos). Los tabs
presentes cubren la superficie real del componente.
## Gaps (disposición)
- Virtualización (listas largas) — *v2 refinement*.
- Grupos/secciones (fijados) — *diferir* (compón `Listbox.Group`).

@ -0,0 +1,10 @@
<script lang="ts">
import * as ChatList from '$soma/components/chat-list';
import type { ChatListAvatarProps } from './types';
let { children, ...rest }: ChatListAvatarProps = $props();
</script>
<ChatList.Avatar {...rest}>
{@render children?.()}
</ChatList.Avatar>

@ -0,0 +1,10 @@
<script lang="ts">
import * as ChatList from '$soma/components/chat-list';
import type { ChatListItemProps } from './types';
let { children, ...rest }: ChatListItemProps = $props();
</script>
<ChatList.Item {...rest}>
{@render children?.()}
</ChatList.Item>

@ -0,0 +1,10 @@
<script lang="ts">
import * as ChatList from '$soma/components/chat-list';
import type { ChatListMetaProps } from './types';
let { children, ...rest }: ChatListMetaProps = $props();
</script>
<ChatList.Meta {...rest}>
{@render children?.()}
</ChatList.Meta>

@ -0,0 +1,10 @@
<script lang="ts">
import * as ChatList from '$soma/components/chat-list';
import type { ChatListPreviewProps } from './types';
let { children, ...rest }: ChatListPreviewProps = $props();
</script>
<ChatList.Preview {...rest}>
{@render children?.()}
</ChatList.Preview>

@ -0,0 +1,10 @@
<script lang="ts">
import * as ChatList from '$soma/components/chat-list';
import type { ChatListTitleProps } from './types';
let { children, ...rest }: ChatListTitleProps = $props();
</script>
<ChatList.Title {...rest}>
{@render children?.()}
</ChatList.Title>

@ -0,0 +1,84 @@
/**
* ChatList — the conversation-list pane. A vertical list of rows; each row is
* a `Listbox.Item` (role=option), so hover/selected/focus come from the global
* `[data-archetype='item']` state-layer — the recipe only lays out the row.
*
* Row grid: avatar | (title / preview) | meta. Avatar and meta span both text
* rows; the middle column truncates. All axes logical (RTL-safe).
*/
[data-chat-list] {
display: flex;
flex-direction: column;
background: var(--chat-list-bg);
padding-inline: var(--chat-list-padding-inline);
padding-block: var(--chat-list-padding-block);
overflow-y: auto;
}
[data-chat-list-item] {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-rows: auto auto;
align-items: center;
column-gap: var(--chat-list-item-gap);
row-gap: var(--chat-list-item-row-gap);
padding-inline: var(--chat-list-item-padding-inline);
padding-block: var(--chat-list-item-padding-block);
border-radius: var(--chat-list-item-radius);
cursor: pointer;
}
[data-chat-list-avatar] {
grid-column: 1;
grid-row: 1 / 3;
display: flex;
align-items: center;
flex: none;
}
[data-chat-list-title] {
grid-column: 2;
grid-row: 1;
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--chat-list-title-font-size);
font-weight: var(--chat-list-title-font-weight);
color: var(--chat-list-title-color);
}
[data-chat-list-preview] {
grid-column: 2;
grid-row: 2;
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--chat-list-preview-font-size);
color: var(--chat-list-preview-color);
}
[data-chat-list-meta] {
grid-column: 3;
grid-row: 1 / 3;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: var(--chat-list-meta-gap);
flex: none;
font-size: var(--chat-list-meta-font-size);
color: var(--chat-list-meta-color);
white-space: nowrap;
}
/* Unread rooms: bolder name + a less-muted preview (the app adds the Badge). */
[data-chat-list-item][data-unread] [data-chat-list-title] {
font-weight: var(--chat-list-title-unread-weight);
}
[data-chat-list-item][data-unread] [data-chat-list-preview] {
color: var(--chat-list-preview-unread-color);
}

@ -0,0 +1,15 @@
<script lang="ts">
import './chat-list.css';
/**
* Eidos `<ChatList>` — the conversation-list pane (composes the soma
* `Listbox` for single-select + keyboard). Bind `value` to the active
* conversation id.
*/
import * as ChatList from '$soma/components/chat-list';
import type { ChatListProps } from './types';
let { value = $bindable<string | undefined>(undefined), children, ...rest }: ChatListProps =
$props();
</script>
<ChatList.Provider {...rest} bind:value {children} />

@ -0,0 +1,34 @@
import ChatListComponent from './chat-list.svelte';
import Item from './chat-list-item.svelte';
import Avatar from './chat-list-avatar.svelte';
import Title from './chat-list-title.svelte';
import Preview from './chat-list-preview.svelte';
import Meta from './chat-list-meta.svelte';
type ChatListNamespace = typeof ChatListComponent & {
Item: typeof Item;
Avatar: typeof Avatar;
Title: typeof Title;
Preview: typeof Preview;
Meta: typeof Meta;
};
const ChatList = ChatListComponent as ChatListNamespace;
ChatList.Item = Item;
ChatList.Avatar = Avatar;
ChatList.Title = Title;
ChatList.Preview = Preview;
ChatList.Meta = Meta;
export { ChatList };
export default ChatList;
export type {
ChatListProps,
ChatListSnippetProps,
ChatListItemProps as ItemProps,
ChatListAvatarProps as AvatarProps,
ChatListTitleProps as TitleProps,
ChatListPreviewProps as PreviewProps,
ChatListMetaProps as MetaProps
} from './types';

@ -0,0 +1,18 @@
import type {
ProviderProps,
ItemProps,
AvatarProps,
TitleProps,
PreviewProps,
MetaProps,
ProviderSnippetProps
} from '$soma/components/chat-list';
export type ChatListProps = ProviderProps;
export type ChatListItemProps = ItemProps;
export type ChatListAvatarProps = AvatarProps;
export type ChatListTitleProps = TitleProps;
export type ChatListPreviewProps = PreviewProps;
export type ChatListMetaProps = MetaProps;
export type { ProviderSnippetProps as ChatListSnippetProps };

@ -875,6 +875,24 @@
--chat-composer-transition-duration: var(--duration-fast);
--chat-composer-transition-ease: var(--ease-default);
--chat-composer-disabled-opacity: var(--opacity-disabled);
--chat-list-bg: var(--color-surface-default);
--chat-list-padding-inline: var(--space-1);
--chat-list-padding-block: var(--space-1);
--chat-list-item-gap: var(--space-2-5);
--chat-list-item-padding-inline: var(--space-2);
--chat-list-item-padding-block: var(--space-2);
--chat-list-item-radius: var(--radius-md);
--chat-list-item-row-gap: var(--space-0-5);
--chat-list-title-font-size: var(--size-sm-font-size);
--chat-list-title-font-weight: var(--font-weight-medium);
--chat-list-title-color: var(--color-content-primary);
--chat-list-title-unread-weight: var(--font-weight-bold);
--chat-list-preview-font-size: var(--size-sm-font-size);
--chat-list-preview-color: var(--color-content-muted);
--chat-list-preview-unread-color: var(--color-content-secondary);
--chat-list-meta-gap: var(--space-1);
--chat-list-meta-font-size: var(--size-xs-font-size);
--chat-list-meta-color: var(--color-content-muted);
--chat-log-bg: var(--color-surface-muted);
--chat-log-padding-inline: var(--space-3);
--chat-log-padding-block: var(--space-2);

@ -698,6 +698,32 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'transition-ease': 'var(--ease-default)',
'disabled-opacity': 'var(--opacity-disabled)'
},
'chat-list': {
// ── Shell (a vertical list of conversation rows) ──────────────────
bg: 'var(--color-surface-default)',
'padding-inline': 'var(--space-1)',
'padding-block': 'var(--space-1)',
// ── Item (a conversation row; hover/selected come from archetype
// 'item' via the composed Listbox — not re-declared here) ──────────
'item-gap': 'var(--space-2-5)',
'item-padding-inline': 'var(--space-2)',
'item-padding-block': 'var(--space-2)',
'item-radius': 'var(--radius-md)',
'item-row-gap': 'var(--space-0-5)',
// ── Title (conversation name) ─────────────────────────────────────
'title-font-size': 'var(--size-sm-font-size)',
'title-font-weight': 'var(--font-weight-medium)',
'title-color': 'var(--color-content-primary)',
'title-unread-weight': 'var(--font-weight-bold)',
// ── Preview (last message, truncated) ─────────────────────────────
'preview-font-size': 'var(--size-sm-font-size)',
'preview-color': 'var(--color-content-muted)',
'preview-unread-color': 'var(--color-content-secondary)',
// ── Meta (time + unread badge column) ─────────────────────────────
'meta-gap': 'var(--space-1)',
'meta-font-size': 'var(--size-xs-font-size)',
'meta-color': 'var(--color-content-muted)'
},
'chat-log': {
// ── Shell — a muted canvas so surface bubbles float (reference:
// WhatsApp's tinted wallpaper / Telegram's soft backdrop) ─────────

@ -0,0 +1,8 @@
import type { LangNode } from '$libs/langs';
export const chatListLangs = {
label: {
es: 'Conversaciones',
en: 'Conversations'
}
} satisfies LangNode;

@ -13,6 +13,7 @@ import { carouselLangs } from './carousel';
import { cascadeLangs } from './cascade';
import { chartLangs } from './chart';
import { chatComposerLangs } from './chat-composer';
import { chatListLangs } from './chat-list';
import { chatLogLangs } from './chat-log';
import { chatMessageLangs } from './chat-message';
import { chatTypingLangs } from './chat-typing';
@ -128,6 +129,7 @@ export const componentLangs = {
cascade: cascadeLangs,
chart: chartLangs,
'chat-composer': chatComposerLangs,
'chat-list': chatListLangs,
'chat-log': chatLogLangs,
'chat-message': chatMessageLangs,
'chat-typing': chatTypingLangs,

@ -0,0 +1,90 @@
import type { Morfo } from '../types';
import { v } from '../types';
// Chat list — the conversation-list pane (rooms / DMs). COMPOSES the soma
// `Listbox` (single-select): the active conversation is the selected value,
// and roving-tabindex keyboard navigation + the `option` role come from
// Listbox (double registration, shared id/ref — the chat-log precedent).
// Perception + selection events are DELEGATED to the composed Listbox (D.1);
// this morfo only declares the styling contract (the conversation-row parts)
// and the `data-unread` state. Data-agnostic: the app supplies conversations,
// composes the parts, and binds the selected value. Virtualization is a
// registered refinement — conversation lists are short.
export const chatListMorfo = {
name: 'ChatList',
kebab: 'chat-list',
scope: ['soma', 'eidos'],
expression: 'delegated',
apg: 'none — composes the Listbox pattern; selection + keyboard come from the composed Listbox',
texts: {
label: '#?components.chat-list.label|Conversations'
},
events: [],
parts: [
{
// The list container — double-registers as `Listbox.Provider`
// (role=listbox + roving focus + selection come from Listbox).
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: []
},
{
// One conversation row — double-registers as `Listbox.Item`
// (role=option + aria-selected + activation come from Listbox).
// `data-unread` styles rooms with unseen messages.
name: 'Item',
kebab: 'item',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [{ attr: 'data-unread', value: v.propRef('unread'), severity: 'optional' }],
aria: []
},
{
// Avatar column — the app composes `Avatar` / `AvatarGroup`.
name: 'Avatar',
kebab: 'avatar',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
// Conversation name (top line).
name: 'Title',
kebab: 'title',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
// Last-message preview (secondary line, truncated). May compose
// `chat-typing` instead when someone is typing in the room.
name: 'Preview',
kebab: 'preview',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
// Trailing column: time + unread badge (compose `Badge`).
name: 'Meta',
kebab: 'meta',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -0,0 +1,81 @@
# ChatList (soma)
The conversation-list pane of the chat block — the rooms/DMs sidebar. It
**composes the soma `Listbox`** (single-select): the active conversation is
the selected value, and roving-tabindex keyboard navigation, typeahead and
the `option` role all come from Listbox (double registration, shared id/ref
— the chat-log precedent). Perception + selection events are **delegated** to
Listbox (D.1); this component adds the conversation-row layout contract and
the `data-unread` state.
Data-agnostic: the app supplies the conversations as `ChatList.Item` children
and binds `value` (one active conversation id).
## Anatomy
```svelte
<ChatList.Provider bind:value={active}>
{#each rooms as room (room.id)}
<ChatList.Item value={room.id} unread={room.unread > 0}>
<ChatList.Avatar><Avatar>…</Avatar></ChatList.Avatar>
<ChatList.Title>{room.name}</ChatList.Title>
<ChatList.Preview>{room.last}</ChatList.Preview> <!-- or <ChatTyping> -->
<ChatList.Meta>{room.time} <Badge>{room.unread}</Badge></ChatList.Meta>
</ChatList.Item>
{/each}
</ChatList.Provider>
```
## Parts
| Part | Element | Notes |
| ---------- | ------- | -------------------------------------------------------------- |
| `Provider` | `<div>` | Double-registers as `Listbox.Provider` (role=listbox). |
| `Item` | `<div>` | Double-registers as `Listbox.Item` (role=option); `data-unread`; `value` = conversation id. |
| `Avatar` | `<div>` | Avatar column slot (compose `Avatar` / `AvatarGroup`). |
| `Title` | `<div>` | Conversation name (top line). |
| `Preview` | `<div>` | Last message (truncated) — or compose `ChatTyping` when typing.|
| `Meta` | `<div>` | Trailing time + unread `Badge` column. |
## Provider props
| Prop | Default | Meaning |
| --------------- | ------- | ------------------------------------------------ |
| `value` | — | Active conversation id (bindable). |
| `ariaLabel` | langs | Accessible name of the listbox. |
| `ariaLabelledby`| — | External label id. |
| `onValueChange` | — | `(id)` when the active conversation changes. |
`Item`: `value` (required, selection key) · `unread` · `disabled` ·
`textValue` (typeahead override). Snippet props: `{ value }`.
## A11y
- The pane **is** a `listbox` (single-select) — arrow keys, Home/End,
typeahead, `aria-selected` on the active option, all from the composed
Listbox. No re-implementation.
- Unread is a **visual** state (`data-unread`); the count rides an accessible
`Badge` the app composes. (A "N unread, muted" announcement is app-land.)
- Field/Form integration: n/a.
## Decisiones
- **Compose Listbox, don't reinvent** — a conversation list is single-select
navigation; Listbox already ships the roving focus / selection / typeahead /
a11y. chat-list maps its single `value` to/from Listbox's `string[]`.
- **Delegated expression** — no own sema events; selection perception belongs
to the composed Listbox (D.1).
- **`data-unread` is a per-instance prop** (each row differs) → the item runs
a per-instance runtime (AccordionItem pattern); its source returns the raw
boolean.
- **No virtualization in v1** — conversation lists are short; a virtualized
variant is a registered refinement (pairs with VirtualList chat-mode).
## Gaps (disposición)
- Virtualización de la lista (listas muy largas) — *v2 refinement* (junto a
los rangos bidireccionales de VirtualList).
- Swipe actions / context menu por conversación (archivar, silenciar) —
*diferir* (composición de `ContextMenu` / gestos).
- Secciones/grupos (fijados, no leídos) — *diferir* (compón `ChatList` con los
`Listbox.Group` que ya existen).

@ -0,0 +1,271 @@
import { context, type WithRefOpts } from '../../provider';
import {
readableActive,
writableActive,
type ActiveProps,
type StateProps
} from '$libs/reactive';
import { Soma } from '../../core/soma.svelte';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import { chatListMorfo } from '../../../morfo/components/chat-list';
import { ListboxProvider, ListboxItemProvider } from '../listbox/listbox-provider.svelte';
import { CHAT_LIST_LANGS } from './langs';
// ── Root provider ──────────────────────────────────────────────────────────
interface ChatListOpts
extends
WithRefOpts,
StateProps<{ value: string | undefined }>,
ActiveProps<{
ariaLabel: string | undefined;
ariaLabelledby: string | undefined;
onValueChange: ((id: string | undefined) => void) | undefined;
}> {}
export class ChatListProvider {
readonly opts: ChatListOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
static readonly ctx = context<ChatListProvider>('ChatList');
static get(): ChatListProvider | undefined {
return this.ctx.getOr(undefined) as ChatListProvider | undefined;
}
static require(): ChatListProvider {
return this.ctx.get();
}
static create(opts: ChatListOpts) {
return new ChatListProvider(opts);
}
/**
* The composed Listbox — created HERE and double-registered on the same
* shell element (shared id + ref, the chat-log precedent). Listbox owns
* `role=listbox`, roving-tabindex keyboard nav and selection; chat-list
* maps its single-select value to/from the app's `value` (one active
* conversation) and adds the conversation-row styling contract.
*/
readonly listbox: ListboxProvider;
private constructor(opts: ChatListOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(chatListMorfo, {});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: ChatListProvider.ctx,
syncAttrs: true
});
this.listbox = ListboxProvider.create({
id: opts.id,
ref: opts.ref,
// Single active conversation ⇄ Listbox's string[] value.
value: writableActive(
() => (this.opts.value.current ? [this.opts.value.current] : []),
(arr) => (this.opts.value.current = arr[0])
),
selectionMode: readableActive(() => 'single' as const),
loop: readableActive(() => true),
typeahead: readableActive(() => true),
typeaheadTimeout: readableActive(() => 500),
orientation: readableActive(() => 'vertical' as const),
dir: readableActive(() => undefined),
disabled: readableActive(() => false),
readonly: readableActive(() => false),
required: readableActive(() => false),
invalid: readableActive(() => false),
name: readableActive(() => undefined),
ariaLabel: readableActive(
() => this.opts.ariaLabel.current ?? this.soma.langs.ts(CHAT_LIST_LANGS.LABEL)
),
ariaLabelledby: readableActive(() => this.opts.ariaLabelledby.current),
onValueChange: readableActive(
() => (arr: string[]) => this.opts.onValueChange.current?.(arr[0])
)
});
}
readonly snippetProps = $derived.by(() => ({ value: this.opts.value.current }));
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
...this.listbox.props
} as const)
);
}
// ── Item (multi-instance: per-instance runtime, composes Listbox.Item) ─────
interface ChatListItemOpts
extends
WithRefOpts,
ActiveProps<{
value: string;
unread: boolean | undefined;
disabled: boolean | undefined;
textValue: string | undefined;
}> {}
export class ChatListItemProvider {
readonly opts: ChatListItemOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: ChatListProvider;
readonly runtime: SomaRuntime;
/** The composed Listbox option (role, aria-selected, activation). */
readonly listboxItem: ListboxItemProvider;
static create(opts: ChatListItemOpts) {
return new ChatListItemProvider(opts);
}
private constructor(opts: ChatListItemOpts) {
this.opts = opts;
this.provider = ChatListProvider.require();
// Per-instance runtime — `unread` differs per row (AccordionItem
// pattern; a shared prop source cannot carry per-instance state).
this.runtime = this.provider.soma.runtime(chatListMorfo, {
props: {
unread: () => this.opts.unread.current
}
});
this.runtimePart = this.runtime.part('item', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
this.listboxItem = ListboxItemProvider.create({
id: opts.id,
ref: opts.ref,
value: opts.value,
disabled: readableActive(() => this.opts.disabled.current ?? false),
textValue: opts.textValue
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
...this.listboxItem.props
} as const)
);
}
// ── Content parts (avatar · title · preview · meta) — static styled slots ──
interface ChatListPartOpts extends WithRefOpts {}
export class ChatListAvatarProvider {
readonly opts: ChatListPartOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: ChatListProvider;
static create(opts: ChatListPartOpts) {
return new ChatListAvatarProvider(opts);
}
private constructor(opts: ChatListPartOpts) {
this.opts = opts;
this.provider = ChatListProvider.require();
this.runtimePart = this.provider.runtime.part('avatar', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props
} as const)
);
}
export class ChatListTitleProvider {
readonly opts: ChatListPartOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: ChatListProvider;
static create(opts: ChatListPartOpts) {
return new ChatListTitleProvider(opts);
}
private constructor(opts: ChatListPartOpts) {
this.opts = opts;
this.provider = ChatListProvider.require();
this.runtimePart = this.provider.runtime.part('title', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props
} as const)
);
}
export class ChatListPreviewProvider {
readonly opts: ChatListPartOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: ChatListProvider;
static create(opts: ChatListPartOpts) {
return new ChatListPreviewProvider(opts);
}
private constructor(opts: ChatListPartOpts) {
this.opts = opts;
this.provider = ChatListProvider.require();
this.runtimePart = this.provider.runtime.part('preview', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props
} as const)
);
}
export class ChatListMetaProvider {
readonly opts: ChatListPartOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: ChatListProvider;
static create(opts: ChatListPartOpts) {
return new ChatListMetaProvider(opts);
}
private constructor(opts: ChatListPartOpts) {
this.opts = opts;
this.provider = ChatListProvider.require();
this.runtimePart = this.provider.runtime.part('meta', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props
} as const)
);
}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ChatListAvatarProvider } from '../chat-list-provider.svelte';
import type { ChatListAvatarProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'chat-list-avatar'),
children,
child,
...restProps
}: ChatListAvatarProps = $props();
const provider = ChatListAvatarProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,43 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ChatListItemProvider } from '../chat-list-provider.svelte';
import type { ChatListItemProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'chat-list-item'),
value,
unread,
disabled = false,
textValue,
children,
child,
...restProps
}: ChatListItemProps = $props();
const provider = ChatListItemProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: readableActive(() => value),
unread: readableActive(() => unread),
disabled: readableActive(() => disabled),
textValue: readableActive(() => textValue)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ChatListMetaProvider } from '../chat-list-provider.svelte';
import type { ChatListMetaProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'chat-list-meta'),
children,
child,
...restProps
}: ChatListMetaProps = $props();
const provider = ChatListMetaProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ChatListPreviewProvider } from '../chat-list-provider.svelte';
import type { ChatListPreviewProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'chat-list-preview'),
children,
child,
...restProps
}: ChatListPreviewProps = $props();
const provider = ChatListPreviewProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ChatListTitleProvider } from '../chat-list-provider.svelte';
import type { ChatListTitleProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'chat-list-title'),
children,
child,
...restProps
}: ChatListTitleProps = $props();
const provider = ChatListTitleProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,46 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ChatListProvider } from '../chat-list-provider.svelte';
import type { ChatListProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'chat-list'),
value = $bindable<string | undefined>(undefined),
ariaLabel,
ariaLabelledby,
onValueChange,
children,
child,
...restProps
}: ChatListProps = $props();
const provider = ChatListProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: writableActive(
() => value,
(v) => (value = v)
),
ariaLabel: readableActive(() => ariaLabel),
ariaLabelledby: readableActive(() => ariaLabelledby),
onValueChange: readableActive(() => onValueChange)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ ...provider.snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.(provider.snippetProps)}
</div>
{/if}

@ -0,0 +1,16 @@
export { default as Provider } from './components/chat-list.svelte';
export { default as Item } from './components/chat-list-item.svelte';
export { default as Avatar } from './components/chat-list-avatar.svelte';
export { default as Title } from './components/chat-list-title.svelte';
export { default as Preview } from './components/chat-list-preview.svelte';
export { default as Meta } from './components/chat-list-meta.svelte';
export type {
ChatListProps as ProviderProps,
ChatListItemProps as ItemProps,
ChatListAvatarProps as AvatarProps,
ChatListTitleProps as TitleProps,
ChatListPreviewProps as PreviewProps,
ChatListMetaProps as MetaProps,
ChatListSnippetProps as ProviderSnippetProps
} from './types';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,5 @@
// Imperative idlangref constants (A3). The catalog lives in
// `src/uix/langs/components/chat-list.ts`.
export const CHAT_LIST_LANGS = {
LABEL: '#?components.chat-list.label|Conversations'
} as const;

@ -0,0 +1,67 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without } from '../../types';
import type { PrimitiveDivAttributes } from '../../types';
/** Snippet props exposed by `ChatList.Provider`. */
export type ChatListSnippetProps = {
/** The active conversation id (selected value). */
value: string | undefined;
};
/**
* Props for the root `ChatList.Provider` — the conversation-list pane.
*
* COMPOSES the soma `Listbox` (single-select): the active conversation is the
* selected value, and roving-tabindex keyboard navigation + the `option` role
* come from Listbox. Data-agnostic: the app supplies the conversations as
* `ChatList.Item` children and binds `value`.
*/
export type ChatListProps = WithChild<
{
id?: string;
/** The active conversation id. Bindable. */
value?: string;
/** Accessible name. @default translated "Conversations" */
ariaLabel?: string;
/** External label id (wins over `ariaLabel`). */
ariaLabelledby?: string;
/** Fires when the active conversation changes. */
onValueChange?: (id: string | undefined) => void;
children?: Snippet<[ChatListSnippetProps]>;
},
ChatListSnippetProps
> &
Without<PrimitiveDivAttributes, {}>;
/**
* Props for `ChatList.Item` — one conversation row. Composes `Listbox.Item`
* (role=option, aria-selected, activation). Compose the content parts inside.
*/
export type ChatListItemProps = WithChild<{
id?: string;
/** Conversation id — the selection key. Required. */
value: string;
/** Marks the row as having unseen messages (`data-unread`). */
unread?: boolean;
/** Disable the row. @default false */
disabled?: boolean;
/** Explicit typeahead text (defaults to the row's text content). */
textValue?: string;
}> &
Without<PrimitiveDivAttributes, {}>;
/** Props for `ChatList.Avatar` — the avatar column (compose `Avatar`). */
export type ChatListAvatarProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
/** Props for `ChatList.Title` — the conversation name line. */
export type ChatListTitleProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
/** Props for `ChatList.Preview` — the last-message line (truncated). */
export type ChatListPreviewProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
/** Props for `ChatList.Meta` — the trailing time + unread-badge column. */
export type ChatListMetaProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;

@ -6,6 +6,7 @@ export * as Button from './button';
export * as Calendar from './calendar';
export * as Carousel from './carousel';
export * as ChatComposer from './chat-composer';
export * as ChatList from './chat-list';
export * as ChatLog from './chat-log';
export * as ChatMessage from './chat-message';
export * as ChatTyping from './chat-typing';

@ -269,6 +269,7 @@
{ slug: '/uix/components/tree-view', label: 'Tree view' },
{ slug: '/uix/components/tree-grid', label: 'Tree grid' },
{ slug: '/uix/components/chat-composer', label: 'Chat composer' },
{ slug: '/uix/components/chat-list', label: 'Chat list' },
{ slug: '/uix/components/chat-log', label: 'Chat log' },
{ slug: '/uix/components/chat-message', label: 'Chat message' },
{ slug: '/uix/components/chat-typing', label: 'Chat typing' },

@ -0,0 +1,306 @@
<script lang="ts">
/**
* ChatList — the conversation-list pane (v2 demo layout). Composes the
* soma Listbox (single-select active conversation + roving-tabindex
* keyboard) with a conversation-row layout: Avatar · Title/Preview · time
* + unread Badge. Typing-in-list composes ChatTyping in the preview.
*/
import { ChatList } from '$uix/eidos/components/chat-list';
import { ChatTyping } from '$uix/eidos/components/chat-typing';
import { Avatar } from '$uix/eidos/components/avatar';
import { Badge } from '$uix/eidos/components/badge';
import { Button } from '$uix/eidos/components/button';
import { compileMorfo } from '$uix/morfo';
import { chatListMorfo } from '@/uix/morfo/components/chat-list';
import { getActiveUix } from '$active-uix';
import SystemAxes from '../../lib/SystemAxes.svelte';
const uix = getActiveUix();
type Tab = 'live' | 'system' | 'api' | 'morfo' | 'a11y';
let tab = $state<Tab>('live');
// ── Conversation data (app-land) ────────────────────────────────────────
type Room = {
id: string;
name: string;
initials: string;
last: string;
time: string;
unread: number;
typing?: string[];
};
let rooms = $state<Room[]>([
{ id: 'ada', name: 'Ada Lovelace', initials: 'AL', last: '¿Vemos el plan esta tarde?', time: '14:32', unread: 2 },
{ id: 'eco', name: 'Eco (bot)', initials: 'EC', last: 'Vamos a probar el bloque completo.', time: '13:10', unread: 0 },
{ id: 'grace', name: 'Grace Hopper', initials: 'GH', last: '', time: '12:48', unread: 0, typing: ['Grace'] },
{ id: 'linus', name: 'Linus T.', initials: 'LT', last: 'Merge cuando quieras 👍', time: 'ayer', unread: 0 },
{ id: 'equipo', name: 'Equipo UIX', initials: 'UX', last: 'Alan: subo el escaparate', time: 'ayer', unread: 5 }
]);
let active = $state<string | undefined>('ada');
function selectRoom(id: string | undefined) {
active = id;
// Opening a conversation clears its unread count (app behavior).
if (id) rooms = rooms.map((r) => (r.id === id ? { ...r, unread: 0 } : r));
}
function bumpUnread() {
// Simulate an incoming message on a non-active room.
const target = rooms.find((r) => r.id !== active);
if (target) rooms = rooms.map((r) => (r.id === target.id ? { ...r, unread: r.unread + 1 } : r));
}
// ── System axes ─────────────────────────────────────────────────────────
let density = $state('comfortable');
let scaling = $state(1);
let mode = $state<'inherit' | 'light' | 'dark'>('inherit');
let dir = $state<'ltr' | 'rtl'>('ltr');
let borderWidth = $state(1);
const stageStyle = $derived(`--scaling: ${scaling}; --border-width: ${borderWidth}px;`);
let stageRef = $state<HTMLElement | null>(null);
const compiled = compileMorfo(chatListMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { ChatList } from '$uix/eidos/components/chat-list';",
" import { Avatar } from '$uix/eidos/components/avatar';",
" import { Badge } from '$uix/eidos/components/badge';",
'</' + 'script>',
'',
'<ChatList bind:value={active}>',
' {#each rooms as room (room.id)}',
' <ChatList.Item value={room.id} unread={room.unread > 0}>',
' <ChatList.Avatar><Avatar size="md">…</Avatar></ChatList.Avatar>',
' <ChatList.Title>{room.name}</ChatList.Title>',
' <ChatList.Preview>{room.last}</ChatList.Preview>',
' <ChatList.Meta>',
' {room.time}',
' {#if room.unread}<Badge color="primary" size="sm">{room.unread}</Badge>{/if}',
' </ChatList.Meta>',
' </ChatList.Item>',
' {/each}',
'</ChatList>'
].join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Chat · ChatList</div>
<h1 data-uix-page-title>ChatList</h1>
<p data-uix-page-lede>
The conversation-list pane (rooms / DMs). Composes the soma
<code>Listbox</code> for single-select (the active conversation) with
roving-tabindex keyboard navigation — arrow keys move, Enter/click
opens. Each row lays out an avatar, name, last-message preview and a
trailing time + unread <code>Badge</code>; typing-in-list composes
<code>ChatTyping</code> in the preview slot.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{partsList.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>rooms</span>{rooms.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>active</span>{active ?? '—'}</span>
</div>
</header>
<div data-uix-stage>
<div
data-uix-stage-area
bind:this={stageRef}
data-density={density}
data-theme={mode === 'inherit' ? undefined : mode}
data-mode={mode === 'inherit' ? undefined : mode}
{dir}
style={stageStyle}
>
<div
style="inline-size: min(22rem, 100%); block-size: 26rem; border: var(--border-width) solid var(--color-border-default); border-radius: var(--radius-lg); overflow: hidden;"
>
<ChatList bind:value={active} onValueChange={selectRoom}>
{#each rooms as room (room.id)}
<ChatList.Item value={room.id} unread={room.unread > 0}>
<ChatList.Avatar>
<Avatar size="md"><Avatar.Fallback>{room.initials}</Avatar.Fallback></Avatar>
</ChatList.Avatar>
<ChatList.Title>{room.name}</ChatList.Title>
<ChatList.Preview>
{#if room.typing && room.typing.length > 0}
<ChatTyping typers={room.typing} />
{:else}
{room.last}
{/if}
</ChatList.Preview>
<ChatList.Meta>
<span>{room.time}</span>
{#if room.unread > 0}
<Badge color="primary" size="sm">{room.unread}</Badge>
{/if}
</ChatList.Meta>
</ChatList.Item>
{/each}
</ChatList>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>active</span>
<span>{active ?? 'none'}</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>arrow keys</span> navigate · Enter opens
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'system'} onclick={() => (tab = 'system')}>
<span data-uix-layer-badge="eidos">eidos</span> System
</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span><span data-uix-tab-count>{partsList.length}p</span>
</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
The conversation data is app state; the list owns selection + keyboard
via the composed Listbox. Selecting a room clears its unread count (app
behavior). Use the keyboard in the stage: <span data-uix-kbd>↑</span> /
<span data-uix-kbd>↓</span> move, <span data-uix-kbd>Enter</span> opens.
</p>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>simulate</span>
<span style="display: flex; gap: var(--uix-space-1);">
<Button size="sm" variant="ghost" onclick={bumpUnread}>incoming (unread++)</Button>
<Button size="sm" variant="ghost" onclick={() => selectRoom(undefined)}>deselect</Button>
</span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>composition · ChatList + Avatar + Badge</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'system'}
<section data-uix-section>
<h2 data-uix-section-title><span data-uix-layer-badge="eidos">eidos</span> System axes</h2>
<p data-uix-section-desc>
Foundation knobs applied live. RTL mirrors the whole row (avatar side,
meta side) — all axes are logical.
</p>
<SystemAxes bind:density bind:scaling bind:mode bind:dir bind:borderWidth />
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr
><td class="name">value</td><td class="type">string (bindable)</td><td
class="default empty">—</td
><td>Active conversation id (single-select over the composed Listbox).</td></tr
>
<tr
><td class="name">onValueChange</td><td class="type">(id) =&gt; void</td><td
class="default empty">—</td
><td>Fires when the active conversation changes.</td></tr
>
<tr
><td class="name">ariaLabel</td><td class="type">string</td><td class="default">langs</td
><td>Accessible name of the listbox.</td></tr
>
<tr
><td class="name">Item value / unread / disabled</td><td class="type"
>string / boolean / boolean</td
><td class="default">— / false / false</td><td
>Row id (selection key), unread state (<code>data-unread</code>), disabled.</td
></tr
>
<tr
><td class="name">Avatar · Title · Preview · Meta</td><td class="type">slots</td><td
class="default empty">—</td
><td>Row content slots (the app composes Avatar / Badge / ChatTyping).</td></tr
>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span> · declarative contract
</h2>
<p data-uix-section-desc>
Source: <code>src/uix/morfo/components/chat-list.ts</code>. The provider
and item DOUBLE-register as <code>Listbox.Provider</code> /
<code>Listbox.Item</code>; selection + keyboard are delegated
(<code>expression: 'delegated'</code>, no own events).
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead
><tr><th>Part</th><th>Marker</th><th>Element</th><th>Optional</th></tr></thead
>
<tbody>
{#each partsList as part (part.kebab)}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>
The pane IS a <code>listbox</code> (from the composed Listbox):
single-select, roving-tabindex, arrow-key navigation, typeahead. The
active conversation is the selected <code>option</code>
(<code>aria-selected</code>). Unread is a visual state
(<code>data-unread</code> → bolder name); the count rides an accessible
<code>Badge</code>.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
<tr><td><span data-uix-kbd>↑</span> / <span data-uix-kbd>↓</span></td><td>Previous / next conversation.</td></tr>
<tr><td><span data-uix-kbd>Home</span> / <span data-uix-kbd>End</span></td><td>First / last.</td></tr>
<tr><td><span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span></td><td>Open the focused conversation.</td></tr>
<tr><td>type a name</td><td>Typeahead to the matching conversation.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.