diff --git a/src/uix/morfo/components/avatar.ts b/src/uix/morfo/components/avatar.ts index 65ba05c80..e596e3d51 100644 --- a/src/uix/morfo/components/avatar.ts +++ b/src/uix/morfo/components/avatar.ts @@ -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: [] } ] diff --git a/src/uix/soma/components/avatar/avatar-provider.svelte.ts b/src/uix/soma/components/avatar/avatar-provider.svelte.ts index e2b705b96..81f4d6d46 100644 --- a/src/uix/soma/components/avatar/avatar-provider.svelte.ts +++ b/src/uix/soma/components/avatar/avatar-provider.svelte.ts @@ -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 | 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 & { + status: State; +}; export class AvatarProvider extends Provider { static readonly ctx = context('Avatar'); @@ -37,8 +39,19 @@ export class AvatarProvider extends Provider { 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 { 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 { static create(opts: AvatarImageOpts) { @@ -127,16 +137,17 @@ export class AvatarImageProvider extends Provider { 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 { 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 { 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 }; diff --git a/src/uix/soma/components/avatar/components/avatar-fallback.svelte b/src/uix/soma/components/avatar/components/avatar-fallback.svelte index 5bf9abb3e..6a874e6e2 100644 --- a/src/uix/soma/components/avatar/components/avatar-fallback.svelte +++ b/src/uix/soma/components/avatar/components/avatar-fallback.svelte @@ -1,5 +1,5 @@ diff --git a/src/uix/soma/components/avatar/components/avatar-image.svelte b/src/uix/soma/components/avatar/components/avatar-image.svelte index 8c4550cac..982f40361 100644 --- a/src/uix/soma/components/avatar/components/avatar-image.svelte +++ b/src/uix/soma/components/avatar/components/avatar-image.svelte @@ -1,5 +1,5 @@ diff --git a/src/uix/soma/components/avatar/components/avatar.svelte b/src/uix/soma/components/avatar/components/avatar.svelte index ebc0bd29a..df15f0a13 100644 --- a/src/uix/soma/components/avatar/components/avatar.svelte +++ b/src/uix/soma/components/avatar/components/avatar.svelte @@ -1,5 +1,5 @@