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 type { Morfo } from '../types';
import { v } from '../types';
export const avatarMorfo = { export const avatarMorfo = {
name: 'Avatar', name: 'Avatar',
@ -11,7 +12,13 @@ export const avatarMorfo = {
kind: 'public', kind: 'public',
defaultElement: 'span', defaultElement: 'span',
optional: false, 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: [] aria: []
}, },
{ {
@ -20,7 +27,13 @@ export const avatarMorfo = {
kind: 'public', kind: 'public',
defaultElement: 'img', defaultElement: 'img',
optional: false, 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: [] aria: []
}, },
{ {
@ -29,7 +42,13 @@ export const avatarMorfo = {
kind: 'public', kind: 'public',
defaultElement: 'span', defaultElement: 'span',
optional: false, 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: [] aria: []
} }
] ]

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

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

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

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { readableActive, writableActive } from '../../../reactive'; import { bindProps, state } from '../../../reactive';
import { mergeProps } from '../../../props'; import { mergeProps } from '../../../props';
import { createId } from '../../../id'; import { createId } from '../../../id';
import { AvatarProvider } from '../avatar-provider.svelte'; import { AvatarProvider } from '../avatar-provider.svelte';
@ -17,21 +17,19 @@
...restProps ...restProps
}: AvatarProps = $props(); }: 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({ const provider = AvatarProvider.create({
id: readableActive(() => id), ...bindProps({
ref: writableActive( id: () => id,
() => ref, ref: { get: () => ref, set: (v) => (ref = v) },
(v) => (ref = v) delayMs: () => delayMs,
), onStatusChange: () => onStatusChange
status: writableActive( }),
() => status, status
(v) => (status = v) } as Parameters<typeof AvatarProvider.create>[0]);
),
delayMs: readableActive(() => delayMs),
onStatusChange: readableActive(() => onStatusChange)
});
const mergedProps = $derived(mergeProps(restProps, provider.props)); const mergedProps = $derived(mergeProps(restProps, provider.props));
</script> </script>

Loading…
Cancel
Save

Powered by TurnKey Linux.