uix(chat): v2 · typing avatars — slot Avatars en chat-typing

Primer remate de v2 (elegido por el usuario). chat-typing gana una parte
opcional `Avatars`: un slot antes de los dots donde la app compone
`AvatarGroup` / `Avatar` para quienes escriben. Decorativo (aria-hidden:
la label ya los nombra). Morfo part + soma provider/wrapper + eidos
wrapper/recipe (layout mínimo; el AvatarGroup ya apila) + demo con toggle.

Verificado (Playwright): slot presente con 2 avatares al activar 2 typers,
aria-hidden, antes de los dots; ausente en idle; toggle off = solo dots.
component:audit PASS, eidos-lint invalid=0, check en baseline (76).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 3 months ago
parent 5773ee6073
commit 3f3658da96

@ -14,7 +14,9 @@ No Air baseline — `chat-typing` is a new component.
## Visual API
The bare `<ChatTyping>` renders the canonical Indicator + Label pair;
Optional `Avatars` slot (compose `AvatarGroup` for the typers) renders
before the dots. The bare `<ChatTyping>` renders the canonical Indicator +
Label pair;
compose children for custom layouts (e.g. an `Avatar` next to the dots).
`Indicator` ships three dots by default (replaceable via children).

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

@ -24,6 +24,15 @@
visibility: hidden;
}
/* ── Avatars (optional slot before the dots — the app composes AvatarGroup;
the container gap separates it from the dots) ─────────────────────────── */
[data-chat-typing-avatars] {
display: inline-flex;
align-items: center;
flex: none;
}
/* ── Dots ───────────────────────────────────────────────────────────────── */
[data-chat-typing-indicator] {

@ -1,13 +1,16 @@
import ChatTypingComponent from './chat-typing.svelte';
import Avatars from './chat-typing-avatars.svelte';
import Indicator from './chat-typing-indicator.svelte';
import Label from './chat-typing-label.svelte';
type ChatTypingNamespace = typeof ChatTypingComponent & {
Avatars: typeof Avatars;
Indicator: typeof Indicator;
Label: typeof Label;
};
const ChatTyping = ChatTypingComponent as ChatTypingNamespace;
ChatTyping.Avatars = Avatars;
ChatTyping.Indicator = Indicator;
ChatTyping.Label = Label;
@ -17,6 +20,7 @@ export default ChatTyping;
export type {
ChatTypingProps,
ChatTypingSnippetProps,
ChatTypingAvatarsProps as AvatarsProps,
ChatTypingIndicatorProps as IndicatorProps,
ChatTypingLabelProps as LabelProps
} from './types';

@ -1,11 +1,13 @@
import type {
ProviderProps,
AvatarsProps,
IndicatorProps,
LabelProps,
ProviderSnippetProps
} from '$soma/components/chat-typing';
export type ChatTypingProps = ProviderProps;
export type ChatTypingAvatarsProps = AvatarsProps;
export type ChatTypingIndicatorProps = IndicatorProps;
export type ChatTypingLabelProps = LabelProps;

@ -55,6 +55,18 @@ export const chatTypingMorfo = {
{ attr: 'aria-hidden', value: v.propRef('ariaHidden') }
]
},
{
// Optional avatar slot before the dots — the app composes
// `AvatarGroup` / `Avatar` for the typers. Decorative: the label
// already names who, so the whole subtree is hidden from AT.
name: 'Avatars',
kebab: 'avatars',
kind: 'public',
defaultElement: 'span',
optional: true,
data: [],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{
// The pulsing dots — decorative (the label carries the meaning).
name: 'Indicator',

@ -23,6 +23,7 @@ The OUTGOING side (debounce, auto-stop) lives in `ChatComposer`
| Part | Element | Notes |
| ----------- | -------- | -------------------------------------------------- |
| `Provider` | `<div>` | `data-state="active\|idle"`; optional `aria-hidden`. |
| `Avatars` | `<span>` | Optional slot before the dots — compose `AvatarGroup` / `Avatar` for the typers; `aria-hidden="true"` (the label names them). |
| `Indicator` | `<span>` | The dots — `aria-hidden="true"` (decorative). |
| `Label` | `<span>` | Renders the provider's aggregated label by default. |
@ -71,7 +72,8 @@ visual loop, so this morfo ships no pack (`expression: 'family-default'`).
## Gaps (disposición)
- Avatares de quien escribe (patrón Messenger) — *v2 gap* (componer `Avatar`
en `Indicator` ya es posible vía children; falta receta dedicada).
- Avatares de quien escribe (patrón Messenger) — **✅ v2 (2026-07-19)**: la
parte `Avatars` es un slot antes de los dots donde la app compone
`AvatarGroup`.
- Typing en la lista de conversaciones (`chat-list`) — *v2 gap* (junto al
propio `chat-list`).

@ -110,6 +110,33 @@ export class ChatTypingProvider {
interface ChatTypingPartOpts extends WithRefOpts {}
export class ChatTypingAvatarsProvider {
readonly opts: ChatTypingPartOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: ChatTypingProvider;
static create(opts: ChatTypingPartOpts) {
return new ChatTypingAvatarsProvider(opts);
}
private constructor(opts: ChatTypingPartOpts) {
this.opts = opts;
this.provider = ChatTypingProvider.require();
this.runtimePart = this.provider.runtime.part('avatars', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props
} as const)
);
}
export class ChatTypingIndicatorProvider {
readonly opts: ChatTypingPartOpts;
readonly runtimePart: SomaRuntimePart;

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ChatTypingAvatarsProvider } from '../chat-typing-provider.svelte';
import type { ChatTypingAvatarsProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'chat-typing-avatars'),
children,
child,
...restProps
}: ChatTypingAvatarsProps = $props();
const provider = ChatTypingAvatarsProvider.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}
<span {...mergedProps}>
{@render children?.()}
</span>
{/if}

@ -1,9 +1,11 @@
export { default as Provider } from './components/chat-typing.svelte';
export { default as Avatars } from './components/chat-typing-avatars.svelte';
export { default as Indicator } from './components/chat-typing-indicator.svelte';
export { default as Label } from './components/chat-typing-label.svelte';
export type {
ChatTypingProps as ProviderProps,
ChatTypingAvatarsProps as AvatarsProps,
ChatTypingIndicatorProps as IndicatorProps,
ChatTypingLabelProps as LabelProps,
ChatTypingSnippetProps as ProviderSnippetProps

@ -32,6 +32,12 @@ export type ChatTypingProps = WithChild<
> &
Without<PrimitiveDivAttributes, {}>;
/** Props for `ChatTyping.Avatars` — the optional avatar slot (decorative). */
export type ChatTypingAvatarsProps = WithChild<{
id?: string;
}> &
Without<PrimitiveSpanAttributes, {}>;
/** Props for `ChatTyping.Indicator` — the pulsing dots (decorative). */
export type ChatTypingIndicatorProps = WithChild<{
id?: string;

@ -5,6 +5,7 @@
* the SYSTEM pulse loop (reduced-motion handled by the loop layer).
*/
import { ChatTyping } from '$uix/eidos/components/chat-typing';
import { Avatar, AvatarGroup } from '$uix/eidos/components/avatar';
import { Button } from '$uix/eidos/components/button';
import { compileMorfo } from '$uix/morfo';
import { chatTypingMorfo } from '@/uix/morfo/components/chat-typing';
@ -25,6 +26,9 @@
let typers = $state<string[]>([]);
let manyFrom = $state(3);
let ariaHidden = $state(false);
let showAvatars = $state(true);
const initials = (name: string) => name.slice(0, 2).toUpperCase();
function toggleTyper(name: string) {
typers = typers.includes(name) ? typers.filter((t) => t !== name) : [...typers, name];
@ -106,7 +110,25 @@
<p style="margin: 0 0 var(--uix-space-2); color: var(--color-content-secondary); font-size: var(--size-sm-font-size);">
…la conversación va aquí arriba…
</p>
<ChatTyping {typers} {manyFrom} ariaHidden={ariaHidden || undefined} />
{#if showAvatars}
<ChatTyping {typers} {manyFrom} ariaHidden={ariaHidden || undefined}>
{#snippet children({ label })}
{#if typers.length > 0}
<ChatTyping.Avatars>
<AvatarGroup size="xs" stacking="overlap" max={3} total={typers.length}>
{#each typers as name (name)}
<Avatar><Avatar.Fallback>{initials(name)}</Avatar.Fallback></Avatar>
{/each}
</AvatarGroup>
</ChatTyping.Avatars>
{/if}
<ChatTyping.Indicator />
<ChatTyping.Label>{label}</ChatTyping.Label>
{/snippet}
</ChatTyping>
{:else}
<ChatTyping {typers} {manyFrom} ariaHidden={ariaHidden || undefined} />
{/if}
</div>
</div>
<div data-uix-stage-trace>
@ -203,6 +225,14 @@
></span
>
</label>
<label data-uix-control>
<span data-uix-control-label>avatars slot</span>
<span data-uix-switch
><input type="checkbox" bind:checked={showAvatars} /><span data-uix-switch-label
>{showAvatars ? 'AvatarGroup + dots' : 'dots only'}</span
></span
>
</label>
</div>
<div data-uix-code>

Loading…
Cancel
Save

Powered by TurnKey Linux.