Guide

Getting started

This guide adds @parallelogram-js/core to a server-rendered site: installing it, choosing import paths, starting the framework, registering components and loading their styles.

Install

npm install @parallelogram-js/core

The package is ESM only. Load it with import, or with await import() from CommonJS code. There is no require export condition.

Without a bundler

The package ships one file with everything in it, so a page can load the library straight from a CDN with no build step and a single request:

<script type="module">
  import { Parallelogram, Toggle } from 'https://cdn.jsdelivr.net/npm/@parallelogram-js/core';

  const app = Parallelogram.create();
  app.components.add('[data-toggle]', () => Toggle);
  app.run();
</script>

Pin the version once you have tested against one, as in @parallelogram-js/core@1.2.3. The URL above follows the newest release, which before 1.0 is how a minor breaks a page nobody touched. The bare specifier reaches the bundle through the package’s jsdelivr and unpkg fields; name dist/parallelogram.js yourself if you would rather be explicit.

The bundle carries every component whether the page uses one or not, so it is larger than what a bundler would produce for the same page: about 74 kB over the wire against a few kB for one or two components. In exchange there is no request waterfall, which is what the individual files give a browser that loads them directly -- each module names the next, so they arrive in rounds rather than at once.

The individual files are still there and still work without a bundler, at dist/index.js and dist/components/<Name>.js. Reach for them when a page uses one or two components and the extra requests cost less than the bytes.

It targets Baseline 2023: Chrome and Edge 120, Firefox 121, and Safari 17.2 on macOS and iOS, or later. It ships modern JavaScript without transpiling it, so a bundler only needs to resolve and bundle it. Older browsers aren't tested or supported. The versioning and security policy covers which releases may break things and which get fixes.

Import paths

Every path below works with or without .js, so @parallelogram-js/core/components/Toggle and @parallelogram-js/core/components/Toggle.js load the same file.

Path What it loads
@parallelogram-js/core The root entry, described below
@parallelogram-js/core/components/* One component, such as components/Toggle or components/PModal
@parallelogram-js/core/managers/* One manager, such as managers/RouterManager
@parallelogram-js/core/core/* One core class, such as core/BaseComponent or core/WebComponentLoader
@parallelogram-js/core/adapters/* A DeferTracker adapter, such as adapters/ga4
@parallelogram-js/core/styles The package stylesheet
@parallelogram-js/core/styles/*.css One component stylesheet, such as styles/toggle.css
@parallelogram-js/core/custom-elements.json The Custom Elements Manifest

The root entry exports Parallelogram, also as its default export, and ComponentRegistry, WebComponentLoader, DevLogger, BaseComponent, EventManager, RouterManager and PageManager.

import { Parallelogram, BaseComponent } from '@parallelogram-js/core';
import Toggle from '@parallelogram-js/core/components/Toggle';

Code that several paths use, such as BaseComponent, is built into shared files, so it loads once however many paths you import.

Development and production builds

Each path has two builds. The default build is minified, and a bundler that resolves the development export condition picks the development build instead. Vite resolves it during development. With esbuild, pass --conditions=development.

The production build differs from the development build in these ways:

  • The framework's own this.logger calls to debug(), log(), info(), group() and groupEnd() are removed. Calls to warn() and error() stay, because they report real problems.
  • debugger statements are removed and comments are dropped.
  • Names are minified, but class names are kept.

The production build has source maps that name the source files; the development build is readable as it is and has none. Because the production build has no debug calls, debug: true only shows the framework's debug output in the development build. Your own calls to app.logger.info() are not removed.

Start the framework

Create an instance with Parallelogram.create(), register components, then call run().

import { Parallelogram } from '@parallelogram-js/core';

const app = Parallelogram.create({
  router: {},
  pageManager: { containerSelector: '[data-view="main"]' },
});

app.components.add('[data-toggle]', () => import('@parallelogram-js/core/components/Toggle'));

await app.run();

Options

Parallelogram.create(config) takes these options. All are optional.

Option Type Default What it does
debug boolean false Shows debug, log, info and group output from app.logger. The framework's own debug calls only exist in the development build
silent boolean false Hides all logger output, including warnings and errors. Overrides debug
router object null Router options. The router is only created when this is set, so without it links load pages normally. See Pages and the router
pageManager object {} PageManager options, plus containerSelector. See Pages and the router for the options that affect navigation

pageManager.containerSelector names the page manager's container, body by default. Components mount in it, and it is watched for added elements, only when observeRoot isn't set. The framework sets observeRoot to document.body, so components mount anywhere in the body, including a header or footer outside the container, unless you pass your own observeRoot. The router finds fragments by their data-view names anywhere in the page, whatever the container is.

Two PageManager options affect loading on every page:

Option Type Default What it does
retryFailedLoads boolean true Retry an enhancement component whose module fails to load
maxRetryAttempts number 3 How many times to retry before giving up; ignored when retries are off

run() and init()

init() creates the logger, event bus, page manager and web component loader, starts loading the router when router is set, mounts components already on the page and starts watching it. It returns the instance. Calling it a second time logs a warning and does nothing else.

run() calls init() at the right moment. If the document has finished parsing, it calls init() straight away and returns a promise that resolves with the instance. Otherwise it waits for DOMContentLoaded first. Use run() unless you know the DOM is ready, for example in a script that runs after the markup.

The promise resolves once the framework has started, not once components have loaded. Component modules load in the background. With router set, the router's code loads on demand, so pages without it don't download it, and the promise resolves once the router has loaded and started. After calling init() directly, app.router stays null until then; router:initialized is emitted on the event bus when it starts. Links followed before that load pages normally.

destroy() and isInitialized

destroy() stops the web component loader, unmounts every enhancement component, removes the router's listeners and clears every event bus listener. It does nothing when the framework hasn't started. app.isInitialized is true between init() and destroy().

Instances

After init(), the instance exposes the parts it created. They are null before then.

Property What it is
app.eventBus The EventManager components and managers communicate through
app.router The RouterManager once it has loaded, or null when router isn't set
app.pageManager The PageManager, which mounts components through app.pageManager.host
app.logger The DevLogger, which prefixes messages with [parallelogram]
app.webComponentLoader The WebComponentLoader that loads web components
app.componentRegistry The array of enhancement component entries: name, selector, loader and their options

app.components exists from the start, so you can register components before run().

app.eventBus.on('page:component-load-error', ({ componentName, error }) => {
  app.logger.warn(`${componentName} did not load`, error);
});

Events and alerts describes the event bus.

Register components

app.components.add() takes a tag name or a selector, and a loader or an options object. It returns app.components, so calls chain.

app.components
  .add('p-modal', () => import('@parallelogram-js/core/components/PModal'))
  .add('[data-modal][data-modal-target]', () => import('@parallelogram-js/core/components/Modal'))
  .add('[data-toggle]', () => import('@parallelogram-js/core/components/Toggle'))
  .add('[data-tabs]', {
    loader: () => import('@parallelogram-js/core/components/Tabs'),
    priority: 'critical',
  });

Tag names and selectors

A valid custom element name, lowercase with a hyphen such as p-modal, registers a web component. Every other string is a CSS selector and registers an enhancement component, including plain element selectors such as form.

A loader returns the module, usually with a dynamic import(). It can also return the component class itself, or the module without a promise.

Enhancement component options

Option Type Default What it does
loader function Loads the component, when the second argument is an options object
name string The selector The name dependsOn lists and app.pageManager.instances use
priority 'critical' | 'normal' 'normal' Critical components mount before the others on every pass
dependsOn string[] Names of components that must load before this one's loader runs
exportName string The named export to use when the module has no default export

You can also pass the loader as the second argument and the options as the third.

Registering two enhancement components with the same name throws A component named "…" is already registered. Give components that share a selector different names. A dependsOn name that isn't registered logs a warning and is ignored. A component waits while its dependencies retry; if one fails for good, the component isn't loaded either, its elements get the component-error class, and page:component-load-error is emitted for it with the dependency's error as the cause. Components that depend on each other in a cycle throw when the entry that closes the cycle is registered.

priority: 'critical' orders mounting: critical components mount before the others on every pass. After a page swap the page manager mounts critical components first and waits mountDelay for the rest, but with Parallelogram.create() the whole body is watched, so components in the new content usually mount as soon as it is added. Web components don't take options other than loader.

When components load

Nothing downloads when you register a component. An enhancement component's loader runs the first time an element matching its selector is on the page, or is added to it later. The module loads once, and every matching element found while it loads mounts when it arrives, if the element is still on the page. Elements waiting for a module have the component-loading class.

When a loader fails, it is retried after 1 second, then 2, then 4, up to maxRetryAttempts. After the last attempt the waiting elements, and matching elements added later, get the component-error class and the event bus emits page:component-load-error. A module without a component class is not retried.

To try a component that failed for good again, for example once the connection is back, call app.pageManager.host.retry(name) with its registered name. It returns false unless that component failed. Otherwise it starts a fresh set of retries, retries any failed dependencies first, and matching elements on the page wait for the new load.

app.eventBus.on('page:component-load-error', ({ componentName }) => {
  window.addEventListener('online', () => app.pageManager.host.retry(componentName), {
    once: true,
  });
});

When the bundle itself never runs — a 404, a blocked request, a Content Security Policy that rejects it, an error thrown earlier in your own code — none of the above happens, because none of it is running. That is not the same as a loader failing, and it doesn't look the same: no element gets the component-error class, no page:component-load-error is emitted, and the stylesheets that hide content until a component mounts go on hiding it. A [data-tabs] container shows one panel, a closed [data-toggle] target stays hidden, and [data-reveal] content stays transparent, because each of those rules waits for a marker that only the framework writes. Content a visitor must be able to read should not be behind one of those rules, and a deploy is worth gating on the bundle actually loading.

Removing an element unmounts its component. Adding matching markup, whether through the router or your own script, mounts it.

Components added after run()

Components added after the framework has started are registered at once, and matching elements already on the page mount straight away.

await app.run();

app.components.add('[data-datatable]', () => import('@parallelogram-js/core/components/DataTable'));

Without the framework

An enhancement component doesn't need Parallelogram either. Each one has a static enhanceAll() that finds matching elements and mounts itself on them:

import Toggle from '@parallelogram-js/core/components/Toggle';

Toggle.enhanceAll();

It takes the selector to look for and the component's options, so Toggle.enhanceAll('[data-menu]', { capture: true }) mounts only that markup, and it returns the instance holding those elements. Call it again after adding markup to mount what's new.

What you give up is what the framework adds: a component's module loading only on the pages that use it, mounting again after a page swap, dependsOn ordering, and the shared logger and event bus. For a page with one or two enhancements and no router, that is a fair trade.

Accordion and SelectLoader are the exceptions. They are mounted by the framework only.

Web components

Web components don't need the framework. Importing a component module defines its element, and markup already on the page upgrades.

import '@parallelogram-js/core/components/PModal';

To load a web component only on pages that use it, register it with the framework:

app.components.add('p-modal', () => import('@parallelogram-js/core/components/PModal'));

The framework hands tag names to a WebComponentLoader. It loads a component when the page contains its tag, and watches the page for tags added later until every registered component has loaded. A loader whose module doesn't define the element counts as a failure, which is logged as an error.

WebComponentLoader also works on its own:

import { WebComponentLoader } from '@parallelogram-js/core';

const loader = new WebComponentLoader(
  { 'p-select': () => import('@parallelogram-js/core/components/PSelect') },
  { observeDOM: true }
);

loader.init();
Option Type Default What it does
eager boolean true Scan the page for registered tags on init()
observeDOM boolean false Watch for registered tags added later
rootElement Element document.documentElement The element to scan and watch
onLoad function Called with the tag name when a component loads
onError function Called with the tag name and error when a component fails
logger object Receives warnings and errors instead of the console

With eager: false, call loader.loadComponent('p-select') yourself, for example on the first click of a button.

Text and translation

Every component ships with English text: the Close on a modal's close button, Clear the selection on a select, Dismiss notification on a toast. Each is a named default on the class and an attribute on the element, so there are two places to change one.

A site translates once, before the elements are on the page:

import PModal from '@parallelogram-js/core/components/PModal';
import PSelect from '@parallelogram-js/core/components/PSelect';

PModal.defaults.closeLabel = 'Fermer';
Object.assign(PSelect.defaults, { placeholder: 'Choisir…', clearLabel: 'Effacer la sélection' });

A page changes one instance with the attribute, which wins over the default:

<p-modal close-label="Fermer sans enregistrer"></p-modal>

Enhancements work the same way through defaults and their data-* options: DataTable.defaults.sortStatus is the sort every table reports, data-datatable-sort-status the one this table does. Text with {name} placeholders keeps them in whatever order the sentence needs, which is what makes a translation possible at all.

Nothing is translated for you, on purpose: a bundled translation that could not be checked would look finished and be wrong, and the French above is an example of the shape rather than a translation to ship. Dates, times, month and weekday names come from the browser's own Intl and are already in every language: <p-datetime> formats them in the language of the nearest lang, and a language without a region keeps the visitor's own conventions, so <html lang="en"> shows an Australian 15 Jan 2024 and lang="en-US" shows everyone Jan 15. Each component's page lists its text under Attributes, with the English beside it.

Styles

Web components style themselves. Their styles are bundled into each module and adopted in the shadow root, so <p-modal> looks right without a stylesheet. Adjust them with the custom properties and shadow parts each component's page lists, such as --modal-panel-bg and p-modal::part(panel).

p-modal {
  --modal-panel-bg: #fffdf7;
  --modal-radius: 0.25rem;
}

p-modal::part(panel)::backdrop {
  background: rgb(0 0 0 / 0.6);
}

Enhancement components that need styles use a document stylesheet. Import the package stylesheet, or only the ones your pages need. A bundler that resolves package paths in CSS, such as Vite, handles these imports.

@import '@parallelogram-js/core/styles';
@import '@parallelogram-js/core/styles/toggle.css';
@import '@parallelogram-js/core/styles/tabs.css';

The component stylesheets are accordion.css, datatable.css, lazysrc.css, lightbox.css, reveal.css, tabs.css, toasts.css and toggle.css. The package stylesheet contains all of them, focus outlines for framework components, and the design tokens below. Web components inherit these tokens through their shadow roots and carry their own fallbacks, so they render without them.

Design tokens

The package stylesheet declares these custom properties on :root. Set them in your own :root rule, after the package stylesheet, to change every component that reads them. The button family styles <p-uploader>'s buttons, <p-datetime>'s action buttons and quick dates, <p-modal>'s slotted .btn buttons and DataTable's pagination; the panel family sets the padding, shadow and radius of <p-modal>'s panel, header and footer, <p-datetime>'s popover and <p-toasts>' toasts. Each component's own properties, such as --modal-btn-bg, still override the family for that component alone.

Properties What they set
--surface-<surface>-radius, -border-width, -border-color, -color-bg, -color-text, -shadow Each surface: control for form fields, button, panel, dialog, dropdown, item, card and touch. Buttons have only the radius, border width and shadow; a surface declares only the properties a component reads, and the design system page lists them
--surface-dropdown-item-hover-bg, -selected-bg, -current-bg Dropdown option states
--form-control-* Form field padding and font size, with -sm and -lg variants, font family, placeholder colour, focus ring width and colour, focus and hover border colours, and disabled opacity and background. Borders, background and text colour follow the control surface
--button-* Button padding, font size and minimum height, with -sm and -lg variants, font weight, and --button-<variant>-bg, -color, -border, -hover-bg and -hover-border for primary, secondary, danger and ghost
--panel-* Panel padding, with -sm and -lg variants, and header and footer padding and borders. Background, border, radius and shadow follow the panel surface
--framework-focus-color, --framework-focus-width, --framework-focus-offset Focus outlines on framework components
--framework-transition-duration, --framework-transition-easing How focus outlines transition

Colour tokens and dark mode

Colours come from a small set of roles, and the surface, form control, button and panel properties above read them. Set a role to change everything that uses it, or a surface property to change one surface.

Role What it colours
--color-accent, --color-accent-hover Primary buttons, focus rings and focused field borders, and selected options
--color-accent-contrast Text on the accent
--color-text Text on surfaces: the page's own colour in light mode, a light grey in dark
--color-text-muted Placeholders and secondary text
--color-surface, --color-surface-muted Backgrounds of fields, panels, dialogs and dropdowns, and quieter areas
--color-hover Tint behind hovered items
--color-border, --color-border-strong Borders, and borders that need to stand out, such as cards and buttons
--color-control-border Form field borders, which need 3:1 contrast to be seen
--color-overlay, --color-shadow Modal backdrops and shadows
--color-danger, --color-success, --color-warning, each with a -bg Status colours and their backgrounds
--color-danger-contrast Text on a danger fill, such as the danger button
--color-inverse-surface, --color-inverse-text Info toasts, which stand out from the page as a darker surface in both themes
--color-success-strong, --color-warning-strong, --color-danger-strong Solid status fills, such as success, warning and error toasts
--color-on-status Text on the solid status fills

In light mode --color-text is currentColor, so surfaces keep inheriting the page's text colour and nothing changes for a page that sets its own. The dark theme gives it a real value, so panels, dialogs and fields carry readable text even on a page that has no dark styles of its own.

The dark theme redefines only the roles. It applies when the operating system prefers a dark colour scheme, and data-theme on <html> forces either theme: data-theme="dark" always uses it, and data-theme="light" never does. Set roles for the dark theme in the same places.

:root {
  --color-accent: #7c3aed;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) {
    --color-accent: #a78bfa;
  }
}

:root[data-theme='dark'] {
  --color-accent: #a78bfa;
}

Set roles on :root. The surface properties read them there, so a role set on an element further down the page doesn't reach them.

The stylesheet sets color-scheme to match the theme, so the browser's own canvas, text and form controls follow it. A page that wants to decide this itself can set color-scheme after the stylesheet. The stylesheet still doesn't set the page's background and text colours, so style the page to match the theme, or let the browser's own colours show through.

Properties for one component, such as --modal-panel-bg or --toggle-transition-duration, are listed under CSS custom properties on that component's page.

Toggle's stylesheet hides closed targets and animates opening and closing, and Toggle waits for those animations. A target the markup marks data-toggle-state="closed" is hidden before Toggle mounts, but only while scripts are enabled. Tabs' stylesheet shows only the first panel until Tabs mounts, or the panel the markup marks data-tab-panel="active", and Scrollreveal's hides its elements until Scrollreveal mounts. Both show the content again if the component fails to load, but not if it was never registered. Load them for pages that use those components, and register the components wherever their stylesheets are used.

A complete page

This page has an account menu outside the swapped container, and tabs, a toggle and a modal inside it.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Bookings</title>
    <script type="module" src="/assets/app.js"></script>
  </head>
  <body>
    <header>
      <button type="button" data-toggle data-toggle-target="#account-menu" data-toggle-capture>
        Account
      </button>
      <nav id="account-menu">
        <a href="/bookings">Bookings</a>
        <a href="/sign-out">Sign out</a>
      </nav>
    </header>

    <main data-view="main">
      <h1>Bookings</h1>

      <div data-tabs>
        <div data-tabs-list>
          <a href="#upcoming" data-tab="upcoming">Upcoming</a>
          <a href="#past" data-tab="past">Past</a>
        </div>
        <div data-tabs-panels>
          <section id="upcoming" data-tab-panel>
            <h2>Upcoming</h2>
            <p>Table for four, 2 October at 7pm.</p>
            <button type="button" data-modal data-modal-target="#cancel-booking">
              Cancel booking
            </button>
          </section>
          <section id="past" data-tab-panel>
            <h2>Past</h2>
            <p>Table for two, 14 August at 8pm.</p>
          </section>
        </div>
      </div>

      <button type="button" data-toggle data-toggle-target="#refunds">Can I get a refund?</button>
      <div id="refunds">
        <p>Yes, up to 48 hours before your booking.</p>
      </div>

      <p-modal id="cancel-booking" data-modal-size="sm">
        <h2 slot="title">Cancel this booking?</h2>
        <p>Your table for four on 2 October will be released.</p>
        <div slot="actions">
          <button type="button" data-modal-close>Keep booking</button>
          <button type="button" data-modal-close>Cancel booking</button>
        </div>
      </p-modal>
    </main>
  </body>
</html>

/assets/app.js is the bundled output of this script:

import { Parallelogram } from '@parallelogram-js/core';
import './app.css';

const app = Parallelogram.create({
  router: {},
  pageManager: { containerSelector: '[data-view="main"]' },
});

app.components
  .add('p-modal', () => import('@parallelogram-js/core/components/PModal'))
  .add('[data-modal][data-modal-target]', () => import('@parallelogram-js/core/components/Modal'))
  .add('[data-toggle]', () => import('@parallelogram-js/core/components/Toggle'))
  .add('[data-tabs]', () => import('@parallelogram-js/core/components/Tabs'));

app.run();

And app.css imports the stylesheets the page uses:

@import '@parallelogram-js/core/styles/toggle.css';
@import '@parallelogram-js/core/styles/tabs.css';

Module scripts are deferred, so run() starts the framework once the document has been parsed. The Modal component opens the <p-modal> from its trigger, so register both. Without JavaScript, the tab links jump to their sections and the page still reads in order.

Next steps