/** * Typed selector builders that consume a Morfo's contract and emit * CSS selectors safe to feed to `target.matches(...)`. * * Why this exists: * * `sema/components/{name}.ts` packs (and any future eidos consumer that * builds selectors programmatically) used to write strings like * `'[data-dialog-content][data-event-family="commit"]'` by hand. That * couples the consumer to morfo's emitted attrs WITHOUT a compile-time * link — if the morfo renames the part `content` → `body`, the string * goes stale silently. Same for event names declared in the morfo. * * `semaSelector(morfo, partKebab, matchers?)` builds the selector from * the morfo's actual contract: * * - `partKebab` is typed against `morfo.parts[].kebab`. Compile error * if the part doesn't exist. * - `eventName` (when matched) is typed against `morfo.events[].name`. * - `eventFamily` / `eventIntent` are typed against the canonical * unions (`SemaFamily`, `Intent`). * * Output is a plain CSS selector string — `SemaCascadeRule` consumes it * unchanged via `target.matches()`. No runtime overhead beyond string * concatenation. * * Authors of `sema/components/*.ts` SHOULD use this helper for any * selector that targets a morfo-emitted attr. Hand-written strings are * a code smell and signal a contract drift. */ import type { Morfo, MorfoEvent } from './types' import type { Intent } from '../intent' import type { SemaFamily } from '../sema/types' import { MorfoSelectorError } from './errors' // ── Type extraction helpers ─────────────────────────────────────────────── /** Union of `kebab` literals declared on `morfo.parts`. */ export type PartKebabOf = M['parts'][number]['kebab'] /** Union of `name` literals declared on `morfo.events`. `never` if events absent. */ export type EventNameOf = M extends { events?: readonly MorfoEvent[] } ? NonNullable[number]['name'] : never // ── Selector builder ────────────────────────────────────────────────────── export interface SemaSelectorMatchers { /** Match against the data-event-family stamped during emit. */ eventFamily?: SemaFamily /** Match against the data-event-intent stamped during emit. */ eventIntent?: Intent /** * Match against the exact `data-event` stamped during emit. Typed * against the morfo's declared event names — compile error on * unknown names. */ eventName?: EventNameOf /** * Match an event by prefix (`[data-event^="close-"]`). String form * because TypeScript can't enforce prefix-substring relationships * across the morfo's event names; authors are responsible for * writing a real prefix. */ eventNamePrefix?: string /** * Match a state attr emitted by the morfo's `parts[].data` declaration * (`data-state`, `data-color`, `data-disabled`, `data-last-action`, * `data-nested`, …). For now `attr`/`value` are plain strings; a * future iteration will type them against the morfo's data contract. */ state?: { attr: string; value: string } /** * Match an aria attr declared by the morfo's `parts[].aria` (e.g. * `[role="alertdialog"]`). Plain strings for now. */ aria?: { attr: string; value: string } /** * Optional ancestor selector — output becomes ` `. * Useful for instance scoping (`'#delete-confirm-dialog'`) or context * scoping (`'[data-form]'`). Plain string by design — ancestors live * outside the morfo's contract. */ ancestor?: string /** * Append arbitrary CSS at the end of the selector (`:hover`, * `:focus-visible`, etc.). Escape hatch — use sparingly. */ pseudo?: string } /** * Build a CSS selector from a morfo's part contract + sema event tokens. * See module-level doc for the rationale. */ export function semaSelector( morfo: M, partKebab: PartKebabOf, matchers?: SemaSelectorMatchers ): string { const part = morfo.parts.find((p) => p.kebab === partKebab) if (!part) { throw new MorfoSelectorError( `semaSelector: morfo "${morfo.name}" has no part with kebab "${String(partKebab)}"` ) } // Marker is `data-{component}` for the provider archetype, otherwise // `data-{component}-{partKebab}`. Mirrors what `compileMorfo` emits. const marker = part.kebab === 'provider' ? `data-${morfo.kebab}` : `data-${morfo.kebab}-${part.kebab}` const segments: string[] = [`[${marker}]`] if (matchers?.eventFamily) { segments.push(`[data-event-family="${matchers.eventFamily}"]`) } if (matchers?.eventIntent) { segments.push(`[data-event-intent="${matchers.eventIntent}"]`) } if (matchers?.eventName) { const events = (morfo.events ?? []) as readonly MorfoEvent[] const known = events.some((e) => e.name === matchers.eventName) if (!known) { throw new MorfoSelectorError( `semaSelector: morfo "${morfo.name}" has no event named "${String(matchers.eventName)}"` ) } segments.push(`[data-event="${String(matchers.eventName)}"]`) } if (matchers?.eventNamePrefix) { segments.push(`[data-event^="${matchers.eventNamePrefix}"]`) } if (matchers?.state) { segments.push(`[${matchers.state.attr}="${matchers.state.value}"]`) } if (matchers?.aria) { segments.push(`[${matchers.aria.attr}="${matchers.aria.value}"]`) } if (matchers?.pseudo) { segments.push(matchers.pseudo) } const compound = segments.join('') return matchers?.ancestor ? `${matchers.ancestor} ${compound}` : compound }