Theme
Design your own
On this page (11)
Component Skill — components/math-editor/component-skill.md

Native basis

<textarea> in a .math-editor with its <label> - the value, the form participation and the validity are native; MathLive's <math-field> is the input surface.

Web Platform APIs

Constraint validationCustom elements::part()import()

Classes

.math-editor.math-editor-source.math-editor-tools.math-editor-field.math-editor-keyboard.math-editor-message

Upstream

mathlive@0.111.0Official editor (arnog/mathlive, MIT) - mathlive.min.mjs via jsDelivr, pinned; fonts from fonts/ beside itmeta name="math-editor-module"Self-host: point the loader at your copy of the same buildno sounds, no Compute EngineMathLive's keypress sounds and its built-in computation link are off

Accessibility

• The <label for> names the field; MathLive speaks the formula

• A failed submission sets aria-invalid, announces the message and focuses the field - in the first empty placeholder

• The toolbar's commands return focus to the field

§A formula field in a form

Type into the field, or use the toolbar: Fraction wraps what is before the caret. Submit with an empty placeholder left and the editor says what is missing. Reset restores the formula - the textarea is the form control.

§Readonly and disabled

The authored textarea names the starting state: readonly shows a formula that can be selected and copied, not edited (and is not validated); disabled is neither editable nor submitted. The toolbar follows.

§Given and locked

§The virtual keyboard

MathLive has one virtual keyboard per window. The keyboard command (or the keyboard-open state) opens it in the editor's own container, below the field, in the theme's colors and radius; when the second editor opens it, the first one closes its own.

§Two editors, one keyboard

§Following the formula

data-math-preview="<id>" keeps a Formula Rendering element in step with the editor (empty placeholders left out), as in the first example. Every edit also dispatches math-input (bubbles) with the TeX and whether it is complete; df$.shadcn.mathEditor.setValue(el, latex) sets the formula from code - an unchanged value keeps the caret and the undo history.

§Loading

The first editor on a page imports https://cdn.jsdelivr.net/npm/mathlive@0.111.0/mathlive.min.mjs - pinned, never @latest; its fonts load from the fonts/ folder beside it. To self-host, copy mathlive.min.mjs and fonts/ and point the component at the module:

<meta name="math-editor-module" content="/vendor/mathlive/mathlive.min.mjs" />

§States

Named states via the shared State API, bound on each editor:

  • default - editable (the field once MathLive loaded, else the textarea)
  • readonly - selectable and copyable, not editable
  • disabled - not editable, not focusable, not submitted
  • invalid - failed validation: aria-invalid and the message (config.message)
  • keyboard-open - editable, with the virtual keyboard open below the field

The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/math-editor-{state}.png.

Machine contract - verified against math-editor.schema.json by bun run verify:

StateTypeValuesDefaultDescription
readonlybooleantrue, falsefalseSelectable and copyable, not editable (the textarea's readonly).
disabledbooleantrue, falsefalseNot editable, not focusable, not submitted (the textarea's disabled).
invalidbooleantrue, falsefalseFailed validation: aria-invalid and an output role="alert" message.
keyboard-openbooleantrue, falsefalseEditable, with the virtual keyboard open in the editor's container.

§API

Generated from math-editor.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type MathEditorState = 'default' | 'readonly' | 'disabled' | 'invalid' | 'keyboard-open' - setState(name, config) takes the config of the state it names.

StateDescription
default
Editable - the MathLive field once it loaded (data-ready on the editor), else the textarea.
Config fieldTypeDescription
latex?stringthe formula as TeX (default: the textarea's text)
message?string'invalid': the message shown (default: "Enter a complete formula.")
readonly
Selectable and copyable, not editable (the textarea's readonly; not validated).
Config fieldTypeDescription
latex?stringthe formula as TeX (default: the textarea's text)
message?string'invalid': the message shown (default: "Enter a complete formula.")
disabled
Not editable, not focusable, not submitted (the textarea's disabled).
Config fieldTypeDescription
latex?stringthe formula as TeX (default: the textarea's text)
message?string'invalid': the message shown (default: "Enter a complete formula.")
invalid
Failed validation: aria-invalid on the textarea and the message under the field.
Config fieldTypeDescription
latex?stringthe formula as TeX (default: the textarea's text)
message?string'invalid': the message shown (default: "Enter a complete formula.")
keyboard-open
Editable, with the virtual keyboard open below the field.
Config fieldTypeDescription
latex?stringthe formula as TeX (default: the textarea's text)
message?string'invalid': the message shown (default: "Enter a complete formula.")

Every element

MemberDescription
el.api.setState<S extends MathEditorState>(name: S, config?: MathEditorStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?MathEditorStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: MathEditorState; config: MathEditorStateConfigs[MathEditorState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: MathEditorState; config: MathEditorStateConfigs[MathEditorState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: MathEditorState; config: MathEditorStateConfigs[MathEditorState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: MathEditorState; config: MathEditorStateConfigs[MathEditorState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: MathEditorState; config: MathEditorStateConfigs[MathEditorState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.mathEditorApi.setState<S extends MathEditorState>(el: HTMLElement, name: S, config?: MathEditorStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?MathEditorStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.mathEditorApi.getState(el: HTMLElement): { name: MathEditorState; config: MathEditorStateConfigs[MathEditorState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: MathEditorState; config: MathEditorStateConfigs[MathEditorState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.mathEditorApi.render(state: { name: MathEditorState; config: MathEditorStateConfigs[MathEditorState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: MathEditorState; config: MathEditorStateConfigs[MathEditorState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.mathEditorApi.store(el: HTMLElement): Store<{ name: MathEditorState; config: MathEditorStateConfigs[MathEditorState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: MathEditorState; config: MathEditorStateConfigs[MathEditorState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.mathEditorApi.commit<S extends MathEditorState>(el: HTMLElement, name: S, config?: MathEditorStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?MathEditorStateConfigs[S]its config
df$.shadcn.mathEditorStates: MathEditorState[]The declared states, 'default' first: default, readonly, disabled, invalid, keyboard-open.

df$.shadcn.mathEditor

MemberDescription
load(url?: string): Promise<MathliveLike>
Import the official MathLive ESM once. Without `url`: <meta name="math-editor-module">, else the pinned jsDelivr build. A failed import can be retried (the next call imports again).
ArgumentTypeDescription
url?stringa module URL to import instead (the same build, self-hosted - its fonts/ folder beside it)

Returns Promise<MathliveLike> - MathLive, imported once per URL

setValue(el: HTMLElement | string, latex: string): void
Set the formula (an unchanged value keeps the caret and the undo history). Reports `math-input`.
ArgumentTypeDescription
elHTMLElement | stringthe .math-editor element, or a selector for it
latexstringthe formula as TeX
command(el: HTMLElement | string, name: string): void
Run an editing command on the field - the toolbar's `data-math-command` buttons call it. Focus returns to the field; an editor that is readonly or disabled ignores it.
ArgumentTypeDescription
elHTMLElement | stringthe .math-editor element, or a selector for it
namestring'fraction', 'power', 'subscript', 'root' (wrap the selection), 'undo', 'redo' or 'keyboard' (toggle the virtual keyboard)
validate(el: HTMLElement | string): boolean
Check the formula like a form submission would: an invalid one enters 'invalid' with the message.
ArgumentTypeDescription
elHTMLElement | stringthe .math-editor element, or a selector for it

Returns boolean - true when the formula is valid (required and complete, no syntax error)

url: string
The pinned official MathLive build the component loads (never @latest); its fonts load relative to it.

Events

EventDescription
math-input
the formula changed - a preview, a form or a compute panel follows it

detail: MathInputDetail

FieldTypeDescription
latexstringthe formula as TeX (empty placeholders included)
completebooleanfalse while an empty placeholder is left

Types

TypeDescription
MathEditorConfig
An editor's state: every field optional in setState() (merged into the current one); getState() reports both.
FieldTypeDescription
latex?stringthe formula as TeX (default: the textarea's text)
message?string'invalid': the message shown (default: "Enter a complete formula.")
MathInputDetail
The `math-input` event's detail: the formula after an edit.
FieldTypeDescription
latexstringthe formula as TeX (empty placeholders included)
completebooleanfalse while an empty placeholder is left
MathliveLike
The part of the official MathLive module this component uses.
FieldTypeDescription
MathfieldElementtypeof MathfieldElementthe <math-field> element class (its statics configure fonts, sounds and the Compute Engine)
initVirtualKeyboardInCurrentBrowsingContext() => voidgive this browsing context its own virtual keyboard - in a frame MathLive otherwise proxies to the top window's

§CSS view file

/*
 * Formula Editing (math-editor): a MathLive <math-field> over a native
 * textarea. The field is themed through MathLive's documented custom
 * properties and ::part() hooks; its virtual keyboard through the keyboard's
 * documented --keyboard-* / --keycap-* properties. One private hook is pinned
 * to mathlive@0.111.0: .MLK__keycap's radius (MathLive hard-codes it).
 */
@layer components {
  .math-editor {
    display: grid;
    gap: 0.375rem;
    min-inline-size: 0;
    /* the textarea: the editor without JavaScript (or when MathLive cannot load) */
    & > .math-editor-source {
      inline-size: 100%;
      min-block-size: 2.5rem;
      field-sizing: content;
      padding: 0.5rem 0.75rem;
      border: 1px solid var(--input);
      border-radius: var(--radius-md);
      background: transparent;
      color: var(--foreground);
      font-family: var(--font-mono);
      font-size: 0.875rem;
      resize: vertical;
      &:focus-visible {
        outline: 2px solid var(--ring);
        outline-offset: 2px;
      }
    }
    /* MathLive took over: the textarea steps aside but stays the form control */
    &[data-ready] > .math-editor-source {
      display: none;
    }
    /* the field: an input of the system, math inside */
    & > .math-editor-field {
      display: block;
      inline-size: 100%;
      min-block-size: 2.75rem;
      box-sizing: border-box;
      padding: 0.25rem 0.75rem;
      border: 1px solid var(--input);
      border-radius: var(--radius-md);
      background: var(--background);
      color: var(--foreground);
      font-size: 1.25rem;
      --caret-color: var(--foreground);
      --primary-color: var(--primary);
      --selection-background-color: color-mix(in oklch, var(--ring) 28%, transparent);
      --selection-color: var(--foreground);
      --contains-highlight-background-color: var(--muted);
      --placeholder-color: var(--muted-foreground);
      --smart-fence-color: var(--muted-foreground);
      --latex-color: var(--muted-foreground);
      --text-font-family: var(--font-sans);
      --ui-font-family: var(--font-sans);
      transition:
        border-color 120ms ease,
        box-shadow 120ms ease;
      &:focus-within {
        border-color: var(--ring);
        box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 30%, transparent);
        outline: none;
      }
      /* the keyboard and the menu are the component's (toolbar, states), not MathLive's toggles */
      &::part(virtual-keyboard-toggle),
      &::part(menu-toggle) {
        display: none;
      }
    }
    /* the toolbar: data-math-command buttons */
    & > .math-editor-tools {
      display: flex;
      flex-wrap: wrap;
      gap: 0.25rem;
    }
    & > .math-editor-message {
      color: var(--destructive);
      font-size: 0.8125rem;
      line-height: 1.4;
    }
    /* states */
    &[data-state="invalid"] > .math-editor-field,
    &[data-state="invalid"] > .math-editor-source {
      border-color: var(--destructive);
      &:focus-within,
      &:focus-visible {
        box-shadow: 0 0 0 3px color-mix(in oklch, var(--destructive) 25%, transparent);
      }
    }
    &[data-state="readonly"] > .math-editor-field {
      background: var(--muted);
    }
    &[data-state="disabled"] > .math-editor-field,
    &[data-state="disabled"] > .math-editor-source {
      opacity: 0.5;
      cursor: not-allowed;
    }
    /* the virtual keyboard, leased into the editor (MathLive's documented properties) */
    & > .math-editor-keyboard {
      position: relative;
      /* in a container MathLive takes its backdrop padding out of the measured height - the bottom row was clipped */
      --keyboard-padding-top: 0px;
      --keyboard-padding-bottom: 0px;
      --keyboard-background: var(--muted);
      --keyboard-border: var(--border);
      --keyboard-accent-color: var(--primary);
      --keyboard-horizontal-rule: var(--border);
      --keyboard-toolbar-text: var(--muted-foreground);
      --keyboard-toolbar-text-active: var(--foreground);
      --keyboard-toolbar-background-hover: var(--accent);
      --keyboard-toolbar-background-selected: var(--background);
      --keycap-background: var(--background);
      --keycap-background-hover: var(--accent);
      --keycap-background-active: var(--primary);
      --keycap-background-pressed: var(--primary);
      --keycap-border: var(--border);
      --keycap-border-bottom: var(--border);
      --keycap-text: var(--foreground);
      --keycap-text-active: var(--primary-foreground);
      --keycap-text-pressed: var(--primary-foreground);
      --keycap-secondary-background: var(--secondary);
      --keycap-secondary-background-hover: var(--accent);
      --keycap-secondary-text: var(--secondary-foreground);
      --keycap-secondary-border: var(--border);
      --keycap-secondary-border-bottom: var(--border);
      --keycap-primary-background: var(--primary);
      --keycap-primary-background-hover: color-mix(in oklch, var(--primary) 88%, var(--background));
      --keycap-primary-text: var(--primary-foreground);
      border-radius: var(--radius-lg);
      overflow: hidden;
      &:empty {
        display: none;
      }
      /* private, pinned to mathlive@0.111.0 (tests/e2e/math/math-editor.e2e.ts reads it). MathLive injects its
         sheet unlayered, which beats any normal declaration in a layer - only !important wins from here */
      & .MLK__keycap {
        border-radius: var(--radius-sm) !important;
      }
    }
    @media (prefers-reduced-motion: reduce) {
      & > .math-editor-field {
        transition: none;
      }
    }
    @media (prefers-contrast: more) {
      & > .math-editor-field,
      & > .math-editor-source {
        border-color: var(--foreground);
      }
    }
    @media (forced-colors: active) {
      & > .math-editor-field {
        border-color: CanvasText;
        &:focus-within {
          outline: 2px solid Highlight;
        }
      }
      &[data-state="invalid"] > .math-editor-field {
        border-color: Mark;
      }
    }
    @media print {
      & > .math-editor-tools,
      & > .math-editor-keyboard {
        display: none;
      }
    }
  }
}

§JavaScript view file

// -- Formula Editing (math-editor) --------------------------------------------
// A thin adapter around the OFFICIAL MathLive editor (arnog/mathlive, MIT) -
// zero MathLive bytes ship here. The markup is a native <textarea> holding the
// formula's TeX: without JavaScript, or when MathLive cannot load, it IS the
// editor. With MathLive the textarea steps aside (CSS) and a <math-field>
// edits the formula; the textarea keeps its value, so it stays the form
// control - submission, reset, required and disabled are the browser's.
//
// - Lazy: a page without an editor never requests MathLive. The first editor
//   imports the pinned ESM build ONCE (MATHLIVE_URL, or <meta name=
//   "math-editor-module"> / df$.shadcn.mathEditor.load(url) to self-host);
//   its fonts load relative to the module. No sounds, no Compute Engine.
// - Validity is native: an empty placeholder or a syntax error sets the
//   textarea's custom validity; the browser's `invalid` event (a submit, or
//   validate()) puts the editor into 'invalid' with the message - typing never
//   flags an unfinished formula on its own.
// - One virtual keyboard per window (MathLive's): the editor in
//   'keyboard-open' leases it into its own container, themed from the tokens;
//   another editor taking it closes the first one's.
// - data-math-preview="<formula id>" keeps a Formula Rendering element
//   showing the formula as it is typed (through df$.shadcn.mathRenderer).
//
// State lives ON the element (data-state + the store); the bound `api` is the
// only state mutator (AGENTS.md "State API").
import { defussGlobals, defussQuery, componentState, bindComponent, mathText } from '../../../shared/state-api.js';
import type { MathfieldElement, VirtualKeyboardInterface } from 'mathlive';
const df$ = defussGlobals();
const dfDollar = defussQuery();
// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type
// this file's API docs state - arguments, return values, event details - holds against its code.
const mathEditorStates = ['default', 'readonly', 'disabled', 'invalid', 'keyboard-open'];
/** An editor's state: every field optional in setState() (merged into the current one); getState() reports both. */
export interface MathEditorConfig {
  /** the formula as TeX (default: the textarea's text) */
  latex?: string;
  /** 'invalid': the message shown (default: "Enter a complete formula.") */
  message?: string;
}
/** setState() configs per state. */
export interface MathEditorStateConfigs {
  /** Editable - the MathLive field once it loaded (data-ready on the editor), else the textarea. */
  default: MathEditorConfig;
  /** Selectable and copyable, not editable (the textarea's readonly; not validated). */
  readonly: MathEditorConfig;
  /** Not editable, not focusable, not submitted (the textarea's disabled). */
  disabled: MathEditorConfig;
  /** Failed validation: aria-invalid on the textarea and the message under the field. */
  invalid: MathEditorConfig;
  /** Editable, with the virtual keyboard open below the field. */
  'keyboard-open': MathEditorConfig;
}
/** The `math-input` event's detail: the formula after an edit. */
export interface MathInputDetail {
  /** the formula as TeX (empty placeholders included) */
  latex: string;
  /** false while an empty placeholder is left */
  complete: boolean;
}
/** The pinned official MathLive build the component loads (never @latest); its fonts load relative to it. */
export const MATHLIVE_URL = 'https://cdn.jsdelivr.net/npm/mathlive@0.111.0/mathlive.min.mjs';
/** The part of the official MathLive module this component uses. */
interface MathliveLike {
  /** the <math-field> element class (its statics configure fonts, sounds and the Compute Engine) */
  MathfieldElement: typeof MathfieldElement;
  /** give this browsing context its own virtual keyboard - in a frame MathLive otherwise proxies to the top window's */
  initVirtualKeyboardInCurrentBrowsingContext: () => void;
}
// -- loading ----------------------------------------------------------------
let mathlivePromise: Promise<MathliveLike> | null = null;
let mathliveLoaded = '';
const LOAD_MS = 15_000;
/**
 * Import the official MathLive ESM once. Without `url`: <meta name="math-editor-module">, else the pinned
 * jsDelivr build. A failed import can be retried (the next call imports again).
 * @param url - a module URL to import instead (the same build, self-hosted - its fonts/ folder beside it)
 * @returns MathLive, imported once per URL
 */
function load(url?: string): Promise<MathliveLike> {
  const mathliveUrl =
    url ||
    ((dfDollar('meta[name="math-editor-module"]').get(0) ?? null) as HTMLMetaElement | null)?.content ||
    MATHLIVE_URL;
  if (!mathlivePromise || mathliveUrl !== mathliveLoaded) {
    mathliveLoaded = mathliveUrl;
    // in a frame MathLive defines globalThis.mathVirtualKeyboard as a proxy that posts to the top window, with the
    // frame's own origin as target - an opaque-origin sandbox ('null') cannot post, and the first keyboard access
    // throws. MathLive skips that proxy when the name exists: a configurable placeholder, which its public
    // initVirtualKeyboardInCurrentBrowsingContext() replaces with a keyboard of this frame (shown in the editor).
    // VERIFIED: (tmp/probe-mathlive-frame.ts) plain import → "Invalid target origin 'null'"; placeholder + init →
    // the field mounts, the keyboard shows in its container
    const framed = globalThis.self !== globalThis.top;
    if (framed && !('mathVirtualKeyboard' in globalThis))
      Reflect.defineProperty(globalThis, 'mathVirtualKeyboard', {
        value: undefined,
        configurable: true,
        writable: true,
      });
    // the ONE vendor import (verify: VENDOR_IMPORTS) - the official editor, never our code
    // a microtask later: a failing import is a rejection (data-error), never an exception out of load()
    const pending = Promise.resolve()
      .then(() => import(/* @vite-ignore */ mathliveUrl))
      .then((m) => {
        const vendor = m as MathliveLike;
        // the fonts folder beside the module - MathLive's own default resolves to "<module>.mjs/fonts/" (404s)
        // VERIFIED: (tmp/probe-editor.ts) without this every KaTeX_* face request named mathlive.min.mjs/fonts/...
        vendor.MathfieldElement.fontsDirectory = new URL('fonts/', new URL(mathliveUrl, document.baseURI)).href;
        vendor.MathfieldElement.soundsDirectory = null;
        vendor.MathfieldElement.computeEngine = null;
        if (framed) vendor.initVirtualKeyboardInCurrentBrowsingContext();
        return vendor;
      });
    pending.catch(() => {
      if (mathlivePromise === pending) mathlivePromise = null;
    });
    mathlivePromise = pending;
  }
  let timer = 0;
  const deadline = new Promise<never>((_, reject) => {
    timer = setTimeout(
      () => reject(new Error(`MathLive did not load within ${LOAD_MS / 1000} s`)),
      LOAD_MS,
    ) as unknown as number;
  });
  return Promise.race([mathlivePromise, deadline]).finally(() => clearTimeout(timer));
}
// -- the parts ------------------------------------------------------------------
const sourceOf = (el: HTMLElement): HTMLTextAreaElement | null =>
  (dfDollar(el).children('textarea.math-editor-source').get(0) ?? null) as HTMLTextAreaElement | null;
/** the field once MathLive mounted it (data-ready) - before that its methods throw */
const fieldOf = (el: HTMLElement): MathfieldElement | null =>
  el.hasAttribute('data-ready')
    ? ((dfDollar(el).children('math-field.math-editor-field').get(0) ?? null) as MathfieldElement | null)
    : null;
const keyboard = (): VirtualKeyboardInterface | null =>
  (Reflect.get(globalThis, 'mathVirtualKeyboard') as VirtualKeyboardInterface | undefined) ?? null;
const DEFAULT_INVALID = 'Enter a complete formula.';
const INCOMPLETE = 'Fill in every placeholder of the formula.';
const SYNTAX = 'The formula has a syntax error.';
const EDITABLE = new Set(['default', 'invalid', 'keyboard-open']);
/** the label text that names the editor: the textarea's <label>, else the editor's aria-label */
function labelOf(el: HTMLElement, source: HTMLTextAreaElement): string {
  const label = source.id
    ? dfDollar(el)
        .find(`label[for="${CSS.escape(source.id)}"]`)
        .get(0)
    : null;
  return (label?.textContent ?? '').trim() || el.getAttribute('aria-label') || 'Formula';
}
// -- markup -------------------------------------------------------------------
/**
 * The markup of a state, on the live element and on render()'s detached copy: data-state, the textarea's
 * readonly / disabled / aria-invalid, the toolbar's disabled commands, and in 'invalid' the message. The
 * <math-field> and the keyboard are MathLive's (data-ce-chrome) and follow the textarea.
 */
function applyMarkup(el: HTMLElement, name: string, config: MathEditorConfig): void {
  dfDollar(el).attr('data-state', name === 'default' ? null : name);
  const source = sourceOf(el);
  if (source)
    dfDollar(source)
      .attr('readonly', name === 'readonly' ? '' : null)
      .attr('disabled', name === 'disabled' ? '' : null)
      .attr('aria-invalid', name === 'invalid' ? 'true' : null);
  dfDollar(el)
    .find('[data-math-command]')
    .each((_, b) => {
      dfDollar(b).attr('disabled', EDITABLE.has(name) ? null : '');
    });
  dfDollar(el).children('.math-editor-message').remove();
  if (name === 'invalid') {
    const out = dfDollar('<output class="math-editor-message" role="alert" data-ce-chrome></output>').text(
      config.message || DEFAULT_INVALID,
    );
    if (source?.id) out.attr('id', `${source.id}-message`);
    if (source) dfDollar(source).after(out);
    else dfDollar(el).append(out);
  }
}
// -- the virtual keyboard: one per window, leased by one editor at a time ---------
let keyboardOwner: HTMLElement | null = null;
function hostOf(el: HTMLElement): HTMLElement {
  let host = (dfDollar(el).children('.math-editor-keyboard').get(0) ?? null) as HTMLElement | null;
  if (!host) {
    host = document.createElement('div');
    host.className = 'math-editor-keyboard';
    host.setAttribute('data-ce-chrome', '');
    const tools = dfDollar(el).children('.math-editor-tools').get(0);
    if (tools) dfDollar(tools).before(host);
    else dfDollar(el).append(host);
  }
  return host;
}
/**
 * The container takes the keyboard's height: in a container other than <body> MathLive lays the keyboard
 * out at the container's bottom edge, so an auto-height container shows nothing. boundingRect and
 * `geometrychange` are MathLive's public geometry API.
 */
function fitHost(): void {
  const kb = keyboard();
  if (!kb || !keyboardOwner) return;
  hostOf(keyboardOwner).style.blockSize = `${Math.ceil(kb.boundingRect.height)}px`;
}
/** open the keyboard in this editor's container (the previous owner leaves 'keyboard-open') */
function lease(el: HTMLElement): void {
  const kb = keyboard();
  if (!kb || !fieldOf(el)) return;
  const previous = keyboardOwner;
  keyboardOwner = el;
  if (previous && previous !== el) {
    const old = dfDollar(previous).children('.math-editor-keyboard').get(0) as HTMLElement | undefined;
    if (old) old.style.blockSize = '';
    // a visible keyboard stays in its container when the container changes - hidden, it moves on show()
    kb.hide({ animate: false });
    if (previous.dataset.stateName === 'keyboard-open') void mathEditorApi.setState(previous, 'default');
  }
  if (!document.__mathEditorKeyboardInit) {
    document.__mathEditorKeyboardInit = true;
    // window.mathVirtualKeyboard is an EventTarget (MathLive's docs: listen for geometrychange on it); its type omits that
    (kb as unknown as EventTarget).addEventListener('geometrychange', fitHost);
  }
  kb.container = hostOf(el);
  kb.show({ animate: false });
  fitHost();
}
/** close the keyboard if this editor holds it */
function release(el: HTMLElement): void {
  if (keyboardOwner !== el) return;
  keyboardOwner = null;
  hostOf(el).style.blockSize = '';
  const kb = keyboard();
  if (!kb) return;
  kb.hide({ animate: false });
  kb.container = document.body;
}
// -- the live field -----------------------------------------------------------------
/**
 * Focus the field. MathLive's focus() does nothing while MathLive still believes the field has focus - and
 * after a click on a button outside the field it does (its blur bookkeeping misses that move), so its
 * keyboard sink (the part="keyboard-sink" element, pinned to mathlive@0.111.0) takes the focus directly.
 * VERIFIED: (tmp/probe-editor.ts) at the invalid event of a submit click hasFocus() was true, document focus
 * on the button, and focus(), blur() + focus() and a deferred focus() all left it there.
 */
function focusField(field: MathfieldElement): void {
  field.focus();
  if (document.activeElement === field) return;
  const sink = dfDollar(field.shadowRoot as unknown as HTMLElement)
    .find('[part="keyboard-sink"]')
    .get(0) as HTMLElement | undefined;
  sink?.focus();
}
/** the textarea's validity from the formula: an empty placeholder or a syntax error is a custom error */
function validity(el: HTMLElement, latex: string): boolean {
  const source = sourceOf(el);
  const field = fieldOf(el);
  const complete = !/\\placeholder\b/.test(latex);
  const message = !complete ? INCOMPLETE : field?.errors.length ? SYNTAX : '';
  source?.setCustomValidity(message);
  return complete;
}
/** after every edit: the textarea follows, the preview follows, `math-input` reports it */
function edited(el: HTMLElement): void {
  const field = fieldOf(el);
  const source = sourceOf(el);
  if (!field || !source) return;
  const latex = mathText(field.value);
  source.value = latex;
  const complete = validity(el, latex);
  preview(el, field.getValue('latex-without-placeholders'));
  const name = el.dataset.stateName || 'default';
  // a fixed formula leaves 'invalid' as soon as it is valid again - read the flag: checkValidity() would fire
  // `invalid` on every keystroke of a still unfinished formula, and its handler moves the caret
  if (name === 'invalid' && source.validity.valid) void mathEditorApi.setState(el, 'default', { latex, message: '' });
  else mathEditorApi.commit(el, name, { ...(mathEditorApi.getState(el).config as MathEditorConfig), latex });
  // the formula changed - a preview, a form or a compute panel follows it
  el.dispatchEvent(new CustomEvent<MathInputDetail>('math-input', { bubbles: true, detail: { latex, complete } }));
}
/** a linked Formula Rendering element shows the formula (placeholders left out) */
function preview(el: HTMLElement, latex: string): void {
  const ids = (el.dataset.mathPreview ?? '').split(/\s+/).filter(Boolean);
  const renderer = df$.mathRenderer;
  for (const id of ids) {
    const target = dfDollar(`#${CSS.escape(id)}`).get(0) as HTMLElement | undefined;
    if (target?.api && renderer) void renderer.setSource(target, { format: 'latex', text: latex });
  }
}
/** create the <math-field> once MathLive loaded; once it mounted, the textarea steps aside */
async function mount(el: HTMLElement): Promise<void> {
  const source = sourceOf(el);
  if (!source || dfDollar(el).children('math-field').get(0)) return;
  const vendor = await load();
  if (!el.isConnected || dfDollar(el).children('math-field').get(0)) return;
  const field = new vendor.MathfieldElement();
  field.className = 'math-editor-field';
  field.setAttribute('data-ce-chrome', '');
  field.setAttribute('aria-label', labelOf(el, source));
  if (source.id) field.setAttribute('aria-describedby', `${source.id}-message`);
  // MathLive configures a field only after its `mount` event (documented) - its methods throw before
  const mounted = new Promise<void>((resolve) => field.addEventListener('mount', () => resolve(), { once: true }));
  dfDollar(source).before(field);
  await mounted;
  if (!el.isConnected) return;
  field.mathVirtualKeyboardPolicy = 'manual';
  field.menuItems = [];
  field.setValue(source.value, { silenceNotifications: true });
  dfDollar(field).on('input', () => edited(el));
  dfDollar(el).attr('data-ready', '');
  // the state may have moved while MathLive loaded - the field takes the current one
  const name = el.dataset.stateName || 'default';
  field.readOnly = name === 'readonly';
  field.disabled = name === 'disabled';
  validity(el, source.value);
  if (name === 'keyboard-open') lease(el);
}
// -- states ---------------------------------------------------------------------
/** UI side of setState - the only function that switches an editor's state */
function triggerStateChange(el: HTMLElement, name: string, config: MathEditorConfig): void {
  const source = sourceOf(el);
  const field = fieldOf(el);
  if (typeof config.latex === 'string' && source) {
    const latex = mathText(config.latex);
    if (source.value !== latex) source.value = latex;
    // an unchanged value keeps the field, its caret and its undo history
    if (field && field.value !== latex) field.setValue(latex, { silenceNotifications: true });
    validity(el, latex);
  }
  applyMarkup(el, name, config);
  if (field) {
    field.readOnly = name === 'readonly';
    field.disabled = name === 'disabled';
    if (name === 'invalid') field.setAttribute('aria-invalid', 'true');
    else field.removeAttribute('aria-invalid');
  }
  if (name === 'keyboard-open') lease(el);
  else release(el);
}
/** Registry-level API; pass the element explicitly. Unknown names throw. */
export const mathEditorApi = componentState({
  component: 'math-editor',
  states: mathEditorStates,
  mergeConfig: true,
  apply: (el, state) => triggerStateChange(el as HTMLElement, state.name, state.config as MathEditorConfig),
  // reflect reality: the formula as typed
  read: (el, state) => ({
    name: (el as HTMLElement).dataset.stateName || state.name,
    config: { ...state.config, latex: sourceOf(el as HTMLElement)?.value ?? '' },
  }),
  markup: (el, state) => {
    applyMarkup(el as HTMLElement, state.name, state.config as MathEditorConfig);
    // a textarea's markup holds its value as text
    const source = sourceOf(el as HTMLElement);
    const latex = (state.config as MathEditorConfig).latex;
    if (source && typeof latex === 'string' && source.textContent !== latex) dfDollar(source).text(latex);
  },
});
// -- public API -------------------------------------------------------------------
const editor = (el: HTMLElement | string): HTMLElement => {
  const node = (typeof el === 'string' ? dfDollar(el).get(0) : el) as HTMLElement | undefined;
  if (!node?.matches?.('.math-editor') || !node.api) throw new Error('not an initialized .math-editor');
  return node;
};
/**
 * Set the formula (an unchanged value keeps the caret and the undo history). Reports `math-input`.
 * @param el - the .math-editor element, or a selector for it
 * @param latex - the formula as TeX
 */
function setValue(el: HTMLElement | string, latex: string): void {
  const node = editor(el);
  const name = node.dataset.stateName || 'default';
  void mathEditorApi.setState(node, name === 'invalid' ? 'default' : name, { latex: mathText(latex), message: '' });
  preview(node, latex);
  node.dispatchEvent(
    new CustomEvent<MathInputDetail>('math-input', {
      bubbles: true,
      detail: { latex, complete: !/\\placeholder\b/.test(latex) },
    }),
  );
}
const INSERTS: Record<string, string> = {
  fraction: '\\frac{#@}{#?}',
  power: '#@^{#?}',
  subscript: '#@_{#?}',
  root: '\\sqrt{#@}',
};
/**
 * Run an editing command on the field - the toolbar's `data-math-command` buttons call it. Focus returns to
 * the field; an editor that is readonly or disabled ignores it.
 * @param el - the .math-editor element, or a selector for it
 * @param name - 'fraction', 'power', 'subscript', 'root' (wrap the selection), 'undo', 'redo' or 'keyboard' (toggle the virtual keyboard)
 */
function command(el: HTMLElement | string, name: string): void {
  const node = editor(el);
  const field = fieldOf(node);
  const state = node.dataset.stateName || 'default';
  if (!field || !EDITABLE.has(state)) return;
  if (Object.hasOwn(INSERTS, name)) field.insert(INSERTS[name], { focus: true, selectionMode: 'placeholder' });
  else if (name === 'undo' || name === 'redo') field.executeCommand(name);
  else if (name === 'keyboard')
    void mathEditorApi.setState(node, state === 'keyboard-open' ? 'default' : 'keyboard-open');
  else throw new Error(`unknown editor command "${name}" (fraction, power, subscript, root, undo, redo, keyboard)`);
  focusField(field);
  if (name !== 'keyboard') edited(node);
}
/**
 * Check the formula like a form submission would: an invalid one enters 'invalid' with the message.
 * @param el - the .math-editor element, or a selector for it
 * @returns true when the formula is valid (required and complete, no syntax error)
 */
function validate(el: HTMLElement | string): boolean {
  const node = editor(el);
  return sourceOf(node)?.checkValidity() ?? true;
}
df$.mathEditorApi = mathEditorApi;
df$.mathEditorStates = mathEditorStates;
// public imperative API (AGENTS.md "No window globals": df$.shadcn.mathEditor)
df$.mathEditor = { load, setValue, command, validate, url: MATHLIVE_URL };
// -- document-level behavior ------------------------------------------------------
function bindDocument(): void {
  if (document.__mathEditorInit) return;
  document.__mathEditorInit = true;
  // the toolbar: data-math-command buttons inside an editor
  dfDollar(document).on('click', (e: Event) => {
    const button =
      e.target instanceof Element
        ? (dfDollar(e.target).closest('[data-math-command]').get(0) as HTMLElement | undefined)
        : undefined;
    const el = button ? (dfDollar(button).closest('.math-editor').get(0) as HTMLElement | undefined) : undefined;
    if (button && el?.api) command(el, button.dataset.mathCommand ?? '');
  });
  // a submit (or validate()) found the formula invalid: the editor says so - no native bubble on the hidden textarea
  document.addEventListener(
    'invalid',
    (e) => {
      const source =
        e.target instanceof HTMLTextAreaElement && e.target.matches('.math-editor > textarea.math-editor-source')
          ? e.target
          : null;
      const el = source?.parentElement;
      if (!source || !el?.api) return;
      e.preventDefault();
      void mathEditorApi.setState(el, 'invalid', { message: source.validationMessage || DEFAULT_INVALID });
      const field = fieldOf(el);
      if (!field) return;
      focusField(field);
      // an unfinished formula: the caret goes into the first empty placeholder - typing fills it
      if (/\\placeholder\b/.test(field.value)) {
        field.position = 0;
        field.executeCommand('moveToNextPlaceholder');
      }
    },
    true,
  );
  // a form reset restores the textarea's text; the field follows it
  document.addEventListener('reset', (e) => {
    const form = e.target instanceof HTMLFormElement ? e.target : null;
    if (!form) return;
    setTimeout(() => {
      (dfDollar(form).find('.math-editor').toArray() as HTMLElement[]).forEach((el) => {
        const source = sourceOf(el);
        const field = fieldOf(el);
        if (!source || !el.api) return;
        const name = el.dataset.stateName || 'default';
        void mathEditorApi.setState(el, name === 'invalid' || name === 'keyboard-open' ? 'default' : name, {
          latex: source.value,
          message: '',
        });
        field?.resetUndo();
        preview(el, source.value);
      });
    });
  });
}
// -- init ------------------------------------------------------------------------
function init(): void {
  if (keyboardOwner && !keyboardOwner.isConnected) release(keyboardOwner);
  (dfDollar('.math-editor:not([data-init])').toArray() as HTMLElement[]).forEach((el) => {
    el.dataset.init = '';
    const source = sourceOf(el);
    if (!source) return;
    bindDocument();
    // the authored textarea names the starting state: disabled, readonly, else editable
    const name = source.disabled ? 'disabled' : source.readOnly ? 'readonly' : 'default';
    // el.store + el.api (AGENTS.md "State through stores")
    bindComponent(el, mathEditorApi, { name, config: { latex: source.value, message: '' } });
    void mathEditorApi.setState(el, name, { latex: source.value, message: '' });
    mount(el).catch((err) => {
      // the textarea stays the editor - readable, editable, submitted
      dfDollar(el).attr('data-error', err instanceof Error ? err.message : String(err));
    });
  });
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub