[]previous and next page

Events and forms

Events are native DOM events, not synthetic ones, so event.persist() is gone and event.target is typed as an Element.

For agentsUpdated

Events are native DOM events, not synthetic ones, so event.persist() is gone and event.target is typed as an Element. These events are delegated to one listener per event name on the document: click, dblclick, input, change, keydown, keyup, keypress, pointerdown, pointerup, pointermove, mousedown, mouseup, mousemove, focusin, focusout, beforeinput, touchstart, touchend, touchmove and contextmenu. Every other event (onSubmit, onFocus, onBlur, onScroll, onMouseEnter, ...) is a native listener on the element.

Handlers run in a batch: effects flush when the event finishes, so inside the handler the DOM still shows the old state.

React's event names keep React's meaning, so a React form ports unchanged:

  • onChange follows React. On a text input or a textarea it fires on every edit (the input event). On a select, a checkbox, a radio or a file input it fires when the choice is committed (the change event).
  • onchange (lower case) is the native change event. On a text input it fires when the value is committed (on blur or Enter). Use the React spelling for React code and the lower-case one for DOM code.
  • onFocus and onBlur bubble, as in React: they are the focusin and focusout events, so a wrapper sees its inputs get and lose focus. onfocus and onblur are the native, non-bubbling events.
  • onWheel is passive, so a handler cannot cancel scrolling, as in React. Every ...Capture spelling (onClickCapture, onFocusCapture) binds the capture phase.
  • The handler expression is evaluated once. onClick={cond() ? a : b} binds the handler that was chosen when the element was created. Branch inside the handler instead.
  • Two handlers for one event on one element both run. onInput and onChange on the same text input run in the order they are written.

The [handler, data] tuple form calls handler(data, event) and avoids a closure per row.

src/form.tsxtsx
import { useStore } from "@shadow-js/core";

export function Signup(props: { onSave: (value: { name: string; subscribed: boolean; plan: string }) => void }) {
  const [name, setName] = useStore("");
  const [subscribed, setSubscribed] = useStore(false);
  const [plan, setPlan] = useStore("free");

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        props.onSave({ name: name(), subscribed: subscribed(), plan: plan() });
        setName("");
      }}
    >
      <label>
        Name
        <input value={name()} onInput={(event) => setName(event.currentTarget.value)} />
      </label>
      <label>
        <input
          type="checkbox"
          checked={subscribed()}
          onChange={(event) => setSubscribed(event.currentTarget.checked)}
        />
        Subscribe
      </label>
      <select value={plan()} onChange={(event) => setPlan(event.currentTarget.value)} aria-label="Plan">
        <option value="free">Free</option>
        <option value="pro">Pro</option>
      </select>
      <button type="submit">Save</button>
    </form>
  );
}

A controlled input is value={signal()} plus onInput: writing the signal from anywhere updates the field, and typing updates the signal.

class, className, for and htmlFor#

The compiler accepts the React names as aliases, so existing markup compiles unchanged. In the type definitions, class and className are both props of every element, and for and htmlFor are props of label and output. Both spellings write the same attribute. class also takes arrays and objects (clsx style), so clsx and cn helpers are often unnecessary; classList toggles by key and style takes a string or an object in camelCase or kebab-case.

src/attributes.tsxtsx
export function Attributes() {
  return (
    <div
      class={["a", { b: true, c: 1, d: false }]}
      style={{ backgroundColor: "red", "margin-top": "4px" }}
    >
      <label htmlFor="x">X</label>
      <label for="y">Y</label>
      <input id="x" readOnly tabIndex={2} />
    </div>
  );
}

Other camelCase spellings, such as tabIndex and readOnly above, compile to the lower-case attribute. HTML attribute names are always the lower-case name (colSpan is colspan, httpEquiv is http-equiv), in the markup on the server as in the DOM. SVG follows the SVG names: a presentation attribute takes its hyphenated name (strokeWidth is stroke-width, fillOpacity is fill-opacity, stopColor is stop-color), xlinkHref is xlink:href, xmlLang is xml:lang, and names that SVG spells in camelCase keep their case (viewBox, gradientUnits). The React spelling and the SVG spelling both type-check.

dangerouslySetInnerHTML={{ __html }} is innerHTML: a property write of the markup, so use it for trusted HTML only. suppressHydrationWarning and suppressContentEditableWarning are accepted and ignored.

key on an element is accepted and ignored: list identity belongs to <For key={...}>. On a component it is passed as the component's key prop, which For and the head components (Style, Meta) read:

keys.tsxtsx
import { For } from "@shadow-js/web";

export function List(props: { items: { id: string; label: string }[] }) {
  return (
    <ul>
      <For each={props.items} key={(item) => item.id}>
        {(item) => <li key={item().id}>{item().label}</li>}
      </For>
    </ul>
  );
}
src/svg-icon.tsxtsx
export function Icon() {
  return (
    <svg viewBox="0 0 2 2">
      <path strokeWidth="2" fillOpacity={0.5} stopColor="red" />
      <use xlinkHref="#a" />
    </svg>
  );
}