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.
157 lines
5.6 KiB
157 lines
5.6 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'
|
|
|
|
// ── 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
|
|
|
|
// ── 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)}"`
|
|
)
|
|
}
|
|
|
|
// 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
|
|
}
|