From f9889b0b1845b452edda6e34e143fadbad6e2a1b Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 20 Jul 2026 14:11:47 +0200 Subject: [PATCH] =?UTF-8?q?uix(chat):=20v2=20=C2=B7=20chat-list=20?= =?UTF-8?q?=E2=80=94=20panel=20de=20conversaciones=20(compone=20Listbox)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/next-features.md | 7 +- src/uix/eidos/components/chat-list/README.md | 80 +++++ .../chat-list/chat-list-avatar.svelte | 10 + .../chat-list/chat-list-item.svelte | 10 + .../chat-list/chat-list-meta.svelte | 10 + .../chat-list/chat-list-preview.svelte | 10 + .../chat-list/chat-list-title.svelte | 10 + .../eidos/components/chat-list/chat-list.css | 84 +++++ .../components/chat-list/chat-list.svelte | 15 + src/uix/eidos/components/chat-list/index.ts | 34 ++ src/uix/eidos/components/chat-list/types.ts | 18 ++ src/uix/eidos/generated/base.css | 18 ++ src/uix/eidos/lib/recipes/base.ts | 26 ++ src/uix/langs/components/chat-list.ts | 8 + src/uix/langs/components/index.ts | 2 + src/uix/morfo/components/chat-list.ts | 90 ++++++ src/uix/soma/components/chat-list/README.md | 81 +++++ .../chat-list/chat-list-provider.svelte.ts | 271 ++++++++++++++++ .../components/chat-list-avatar.svelte | 35 ++ .../components/chat-list-item.svelte | 43 +++ .../components/chat-list-meta.svelte | 35 ++ .../components/chat-list-preview.svelte | 35 ++ .../components/chat-list-title.svelte | 35 ++ .../chat-list/components/chat-list.svelte | 46 +++ src/uix/soma/components/chat-list/exports.ts | 16 + src/uix/soma/components/chat-list/index.ts | 1 + src/uix/soma/components/chat-list/langs.ts | 5 + src/uix/soma/components/chat-list/types.ts | 67 ++++ src/uix/soma/components/index.ts | 1 + web/routes/uix/+layout@.svelte | 1 + .../uix/components/chat-list/+page.svelte | 306 ++++++++++++++++++ 31 files changed, 1408 insertions(+), 2 deletions(-) create mode 100644 src/uix/eidos/components/chat-list/README.md create mode 100644 src/uix/eidos/components/chat-list/chat-list-avatar.svelte create mode 100644 src/uix/eidos/components/chat-list/chat-list-item.svelte create mode 100644 src/uix/eidos/components/chat-list/chat-list-meta.svelte create mode 100644 src/uix/eidos/components/chat-list/chat-list-preview.svelte create mode 100644 src/uix/eidos/components/chat-list/chat-list-title.svelte create mode 100644 src/uix/eidos/components/chat-list/chat-list.css create mode 100644 src/uix/eidos/components/chat-list/chat-list.svelte create mode 100644 src/uix/eidos/components/chat-list/index.ts create mode 100644 src/uix/eidos/components/chat-list/types.ts create mode 100644 src/uix/langs/components/chat-list.ts create mode 100644 src/uix/morfo/components/chat-list.ts create mode 100644 src/uix/soma/components/chat-list/README.md create mode 100644 src/uix/soma/components/chat-list/chat-list-provider.svelte.ts create mode 100644 src/uix/soma/components/chat-list/components/chat-list-avatar.svelte create mode 100644 src/uix/soma/components/chat-list/components/chat-list-item.svelte create mode 100644 src/uix/soma/components/chat-list/components/chat-list-meta.svelte create mode 100644 src/uix/soma/components/chat-list/components/chat-list-preview.svelte create mode 100644 src/uix/soma/components/chat-list/components/chat-list-title.svelte create mode 100644 src/uix/soma/components/chat-list/components/chat-list.svelte create mode 100644 src/uix/soma/components/chat-list/exports.ts create mode 100644 src/uix/soma/components/chat-list/index.ts create mode 100644 src/uix/soma/components/chat-list/langs.ts create mode 100644 src/uix/soma/components/chat-list/types.ts create mode 100644 web/routes/uix/components/chat-list/+page.svelte diff --git a/docs/next-features.md b/docs/next-features.md index 98912a1db..04b448f36 100644 --- a/docs/next-features.md +++ b/docs/next-features.md @@ -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`. diff --git a/src/uix/eidos/components/chat-list/README.md b/src/uix/eidos/components/chat-list/README.md new file mode 100644 index 000000000..03da1d225 --- /dev/null +++ b/src/uix/eidos/components/chat-list/README.md @@ -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 + + {#each rooms as room (room.id)} + 0}>… + {/each} + +``` + +## 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`). diff --git a/src/uix/eidos/components/chat-list/chat-list-avatar.svelte b/src/uix/eidos/components/chat-list/chat-list-avatar.svelte new file mode 100644 index 000000000..dd10d9734 --- /dev/null +++ b/src/uix/eidos/components/chat-list/chat-list-avatar.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/chat-list/chat-list-item.svelte b/src/uix/eidos/components/chat-list/chat-list-item.svelte new file mode 100644 index 000000000..75cc5aa70 --- /dev/null +++ b/src/uix/eidos/components/chat-list/chat-list-item.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/chat-list/chat-list-meta.svelte b/src/uix/eidos/components/chat-list/chat-list-meta.svelte new file mode 100644 index 000000000..231fa3a2b --- /dev/null +++ b/src/uix/eidos/components/chat-list/chat-list-meta.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/chat-list/chat-list-preview.svelte b/src/uix/eidos/components/chat-list/chat-list-preview.svelte new file mode 100644 index 000000000..702118d27 --- /dev/null +++ b/src/uix/eidos/components/chat-list/chat-list-preview.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/chat-list/chat-list-title.svelte b/src/uix/eidos/components/chat-list/chat-list-title.svelte new file mode 100644 index 000000000..4d99d9681 --- /dev/null +++ b/src/uix/eidos/components/chat-list/chat-list-title.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/chat-list/chat-list.css b/src/uix/eidos/components/chat-list/chat-list.css new file mode 100644 index 000000000..f9ca16c90 --- /dev/null +++ b/src/uix/eidos/components/chat-list/chat-list.css @@ -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); +} diff --git a/src/uix/eidos/components/chat-list/chat-list.svelte b/src/uix/eidos/components/chat-list/chat-list.svelte new file mode 100644 index 000000000..06538fa8a --- /dev/null +++ b/src/uix/eidos/components/chat-list/chat-list.svelte @@ -0,0 +1,15 @@ + + + diff --git a/src/uix/eidos/components/chat-list/index.ts b/src/uix/eidos/components/chat-list/index.ts new file mode 100644 index 000000000..c78c93bcf --- /dev/null +++ b/src/uix/eidos/components/chat-list/index.ts @@ -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'; diff --git a/src/uix/eidos/components/chat-list/types.ts b/src/uix/eidos/components/chat-list/types.ts new file mode 100644 index 000000000..489c96b27 --- /dev/null +++ b/src/uix/eidos/components/chat-list/types.ts @@ -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 }; diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 32de0ac70..0ffa958ad 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -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); diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index 78b6112ee..51afd4b85 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -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) ───────── diff --git a/src/uix/langs/components/chat-list.ts b/src/uix/langs/components/chat-list.ts new file mode 100644 index 000000000..80999dbb6 --- /dev/null +++ b/src/uix/langs/components/chat-list.ts @@ -0,0 +1,8 @@ +import type { LangNode } from '$libs/langs'; + +export const chatListLangs = { + label: { + es: 'Conversaciones', + en: 'Conversations' + } +} satisfies LangNode; diff --git a/src/uix/langs/components/index.ts b/src/uix/langs/components/index.ts index b5858ccb7..c9b66ee49 100644 --- a/src/uix/langs/components/index.ts +++ b/src/uix/langs/components/index.ts @@ -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, diff --git a/src/uix/morfo/components/chat-list.ts b/src/uix/morfo/components/chat-list.ts new file mode 100644 index 000000000..a95c9ea36 --- /dev/null +++ b/src/uix/morfo/components/chat-list.ts @@ -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; diff --git a/src/uix/soma/components/chat-list/README.md b/src/uix/soma/components/chat-list/README.md new file mode 100644 index 000000000..5ce1e99d6 --- /dev/null +++ b/src/uix/soma/components/chat-list/README.md @@ -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 + + {#each rooms as room (room.id)} + 0}> + … + {room.name} + {room.last} + {room.time} {room.unread} + + {/each} + +``` + +## Parts + +| Part | Element | Notes | +| ---------- | ------- | -------------------------------------------------------------- | +| `Provider` | `
` | Double-registers as `Listbox.Provider` (role=listbox). | +| `Item` | `
` | Double-registers as `Listbox.Item` (role=option); `data-unread`; `value` = conversation id. | +| `Avatar` | `
` | Avatar column slot (compose `Avatar` / `AvatarGroup`). | +| `Title` | `
` | Conversation name (top line). | +| `Preview` | `
` | Last message (truncated) — or compose `ChatTyping` when typing.| +| `Meta` | `
` | 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). diff --git a/src/uix/soma/components/chat-list/chat-list-provider.svelte.ts b/src/uix/soma/components/chat-list/chat-list-provider.svelte.ts new file mode 100644 index 000000000..a2b46869c --- /dev/null +++ b/src/uix/soma/components/chat-list/chat-list-provider.svelte.ts @@ -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('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) + ); +} diff --git a/src/uix/soma/components/chat-list/components/chat-list-avatar.svelte b/src/uix/soma/components/chat-list/components/chat-list-avatar.svelte new file mode 100644 index 000000000..c750429b0 --- /dev/null +++ b/src/uix/soma/components/chat-list/components/chat-list-avatar.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/chat-list/components/chat-list-item.svelte b/src/uix/soma/components/chat-list/components/chat-list-item.svelte new file mode 100644 index 000000000..f2fe924af --- /dev/null +++ b/src/uix/soma/components/chat-list/components/chat-list-item.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/chat-list/components/chat-list-meta.svelte b/src/uix/soma/components/chat-list/components/chat-list-meta.svelte new file mode 100644 index 000000000..3c4c486a0 --- /dev/null +++ b/src/uix/soma/components/chat-list/components/chat-list-meta.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/chat-list/components/chat-list-preview.svelte b/src/uix/soma/components/chat-list/components/chat-list-preview.svelte new file mode 100644 index 000000000..778787e65 --- /dev/null +++ b/src/uix/soma/components/chat-list/components/chat-list-preview.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/chat-list/components/chat-list-title.svelte b/src/uix/soma/components/chat-list/components/chat-list-title.svelte new file mode 100644 index 000000000..e6e594079 --- /dev/null +++ b/src/uix/soma/components/chat-list/components/chat-list-title.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/chat-list/components/chat-list.svelte b/src/uix/soma/components/chat-list/components/chat-list.svelte new file mode 100644 index 000000000..3be4d37d0 --- /dev/null +++ b/src/uix/soma/components/chat-list/components/chat-list.svelte @@ -0,0 +1,46 @@ + + +{#if child} + {@render child({ ...provider.snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(provider.snippetProps)} +
+{/if} diff --git a/src/uix/soma/components/chat-list/exports.ts b/src/uix/soma/components/chat-list/exports.ts new file mode 100644 index 000000000..9d1281cb8 --- /dev/null +++ b/src/uix/soma/components/chat-list/exports.ts @@ -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'; diff --git a/src/uix/soma/components/chat-list/index.ts b/src/uix/soma/components/chat-list/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/chat-list/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/chat-list/langs.ts b/src/uix/soma/components/chat-list/langs.ts new file mode 100644 index 000000000..611804066 --- /dev/null +++ b/src/uix/soma/components/chat-list/langs.ts @@ -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; diff --git a/src/uix/soma/components/chat-list/types.ts b/src/uix/soma/components/chat-list/types.ts new file mode 100644 index 000000000..b6c3d18ed --- /dev/null +++ b/src/uix/soma/components/chat-list/types.ts @@ -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; + +/** + * 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; + +/** Props for `ChatList.Avatar` — the avatar column (compose `Avatar`). */ +export type ChatListAvatarProps = WithChild<{ id?: string }> & + Without; + +/** Props for `ChatList.Title` — the conversation name line. */ +export type ChatListTitleProps = WithChild<{ id?: string }> & + Without; + +/** Props for `ChatList.Preview` — the last-message line (truncated). */ +export type ChatListPreviewProps = WithChild<{ id?: string }> & + Without; + +/** Props for `ChatList.Meta` — the trailing time + unread-badge column. */ +export type ChatListMetaProps = WithChild<{ id?: string }> & + Without; diff --git a/src/uix/soma/components/index.ts b/src/uix/soma/components/index.ts index 23824197f..8e4b15835 100644 --- a/src/uix/soma/components/index.ts +++ b/src/uix/soma/components/index.ts @@ -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'; diff --git a/web/routes/uix/+layout@.svelte b/web/routes/uix/+layout@.svelte index 0f5357788..a08f8c7f6 100644 --- a/web/routes/uix/+layout@.svelte +++ b/web/routes/uix/+layout@.svelte @@ -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' }, diff --git a/web/routes/uix/components/chat-list/+page.svelte b/web/routes/uix/components/chat-list/+page.svelte new file mode 100644 index 000000000..883863e9f --- /dev/null +++ b/web/routes/uix/components/chat-list/+page.svelte @@ -0,0 +1,306 @@ + + +
+
+
Chat · ChatList
+

ChatList

+

+ The conversation-list pane (rooms / DMs). Composes the soma + Listbox 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 Badge; typing-in-list composes + ChatTyping in the preview slot. +

+
+ parts{partsList.length} + rooms{rooms.length} + active{active ?? '—'} +
+
+ +
+
+
+ + {#each rooms as room (room.id)} + 0}> + + {room.initials} + + {room.name} + + {#if room.typing && room.typing.length > 0} + + {:else} + {room.last} + {/if} + + + {room.time} + {#if room.unread > 0} + {room.unread} + {/if} + + + {/each} + +
+
+
+ active + {active ?? 'none'} + + arrow keys navigate · Enter opens + +
+
+ +
+ + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ 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: ↑ / + ↓ move, Enter opens. +

+
+ +
+
+
+ eidos + composition · ChatList + Avatar + Badge + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'system'} +
+

eidos System axes

+

+ Foundation knobs applied live. RTL mirrors the whole row (avatar side, + meta side) — all axes are logical. +

+ +
+ {/if} + + {#if tab === 'api'} +
+

API reference

+
+ + + + + + + + + +
PropTypeDefaultDescription
valuestring (bindable)—Active conversation id (single-select over the composed Listbox).
onValueChange(id) => void—Fires when the active conversation changes.
ariaLabelstringlangsAccessible name of the listbox.
Item value / unread / disabledstring / boolean / boolean— / false / falseRow id (selection key), unread state (data-unread), disabled.
Avatar · Title · Preview · Metaslots—Row content slots (the app composes Avatar / Badge / ChatTyping).
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

+ morfo · declarative contract +

+

+ Source: src/uix/morfo/components/chat-list.ts. The provider + and item DOUBLE-register as Listbox.Provider / + Listbox.Item; selection + keyboard are delegated + (expression: 'delegated', no own events). +

+
+ + + + {#each partsList as part (part.kebab)} + + + + + + + {/each} + +
PartMarkerElementOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.optional ? 'yes' : 'no'}
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+

+ The pane IS a listbox (from the composed Listbox): + single-select, roving-tabindex, arrow-key navigation, typeahead. The + active conversation is the selected option + (aria-selected). Unread is a visual state + (data-unread → bolder name); the count rides an accessible + Badge. +

+
+ + + + + + + + +
KeyAction
↑ / ↓Previous / next conversation.
Home / EndFirst / last.
Enter / SpaceOpen the focused conversation.
type a nameTypeahead to the matching conversation.
+
+
+ {/if} +