You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
151 lines
4.7 KiB
151 lines
4.7 KiB
/**
|
|
* Pure morfo → attrs resolver.
|
|
*
|
|
* Walks a `MorfoPart`'s `data` and `aria` declarations, evaluates conditions,
|
|
* resolves value sources against the supplied bindings, and returns a flat
|
|
* attribute map ready to render or to pass to `dom.apply`.
|
|
*
|
|
* No reactivity, no side-effects, no DOM. The runtime layer wraps these in
|
|
* effects; the Provider base class wraps them in a `$derived.by`.
|
|
*/
|
|
|
|
import type {
|
|
MorfoAriaEntry,
|
|
MorfoCondition,
|
|
MorfoData,
|
|
MorfoPart,
|
|
MorfoPrimitiveValueSource,
|
|
MorfoValueSource
|
|
} from './types';
|
|
|
|
export interface MorfoBindings {
|
|
props?: Record<string, unknown>;
|
|
states?: Record<string, unknown>;
|
|
parts?: Record<string, unknown>;
|
|
translations?: (key: string) => string | undefined;
|
|
}
|
|
|
|
export function findMorfoPart(parts: readonly MorfoPart[], target: string): MorfoPart | undefined {
|
|
for (const part of parts) {
|
|
if (part.kebab === target) return part;
|
|
if (part.parts) {
|
|
const nested = findMorfoPart(part.parts, target);
|
|
if (nested) return nested;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function shouldEmitMorfoEntry(
|
|
condition: MorfoCondition | undefined,
|
|
bindings: MorfoBindings
|
|
): boolean {
|
|
if (!condition || condition === 'always') return true;
|
|
if (condition.when === 'part-present') return Boolean(bindings.parts?.[condition.part]);
|
|
if (condition.when === 'state-equals') return bindings.states?.[condition.state] === condition.value;
|
|
if (condition.when === 'prop-truthy') return Boolean(bindings.props?.[condition.prop]);
|
|
if (condition.when === 'prop-falsy') return !bindings.props?.[condition.prop];
|
|
return true;
|
|
}
|
|
|
|
function resolveMorfoPrimitiveSource(
|
|
source: MorfoPrimitiveValueSource,
|
|
bindings: MorfoBindings
|
|
): unknown {
|
|
if (source.kind === 'literal') return source.value;
|
|
if (source.kind === 'stateRef') return bindings.states?.[source.state];
|
|
if (source.kind === 'partRef') return bindings.parts?.[source.target];
|
|
if (source.kind === 'propRef') return bindings.props?.[source.prop];
|
|
if (source.kind === 'translationRef') return bindings.translations?.(source.key);
|
|
return undefined;
|
|
}
|
|
|
|
export function resolveMorfoSource(source: MorfoValueSource, bindings: MorfoBindings): unknown {
|
|
if (source.kind !== 'mapRef') {
|
|
return resolveMorfoPrimitiveSource(source, bindings);
|
|
}
|
|
|
|
const raw = resolveMorfoPrimitiveSource(source.source, bindings);
|
|
if (raw === undefined || raw === null) {
|
|
return source.fallback;
|
|
}
|
|
|
|
const mapped = source.map[String(raw)];
|
|
return mapped ?? source.fallback;
|
|
}
|
|
|
|
export function resolveMorfoDataValue(data: MorfoData, bindings: MorfoBindings): unknown {
|
|
const source = data.value;
|
|
if (!source) return undefined;
|
|
const raw = resolveMorfoSource(source, bindings);
|
|
|
|
if (!data.values || data.values.length === 0) {
|
|
return raw ? '' : undefined;
|
|
}
|
|
|
|
if (source.kind === 'stateRef' && typeof raw === 'boolean') {
|
|
if (raw) return source.state;
|
|
return data.values.find((value) => value !== source.state);
|
|
}
|
|
|
|
return raw;
|
|
}
|
|
|
|
export function resolveMorfoAriaValue(entry: MorfoAriaEntry, bindings: MorfoBindings): unknown {
|
|
const raw = resolveMorfoSource(entry.value, bindings);
|
|
|
|
if (entry.value.kind === 'stateRef') {
|
|
// ARIA boolean attrs require literal 'true'/'false' strings, not the
|
|
// HTML boolean attribute semantics. `aria-pressed="false"` is a valid
|
|
// state distinct from the attribute being absent — never remove it
|
|
// based on falsy values.
|
|
return Boolean(raw) ? 'true' : 'false';
|
|
}
|
|
|
|
return raw;
|
|
}
|
|
|
|
/**
|
|
* Resolves a part's full attribute map (role + data-* + aria-*) against the
|
|
* given bindings.
|
|
*
|
|
* The map ALWAYS includes every declared attr (data with a `value` source +
|
|
* every aria entry). Attrs whose condition is falsy or whose value source
|
|
* resolves to nothing are emitted as `undefined`, which `dom.apply` reads as
|
|
* "remove this attribute" and Svelte ignores during render. This is what
|
|
* lets the runtime clean up attrs that previously matched their condition
|
|
* but no longer do (e.g. `data-invalid` toggling from set to absent).
|
|
*
|
|
* Entries with no `value` source on `data` are skipped entirely — they are
|
|
* documentation in the morfo, not runtime-managed attrs.
|
|
*/
|
|
export function resolvePartAttrs(
|
|
partMeta: MorfoPart,
|
|
bindings: MorfoBindings
|
|
): Record<string, unknown> {
|
|
const result: Record<string, unknown> = {};
|
|
|
|
if (partMeta.role) {
|
|
result.role = partMeta.role;
|
|
}
|
|
|
|
for (const data of partMeta.data) {
|
|
if (!data.value) continue;
|
|
if (!shouldEmitMorfoEntry(data.condition, bindings)) {
|
|
result[data.attr] = undefined;
|
|
continue;
|
|
}
|
|
result[data.attr] = resolveMorfoDataValue(data, bindings);
|
|
}
|
|
|
|
for (const aria of partMeta.aria) {
|
|
if (!shouldEmitMorfoEntry(aria.condition, bindings)) {
|
|
result[aria.attr] = undefined;
|
|
continue;
|
|
}
|
|
result[aria.attr] = resolveMorfoAriaValue(aria, bindings);
|
|
}
|
|
|
|
return result;
|
|
}
|