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.
309 lines
12 KiB
309 lines
12 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 `{ attr, value }` pairs the morfo DECLARES across its parts' `data`
|
|
* contracts: entries with `values` contribute one pair per enum member, entries
|
|
* without contribute `{ attr, value: string }` (presence flags and `emit:
|
|
* 'value'` attrs carry arbitrary values). This is the type behind the `state`
|
|
* matcher — a typo in a declared value stops compiling, which is the drift
|
|
* class `semaSelector` exists to kill.
|
|
*
|
|
* The union spans ALL parts (the matcher interface carries one generic); the
|
|
* per-part strictness lives in the builder's runtime check, the same split
|
|
* `eventName` already has (typed against all events, runtime-validated).
|
|
* On the WIDENED `Morfo` interface it degrades to `{ attr: string; value:
|
|
* string }`, keeping generic helpers compiling — same clause as
|
|
* `ActionNameOf`.
|
|
*/
|
|
export type DataPairOf<M extends Morfo> = M['parts'][number]['data'][number] extends infer D
|
|
? D extends { attr: infer A extends string; values: readonly (infer V extends string)[] }
|
|
? { attr: A; value: V }
|
|
: D extends { attr: infer A extends string }
|
|
? { attr: A; value: string }
|
|
: 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 the morfo's `parts[].data` DECLARES (`data-state`,
|
|
* `data-last-action`, …). Typed against {@link DataPairOf}: the attr must
|
|
* be declared and, where it carries an enum, the value must be one of its
|
|
* members — a renamed value breaks the pack at compile time (M5). The
|
|
* builder re-checks both against the SELECTED part at runtime.
|
|
*
|
|
* For an attr the morfo deliberately does NOT declare, use
|
|
* {@link SemaSelectorMatchers.undeclaredState} — one open slot here would
|
|
* swallow the typed one (the S-11 class).
|
|
*/
|
|
state?: DataPairOf<M>
|
|
|
|
/**
|
|
* Match an attr OUTSIDE the morfo contract — the attrs a component's
|
|
* visual wrapper stamps by design and the morfo deliberately does not
|
|
* declare (dialog's `data-size` / `data-sheet`; eidos-only concerns).
|
|
* The two doors are disjoint on purpose, the framework's two-axis
|
|
* precedent (`allowedTargets`/`targetFallback`): a rule through this one
|
|
* SAYS it is matching outside the contract, and the builder throws if the
|
|
* attr turns out to be declared — a declared attr belongs in `state`,
|
|
* where its contract bites.
|
|
*/
|
|
undeclaredState?: { attr: string; value: string }
|
|
|
|
/**
|
|
* Match an aria attr (e.g. `[role="alertdialog"]`). Deliberately open —
|
|
* NOT typed against `parts[].aria`: the one aria matcher in the framework
|
|
* targets dialog's `role`, which its morfo deliberately does not declare
|
|
* («variant-dependent — the provider sets it from `opts.variant`»). Typing
|
|
* this against the declared aria entries would leave zero call sites
|
|
* covered and outlaw the legitimate one.
|
|
*/
|
|
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) {
|
|
// One structural read — inside the generic body `DataPairOf<M>` is a
|
|
// deferred conditional and TS refuses member access on it; the runtime
|
|
// only needs the shape.
|
|
const st = matchers.state as { attr: string; value: string }
|
|
assertAttrName('state', st.attr)
|
|
// The runtime half of the M5 gate — per-PART strict, where the type is
|
|
// morfo-wide: the attr must be declared by the selected part, and a
|
|
// declared enum bites. Packs are module-level, so a violation throws at
|
|
// import and no suite stays green over it.
|
|
const decl = part.data.find((d) => d.attr === st.attr)
|
|
if (!decl) {
|
|
throw new MorfoSelectorError(
|
|
`semaSelector: part "${String(partKebab)}" of "${morfo.name}" declares no data attr ` +
|
|
`"${st.attr}" — an attr outside the morfo contract goes through \`undeclaredState\``
|
|
)
|
|
}
|
|
if (decl.values && !(decl.values as readonly string[]).includes(st.value)) {
|
|
throw new MorfoSelectorError(
|
|
`semaSelector: "${st.attr}" on part "${String(partKebab)}" declares ` +
|
|
`values [${decl.values.join(', ')}] — "${st.value}" is not one of them`
|
|
)
|
|
}
|
|
segments.push(`[${st.attr}="${escapeAttrValue(st.value)}"]`)
|
|
}
|
|
if (matchers?.undeclaredState) {
|
|
assertAttrName('undeclaredState', matchers.undeclaredState.attr)
|
|
if (part.data.some((d) => d.attr === matchers.undeclaredState!.attr)) {
|
|
throw new MorfoSelectorError(
|
|
`semaSelector: "${matchers.undeclaredState.attr}" IS declared by part ` +
|
|
`"${String(partKebab)}" of "${morfo.name}" — a declared attr goes through \`state\`, ` +
|
|
`where its contract bites`
|
|
)
|
|
}
|
|
segments.push(
|
|
`[${matchers.undeclaredState.attr}="${escapeAttrValue(matchers.undeclaredState.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
|
|
}
|