@use "/shell" as *; @use "quark:list" as list; #release-notice { /* opens once: sheet re-runs restart a delay, so gate it on the fact it writes */ &:not([data-did-open]) { @delay 7000 { is-open: ""; data-did-open: ""; } } /* prevent clicks from bubbling to the sheet */ @on mouseup, click (stop-propagation); } provider-fetch[api-url*="package-metas/index.json"][is-success] { $package-indices: prop("provision").body; /* `excom.navGroup` moves a package out of its type's list, into that group */ $ungrouped: list.reject($package-indices.packages, "navGroup"); .package-links { [bind-elements] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "kit-element")); } [bind-element-bases] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "element-base")); } [bind-tools] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "tool")); } /* the standalone libraries: same rows, rendered flat (shell.css) */ [bind-libraries] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "library")); } [bind-library-group] ul:not(ul ul) { content: iterate(list.filter($package-indices.packages, "navGroup", "libraries")); } ul:not(ul ul) > li { $pkg: item.shortName; > spa-a { route-href: "/packages/#{item.shortName}"; } } /* one `content` rule per link: a second one would rewrite the first's text on every pass */ ul:not(ul ul, [bind-libraries] ul) > li > spa-a { content: item.shortName; } [bind-libraries] ul:not(ul ul) > li > spa-a { content: displayName(item.shortName); } /* packages whose docs span several pages: one collapsible group per section */ [bind-sections] { content: iterate(item.docSections); summary { content: item.title; } details > ul { content: iterate(item.docs); spa-a { route-href: "/packages/#{$pkg}/#{item.name}"; content: item.title; } } details:not(:has(spa-a[is-active])) { open: none; } } } /* page titles: the page, then the site. The docs home keeps the title in the document head; the other routes carry theirs in markup */ spa-route[is-active] { /* a site guide */ &[route-href$=":name"] { $title-guide: list.find($package-indices.docs, "name", $route.params.name); document-title: "#{$title-guide.title or $route.params.name} · Nucleus · docs"; } /* a package README */ &[route-href$=":packageName"] { document-title: "#{displayName($route.params.packageName)} · Nucleus · docs"; } /* one page of a package's docs */ &[route-href$=":docName"] { $title-package: list.find($package-indices.packages, "shortName", $route.params.packageName); document-title: "#{docTitle($title-package, $route.params.docName)} · #{displayName($route.params.packageName)} · Nucleus · docs"; } } /* the page's markdown file, for the footer link: the docs home and each guide the index lists, and a package's page whose index entry says `markdown` (build-docs-index sets it; the dev server's index has none, so no link there). siteDocHref and the path check say which URL is the page's own: the 404 and a package's doc pages have none */ > spa-manager[active-url] { $page-path: attr("active-url").split("#").at(0).split("?").at(0); $page-name: if($page-path == SITE_HOME: SITE_HOME_DOC; else: $page-path.split("/").at(-1)); $page-guide: list.find($package-indices.docs, "name", $page-name); $page-package: list.find($package-indices.packages, "shortName", $page-name); $page-markdown: if($page-guide and siteDocHref($page-guide.name) == $page-path: "/docs/#{$page-guide.name}.md"; $page-package.markdown and "#{SITE_BASE}/packages/#{$page-package.shortName}" == $page-path: "/#{$page-package.shortName}.md"); footer [bind-page-markdown] { href: $page-markdown; content: ternary($page-markdown, "Markdown version of this page"); } } } spa-route { $route: prop("provision"); /* paramless routes name their guide in markup (the docs home, `/`); /docs/:name gets it from params */ $route-doc-name: attr("data-doc-name"); } /* the desktop aside and the mobile sheet stamp the same nav template */ [data-site-nav] { details:has(spa-a[is-active]) { /* not kosher */ open: ""; } spa-a[is-active] { aria-current: ""; } spa-a:not([is-active]) { aria-current: none; } } /* mobile sheet: Escape / back gesture while open; a tapped link closes it */ #site-menu { &[is-open] dismiss-watcher { is-active: ""; } &:not([is-open]) dismiss-watcher { is-active: none; } @on click (target: "spa-a") { is-open: none; } } #site-menu-gesture { &:has(> #site-menu[is-open]) { progress-offset: 1; } &:not(:has(> #site-menu[is-open])) { progress-offset: 0; } @on gesture-handler-start { #site-menu { is-scrubbing: ""; } } @on gesture-handler-end { #site-menu { is-open: event.detail.snap == 1; is-scrubbing: none; } } } main { @on copy-source (handle: copySource); } #search-dialog[open] { > include-content { is-active: ""; /* for some reason this is necessary on first render */ @on include-content-did-render (handle: focusInput); } input[type="search"] { /* works on all subsequent opens */ autofocus: ""; } } #search-dialog:not([open]) input[type="search"] { autofocus: none; }

@on

@on wires listeners inside a rule: one or more event names, an options group that filters and configures the listener, and a block applied once per event.

form {
  @on submit (prevent-default) { is-submitted: ""; }
  @on input, change (debounce: 300) { data-draft: event.target.value; }
  @on keydown (key: "Escape", host: window) { is-open: none; }
}

The prelude says when: which events, which filters. The block says what: writes, @dispatch / @command, @delay. A JS listener is the handle: option; a name after the event list (@on click go;) is a parse error that points there.

Events

@on click, @on super-form-success, @on "my:event" — a bare name or a string. A comma list shares one listener and one block: @on input, change { … }. event.type tells them apart inside the block.

Blocks

@on <events> [(options)] { … } turns the event into a one-shot transaction: the block is an ordinary rule body, evaluated when the event fires instead of when the rule matches. Declarations write the matched element; nested rules write its matching descendants; event is the DOM event; @dispatch / @command statements fire after the writes are queued. It is how typed input, clicks and element events become State without JS:

:scope {
  $count: +attr("data-count");
  [bind-count] { content: $count; }
  @on counter-increment { data-count: $count + 1; }
  @on input {
    data-draft: event.target.value;
    #preview { content: event.target.value or preserve; }
  }
  @on submit (prevent-default) { is-submitted: ""; }
}

A nested rule whose selector starts with a sibling combinator writes the element's siblings instead of its descendants — :scope { button { @on click { + provider-fetch { @command --fetch; } } } } invokes --fetch on the provider-fetch right after the button.

Writes are batched like any rule's; $variables set in a block persist on the element. @on inside a block is not supported.

Options

The options group after the events is a map: name: value entries and bare flags, which mean true. With options the block is optional — @on submit (prevent-default); is a complete statement. These are the same filters <event-handler> offers, in the sheet:

ul {
  @on click (target: "li[data-id]") { data-selected: target.getAttribute("data-id"); }
  @on keydown (key: "Escape", host: window) { is-open: none; }
  @on input (debounce: 300) { data-query: event.target.value; }
  @on scroll (throttle: 100, passive) { data-is-scrolled: event.target.scrollTop > 0; }
  @on click (self, once, prevent-default);
}
Option Effect
target: "<selector>" Delegation: fires only when the event target is inside a descendant matching the selector; that element is target in the block and in the key / debounce / throttle options (event.target otherwise). With host: the selector is matched document-wide.
self Fires only when the event target is the matched element itself.
key: "Escape" / "Shift+K" Keyboard chord; space-separated tokens are alternatives ("Escape Enter"). Listed modifiers (shift, alt, ctrl, meta / cmd) must be held. The space bar is Space / Spacebar, the plus key plus ("Shift+Space").
prevent-default / stop-propagation / stop-immediate-propagation Act on the event as soon as it passes the filters, before any timing.
debounce: <ms> / throttle: <ms> Wait for a pause / run at most once per window (leading edge). Exclusive.
handle: fn A JS listener, for imperative DOM work Quark has no declaration for (moving focus once content renders): a function reference (focusInput), a list (a, b), or a call that returns the listener. Each is called with the event before the block, this being the element. prevent-default and stop-propagation are listeners too, for lists.
once Detach after the first event that passes the filters.
passive / capture Native addEventListener options.
host: window / host: document Register on the window / document while the element is in the document; the listener lets go on the first event after the element is removed.

When values are evaluated

target, key, debounce and throttle are expressions evaluated when the event fires, in the block's scope: event, target, element and the element's current $bindings are all in reach.

#list {
  $row: attr("data-row-selector");
  @on click (target: $row) { data-selected: target.getAttribute("data-id"); }
}

So nothing about a listener is reactive — a changed $row is read by the next click, and no re-run ever re-registers the DOM listener.

handle is evaluated when the event fires too, so it reads current $bindings, but event and target are not in its scope: the listener receives the event as its argument. A reference (handle: focusInput) is only looked up. A call (handle: focusFirst(":invalid")) runs on every event and must return the listener; when it returns anything else no listener runs, and a value of the wrong type logs handle: needs a function or a list of functions, got <typeof>.

#search include-content {
  @on include-content-did-render (handle: focusInput);
}

The flags, once and host configure the registration and are read once per match; they must be bare words.

Two @ons for one event may coexist when their options differ ((key: "Escape") and (key: "Enter")).

Outgoing events

@dispatch and @command inside the block send events and commands from it — see @dispatch / @command.

No @off

There is no @off: a listener you would remove is a listener that should not fire — gate it with options or with event data inside its block. Listeners persist like any other write (see No reversion).

Beta. The Nucleus Stack is in beta for a few weeks until features are stabilized and optimized.

Thanks — we'll email you when it ships.

Something went wrong. Please try again.