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
parent
c9e7082213
commit
f9889b0b18
@ -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 };
|
||||
@ -0,0 +1,8 @@
|
||||
import type { LangNode } from '$libs/langs';
|
||||
|
||||
export const chatListLangs = {
|
||||
label: {
|
||||
es: 'Conversaciones',
|
||||
en: 'Conversations'
|
||||
}
|
||||
} satisfies LangNode;
|
||||
@ -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, {}>;
|
||||
@ -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) => 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"><{part.defaultElement}></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…
Reference in new issue