@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; }

neutron

Define typed custom elements with effect-based lifecycles — props, events, and compose without rewriting the Custom Elements boilerplate.

Neutron is the element factory of the Nucleus Stack: Neutron({ tag, props }) returns a builder you chain lifecycles onto, then define(). Every NucleusKit element is a Neutron element, and so is every element you write yourself.

Features

  • Declarative factory Neutron({ tag, props }).… .define()
  • Typed props Primitives and TokenList reflect to dashed attributes; objects / arrays / elements / promises stay on the instance
  • Effect returns Lifecycles / methods return a POJO (or an array of them) that sets props, emits, listens, calls methods, and styles
  • Fine-grained reactions onPropSet / Unset / Changed / onEffect
  • Events & broadcasts Tag-prefixed custom events, cancelable default actions, channel broadcasts
  • Commands onCommand("--verb") handles the HTML Command API — <button command commandfor> needs no custom element
  • Listener cleanup Listeners added through effects are removed on disconnect and restored on reconnect
  • Compose Combine builders (Neutron.compose) for mixin-style packages
  • Recompose Import a package's raw builder, add / remove lifecycles and methods, then define() it yourself
  • DevTools Neutron.attachDevtools() hooks the Nucleus DevTools extension

Installation

This package is available in the NucleusKit. Or it can be used by itself:

CDN Package Manager
<script src="https://unpkg.com/@excom/kit-utils@0.3.0/dist/index.umd.min.js"></script>
<script src="https://unpkg.com/@excom/neutron@0.2.0/dist/index.umd.min.js"></script>
npm install @excom/neutron
HTML Imports JS / CSS Imports
import { /* … */ } from "@excom/neutron";
Peer dependencies (0)

Packages a consumer must install alongside this one. Workspace deps are bundled.

Package Version
View Dist Files

All exports:

.
  • types: ./dist/index.d.ts
  • import: ./dist/index.js
  • default: ./dist/index.js
./index
  • types: ./dist/index.d.ts
  • import: ./dist/index.js
  • default: ./dist/index.js
./index.js
  • types: ./dist/index.d.ts
  • import: ./dist/index.js
  • default: ./dist/index.js
./index.min
  • types: ./dist/index.d.ts
  • import: ./dist/index.min.js
  • default: ./dist/index.min.js
./index.min.js
  • types: ./dist/index.d.ts
  • import: ./dist/index.min.js
  • default: ./dist/index.min.js
./index.umd.min
  • types: ./dist/index.d.ts
  • default: ./dist/index.umd.min.js
./index.umd.min.js
  • types: ./dist/index.d.ts
  • default: ./dist/index.umd.min.js

Usage

Beta disclaimer: Neutron automatically defines your element's Typescript types based upon your element config. This is done via complicated internal typing that has a few known issues. These issues will be resolved in the first stable release.

An element owns its own state (attributes) and announces changes (events). It never renders children or reaches into siblings — coordination belongs to Quark. The rules these examples follow are collected in Best Practices and Creating Elements.

import { Neutron } from "@excom/neutron";
​
export const PressTracker = Neutron({
  tag: "press-tracker",
  props: {
    pressCount: { type: Number, defaultValue: () => 0 }, // reflects ↔ `press-count`
  },
})
  .onEvent("click", ({ pressCount }) => ({
    // effects are declarative instructions, not imperative mutations
    pressCount: pressCount + 1,
    emit: ["press-tracker-press", { detail: { pressCount: pressCount + 1 } }],
  }));
​
PressTracker.define();
<press-tracker press-count="0">
  <button>Press</button>
</press-tracker>
<!-- `press-tracker[press-count="3"]` is now a CSS / Quark selector -->

Documentation

Defining elements

  • Props — typed props, reflection, TokenList, naming rules
  • Provision — the one property for published rich data
  • TypeScript — global element types, ConstructorType

Behavior

  • Lifecycles — onConnected & co., destructuring, async pitfalls
  • Effects — the object a handler returns
  • Prop reactions — onPropSet / Unset / Changed / onEffect
  • Methods — methods as effectors
  • Events — emits, default actions, broadcasts, listener cleanup
  • Commands — onCommand for --verb commands, the command effect
  • Promise props — onPromiseResolved / Rejected

Composition

  • Compose — stack builders into mixin-style packages
  • Recompose — edit a packaged element before defining it

Runtime

  • Define — define() and class introspection
  • Debug — DevTools hook, loop guard

Examples

State on connect

Neutron({ tag: "ready-flag", props: { isReady: Boolean } })
  .onConnected(() => ({ isReady: true, emit: ["ready-flag-ready"] }))
  .define();

Child element effect across handlers

Assign an element prop, then react to it with a nested effect. Listener callbacks that return effects must be defineMethods methods:

Neutron({
  tag: "focus-host",
  props: {
    inputEl: { type: HTMLInputElement, store: "weak" },
    isFocused: Boolean,
  },
})
  .defineMethods({
    handleFocus: () => ({ isFocused: true, emit: ["focus-host-focus"] }),
    handleBlur: () => ({ isFocused: false }),
  })
  .onConnected((el) => ({
    inputEl: el.querySelector("input"),
  }))
  .onPropSet("inputEl", ({ handleFocus, handleBlur }) => ({
    inputEl: {
      addListeners: [
        ["focus", handleFocus],
        ["blur", handleBlur],
      ],
    },
  }));
Release notes (4)

0.2.0

  • Add hydration of prerendered pages: an element takes its provision from the page's hydration island before it mounts, and deferred event defaults keep hydration open
  • Add no-ssr and Neutron({ ssr: false }) to keep elements out of a prerender: the attribute keeps every Neutron element on or inside its element unmounted until the browser, and the option does the same for one tag's own instances
  • Fix an issue where a value assigned to a declared prop before the element's definition loaded was dropped: it is now applied at upgrade
  • Fix an issue where Neutron.compose() lost reflectDefaultProps when a later builder left it out: an option a builder leaves out is inherited, and the last builder that states it wins
  • Fix an error thrown when a window or document listener fires after its element was removed and garbage-collected: the listener now does nothing
  • Fix an issue where a reflected lifecycle attribute (reflectDefaultProps) was read back: an is-mounted in markup or on a clone no longer runs onConnected on an element that is not mounted, and the attribute returns to the element's real state

0.1.3

  • Fix the published type declarations: index.d.ts pointed at a folder that is not in the tarball, so consumers got any for named re-exports and missing-member errors for export * instead of the real types

0.1.2

  • Fix an issue where an effect naming a property the element does not have was skipped silently under test runners: it throws NeutronError there too, as in a browser

0.1.1

  • Ship only dist (and declared extras) in the npm tarball; drop build logs, tests and sources
View Source

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.