soma: Avatar migrated to runtime + V2 props bridge

First component of the bulk migration phase (step 2 of the 1-3-2 plan).
Pattern is now well-rehearsed across Toggle / Collapsible / Toast / Switch:
  - morfo: add `value` sources to data entries that were enum-only
  - provider: replace manual Opts interface with `OptsFromProps<...>`,
    create runtime in constructor, register parts, drop manual data-* /
    aria attrs from props (runtime emits via dom.apply)
  - wrapper: replace per-prop `readableActive(() => x)` list with one
    `bindProps({ ... })` call
  - keep manual: HTML primitives (style, src, etc.) and event handlers

Avatar specifics
- 3 parts (provider/image/fallback) all share `data-status` from the root's
  internal `status` State (idle/loading/loaded/error). morfo's `propRef('status')`
  reads from runtime sources where the root provider exposes it.
- `status` is not a public Props field — it's an internal $state in the
  wrapper. Manually intersected into `AvatarOpts` rather than derived from
  AvatarProps.
- Image's preload logic, src/crossorigin/referrerpolicy passthrough, and
  the style-based show/hide on each part stay in the provider's render —
  none of these are morfo-managed.
- Wrapper cast uses `as unknown as Parameters<typeof X.create>[0]` for
  parts whose Opts have only `id`+`ref` (TS can't extract the specific
  fields from the generic `Record<string, Active<unknown>>` return of
  `bindProps`). Toggle/Switch don't need this because their richer Opts
  shape provides enough overlap.

Bulk migration paused at this point — Progress / Meter need a design
decision around `aria-label` priority logic (the legacy provider has
"consumer-passed wins over translated default", which conflicts with the
"dom.apply is the only writer of mutable attrs" rule). Either:
  (a) Drop aria-label from those morfos and keep provider-managed (legacy
      pattern preserved, morfo loses one declaration).
  (b) Extend the resolver with a priority chain (propRef → translationRef
      fallback) so the morfo can express "consumer wins, translation
      otherwise".

Verification
- 66/66 morfo-check (Avatar passes; all data-status emitted by runtime).
- 68/68 smoke routes.
- svelte-check unchanged at 155.
morfo-runtime
dev 6 months ago
parent 55a5ad2647
commit e7e32a8355

@ -1,4 +1,5 @@
import type { Morfo } from '../types';
import { v } from '../types';
export const avatarMorfo = {
name: 'Avatar',
@ -11,7 +12,13 @@ export const avatarMorfo = {
kind: 'public',
defaultElement: 'span',
optional: false,
data: [{ attr: 'data-status', values: ['idle', 'loading', 'loaded', 'error'] }],
data: [
{
attr: 'data-status',
values: ['idle', 'loading', 'loaded', 'error'],
value: v.propRef('status')
}
],
aria: []
},
{
@ -20,7 +27,13 @@ export const avatarMorfo = {
kind: 'public',
defaultElement: 'img',
optional: false,
data: [{ attr: 'data-status', values: ['idle', 'loading', 'loaded', 'error'] }],
data: [
{
attr: 'data-status',
values: ['idle', 'loading', 'loaded', 'error'],
value: v.propRef('status')
}
],
aria: []
},
{
@ -29,7 +42,13 @@ export const avatarMorfo = {
kind: 'public',
defaultElement: 'span',
optional: false,
data: [{ attr: 'data-status', values: ['idle', 'loading', 'loaded', 'error'] }],
data: [
{
attr: 'data-status',
values: ['idle', 'loading', 'loaded', 'error'],
value: v.propRef('status')
}
],
aria: []
}
]

@ -1,14 +1,16 @@
import { watch } from 'runed';
import { Provider, context, type WithRefOpts } from '../../provider';
import { createAttrs, registerContract } from '../../attrs';
import { state, type ActiveProps, type StateProps } from '../../reactive';
import type { OptsFromProps, State } from '../../reactive';
import type { OnChangeFn } from '../../types';
import { Soma } from '../../core/soma.svelte';
import { createMorfoRuntime, type MorfoRuntime } from '../../morfo';
import type { HTMLImgAttributes } from 'svelte/elements';
import type { AvatarLoadingStatus } from './types';
import type { AvatarLoadingStatus, AvatarProps, AvatarImageProps } from './types';
import { avatarMorfo } from '../../../morfo/components/avatar';
const attrs = createAttrs(avatarMorfo);
createAttrs(avatarMorfo);
registerContract(avatarMorfo);
type CrossOrigin = HTMLImgAttributes['crossorigin'];
@ -16,13 +18,13 @@ type ReferrerPolicy = HTMLImgAttributes['referrerpolicy'];
// ── Root provider ──────────────────────────────────────────────────────────
interface AvatarOpts
extends WithRefOpts,
StateProps<{ status: AvatarLoadingStatus }>,
ActiveProps<{
delayMs: number;
onStatusChange: OnChangeFn<AvatarLoadingStatus> | undefined;
}> {}
/**
* `status` is internal state owned by the wrapper (not part of AvatarProps),
* so it's intersected manually rather than derived from Props.
*/
type AvatarOpts = OptsFromProps<AvatarProps, 'delayMs' | 'onStatusChange', never> & {
status: State<AvatarLoadingStatus>;
};
export class AvatarProvider extends Provider<AvatarOpts> {
static readonly ctx = context<AvatarProvider>('Avatar');
@ -37,8 +39,19 @@ export class AvatarProvider extends Provider<AvatarOpts> {
return new AvatarProvider(opts);
}
readonly runtime: MorfoRuntime;
private constructor(opts: AvatarOpts) {
super(opts, 'Avatar', 'provider', attrs.provider, AvatarProvider.ctx);
super(opts, { morfo: avatarMorfo, part: 'provider' }, AvatarProvider.ctx);
const soma = Soma.require();
this.runtime = createMorfoRuntime(avatarMorfo, {
dom: soma.dom,
props: {
status: () => opts.status.current
}
});
this.runtime.registerPart('provider', { id: opts.id, ref: opts.ref });
// Fire onStatusChange whenever status transitions.
let previous = opts.status.current;
@ -103,21 +116,18 @@ export class AvatarProvider extends Provider<AvatarOpts> {
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'data-status': this.opts.status.current
...this.runtime.partProps('provider')
} as const)
);
}
// ── Image ──────────────────────────────────────────────────────────────────
interface AvatarImageOpts
extends WithRefOpts,
ActiveProps<{
src: string | null | undefined;
crossOrigin: CrossOrigin;
referrerPolicy: ReferrerPolicy;
}> {}
type AvatarImageOpts = OptsFromProps<
AvatarImageProps,
'src' | 'crossorigin' | 'referrerpolicy',
never
>;
export class AvatarImageProvider extends Provider<AvatarImageOpts> {
static create(opts: AvatarImageOpts) {
@ -127,16 +137,17 @@ export class AvatarImageProvider extends Provider<AvatarImageOpts> {
readonly provider: AvatarProvider;
private constructor(opts: AvatarImageOpts) {
super(opts, 'Avatar', 'image', attrs.image);
super(opts, { morfo: avatarMorfo, part: 'image' });
this.provider = AvatarProvider.require();
this.provider.runtime.registerPart('image', { id: opts.id, ref: opts.ref });
// Preload on src change. `watch.pre` runs synchronously so the
// status flips to 'loading' before the first paint.
watch.pre(
[
() => this.opts.src.current,
() => this.opts.crossOrigin.current,
() => this.opts.referrerPolicy.current
() => this.opts.crossorigin.current,
() => this.opts.referrerpolicy.current
],
([src, crossOrigin, referrerPolicy]) => {
if (!src) {
@ -151,16 +162,15 @@ export class AvatarImageProvider extends Provider<AvatarImageOpts> {
readonly props = $derived.by(() => {
const status = this.provider.opts.status.current;
return this.assertProps({
...this.baseProps,
...this.provider.runtime.partProps('image'),
// The img element exists in DOM for all states, but is hidden
// until `loaded` so preloading doesn't flicker.
style: {
display: status === 'loaded' ? 'block' : 'none'
},
src: this.opts.src.current || undefined,
crossorigin: this.opts.crossOrigin.current,
referrerpolicy: this.opts.referrerPolicy.current,
'data-status': status
crossorigin: this.opts.crossorigin.current,
referrerpolicy: this.opts.referrerpolicy.current
} as const);
});
}
@ -177,17 +187,19 @@ export class AvatarFallbackProvider extends Provider<AvatarFallbackOpts> {
readonly provider: AvatarProvider;
private constructor(opts: AvatarFallbackOpts) {
super(opts, 'Avatar', 'fallback', attrs.fallback);
super(opts, { morfo: avatarMorfo, part: 'fallback' });
this.provider = AvatarProvider.require();
this.provider.runtime.registerPart('fallback', { id: opts.id, ref: opts.ref });
}
readonly props = $derived.by(() => {
const status = this.provider.opts.status.current;
// Visible while idle / loading / error. Hidden once image loaded.
return this.assertProps({
...this.baseProps,
style: status === 'loaded' ? { display: 'none' } : undefined,
'data-status': status
...this.provider.runtime.partProps('fallback'),
style: status === 'loaded' ? { display: 'none' } : undefined
} as const);
});
}
export type { OnChangeFn };

@ -1,5 +1,5 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { bindProps } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { AvatarFallbackProvider } from '../avatar-provider.svelte';
@ -15,13 +15,12 @@
...restProps
}: AvatarFallbackProps = $props();
const state = AvatarFallbackProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const state = AvatarFallbackProvider.create(
bindProps({
id: () => id,
ref: { get: () => ref, set: (v) => (ref = v) }
}) as unknown as Parameters<typeof AvatarFallbackProvider.create>[0]
);
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>

@ -1,5 +1,5 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { bindProps } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { AvatarImageProvider } from '../avatar-provider.svelte';
@ -16,16 +16,15 @@
...restProps
}: AvatarImageProps = $props();
const state = AvatarImageProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
src: readableActive(() => src),
crossOrigin: readableActive(() => crossorigin),
referrerPolicy: readableActive(() => referrerpolicy)
});
const state = AvatarImageProvider.create(
bindProps({
id: () => id,
ref: { get: () => ref, set: (v) => (ref = v) },
src: () => src,
crossorigin: () => crossorigin,
referrerpolicy: () => referrerpolicy
}) as Parameters<typeof AvatarImageProvider.create>[0]
);
const mergedProps = $derived(mergeProps({}, restProps, state.props));
</script>

@ -1,5 +1,5 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { bindProps, state } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { AvatarProvider } from '../avatar-provider.svelte';
@ -17,21 +17,19 @@
...restProps
}: AvatarProps = $props();
let status = $state<AvatarLoadingStatus>('idle');
// `status` is internal (not a public prop). Keep it as a State box so the
// provider can mutate it from `loadImage` and the runtime can subscribe.
const status = state<AvatarLoadingStatus>('idle');
const provider = AvatarProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
status: writableActive(
() => status,
(v) => (status = v)
),
delayMs: readableActive(() => delayMs),
onStatusChange: readableActive(() => onStatusChange)
});
...bindProps({
id: () => id,
ref: { get: () => ref, set: (v) => (ref = v) },
delayMs: () => delayMs,
onStatusChange: () => onStatusChange
}),
status
} as Parameters<typeof AvatarProvider.create>[0]);
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>

Loading…
Cancel
Save

Powered by TurnKey Linux.