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.
svelte-kit-vice/src/uix/morfo/selectors.ts

231 lines
8.8 KiB

/**
* 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'
import { partMarkerAttr } from './compile'
// ── Escaping ──────────────────────────────────────────────────────────────
/**
* Escape a value for interpolation inside a double-quoted CSS attribute
* selector (`[attr="value"]`). Backslashes and double quotes are the only
* characters that can break out of the quoted string; newlines are invalid
* raw in CSS strings, so they go to their escaped code-point form. Own,
* isomorphic implementation — `CSS.escape` is a DOM global this layer
* (SSR / node tests) can't assume (MOR-1, clean-room 2026-07-10: unescaped
* matchers produced selectors that THREW inside `target.matches()` during
* the sema cascade).
*/
function escapeAttrValue(value: string): string {
return value.replace(/[\\"]/g, (c) => `\\${c}`).replace(/\n/g, '\\a ')
}
/** Attr NAMES can't be quoted in a selector — enforce a dashed-ident shape. */
const ATTR_NAME_RE = /^[a-zA-Z][\w-]*$/
function assertAttrName(context: string, attr: string): void {
if (!ATTR_NAME_RE.test(attr)) {
throw new MorfoSelectorError(
`semaSelector: ${context}.attr "${attr}" is not a valid attribute name`
)
}
}
// ── Type extraction helpers ───────────────────────────────────────────────
/** Union of `kebab` literals declared on `morfo.parts`. */
export type PartKebabOf<M extends Morfo> = M['parts'][number]['kebab']
/** Union of `name` literals declared on `morfo.events`. `never` if events absent. */
export type EventNameOf<M extends Morfo> = M extends {
events?: readonly MorfoEvent[]
}
? NonNullable<M['events']>[number]['name']
: never
/**
* Union of keyboard `action` literals declared across `morfo.parts[].keyboard`.
* Distributive on purpose: part literals without a `keyboard` key have no such
* property in their `as const` type, so a plain indexed access would not
* compile — each part is matched structurally instead. `never` when no part
* declares keyboard. The `keyboard?:` in the matcher is what keeps the WIDENED
* `Morfo` resolving to `string` (its `keyboard` is optional on the interface;
* a required matcher made `ActionNameOf<Morfo>` collapse to `never` and every
* generic helper lost its handlers' contextual type).
*/
export type ActionNameOf<M extends Morfo> = M['parts'][number] extends infer P
? P extends { keyboard?: readonly { action: infer A extends string }[] | undefined }
? A
: never
: never
/**
* Union of event `name` literals that may STAMP on part `K`: the event's
* canonical `target` is `K`, or `K` appears in its `allowedTargets`. This is
* the type behind the part-anchored emission surface
* (`SomaRuntimePart.trigger`) — an event that cannot land on a part cannot be
* emitted from it, at compile time.
*
* `targetFallback` parts are NOT included on purpose: a fallback is the
* RUNTIME's mount-state resolution, never a caller's anchor — anchoring to a
* fallback part would let a provider force the degraded landing while the
* primary surface is mounted.
*/
export type EventNameTargeting<
M extends Morfo,
K extends PartKebabOf<M>
> = M extends { events?: readonly MorfoEvent[] }
? NonNullable<M['events']>[number] extends infer E
? E extends { name: infer N extends string; semantic: infer S }
?
| (S extends { target: { target: K } } ? N : never)
| (S extends { allowedTargets: readonly (infer R)[] }
? R extends { target: K }
? N
: never
: never)
: never
: never
: never
// ── Selector builder ──────────────────────────────────────────────────────
export interface SemaSelectorMatchers<M extends Morfo> {
/** 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<M>
/**
* 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 `<ancestor> <part-selector>`.
* 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<M extends Morfo>(
morfo: M,
partKebab: PartKebabOf<M>,
matchers?: SemaSelectorMatchers<M>
): 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)}"`
)
}
// The marker attr — ONE source with `compileMorfo` (partMarkerAttr), so
// the emission and the matcher can never drift apart (MOR-2).
const marker = partMarkerAttr(morfo.kebab, part.kebab)
const segments: string[] = [`[${marker}]`]
if (matchers?.eventFamily) {
segments.push(`[data-event-family="${escapeAttrValue(matchers.eventFamily)}"]`)
}
if (matchers?.eventIntent) {
segments.push(`[data-event-intent="${escapeAttrValue(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="${escapeAttrValue(String(matchers.eventName))}"]`)
}
if (matchers?.eventNamePrefix) {
segments.push(`[data-event^="${escapeAttrValue(matchers.eventNamePrefix)}"]`)
}
if (matchers?.state) {
assertAttrName('state', matchers.state.attr)
segments.push(`[${matchers.state.attr}="${escapeAttrValue(matchers.state.value)}"]`)
}
if (matchers?.aria) {
assertAttrName('aria', matchers.aria.attr)
segments.push(`[${matchers.aria.attr}="${escapeAttrValue(matchers.aria.value)}"]`)
}
if (matchers?.pseudo) {
segments.push(matchers.pseudo)
}
const compound = segments.join('')
return matchers?.ancestor ? `${matchers.ancestor} ${compound}` : compound
}

Powered by TurnKey Linux.