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.
143 lines
4.4 KiB
143 lines
4.4 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 '../../morfo/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. Returns `undefined`-valued entries for declared attrs that
|
|
* shouldn't be emitted in the current state — the caller decides whether to
|
|
* skip them (Svelte does it automatically; `dom.apply` removes them).
|
|
*/
|
|
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)) continue;
|
|
const value = resolveMorfoDataValue(data, bindings);
|
|
if (value !== undefined) {
|
|
result[data.attr] = value;
|
|
}
|
|
}
|
|
|
|
for (const aria of partMeta.aria) {
|
|
if (!shouldEmitMorfoEntry(aria.condition, bindings)) continue;
|
|
const value = resolveMorfoAriaValue(aria, bindings);
|
|
if (value !== undefined) {
|
|
result[aria.attr] = value;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|