إنتقل إلى المحتوى الرئيسي

JavaScript helpers

Generated by scripts/build.mjs from js/: each helper as its own source describes it, and its types. They're optional: plain ES modules with no dependencies, one for each behaviour the CSS can't give. Import the ones you need, from the same version as the CSS:

HTML
<script type="module">
import { initTabs } from "https://design.arabesquelabs.com/v/0.9.0/js/tabs.js";
initTabs();
</script>

The React components (dist/react/) call them for you (React).

announcement​

Announcements: a [data-dismiss] button hides its .al-announcement and remembers it (localStorage, by data-announcement), so it stays gone on every page; focus moves on to what came after it. Where storage isn't allowed, it's gone for this page only.

Used by: Phase banner. Source: js/announcement.js.

TypeScript
/** Hide the announcements already dismissed, and wire each [data-dismiss] button inside root. */
export function initAnnouncements(root?: ParentNode): void;
/** Show an announcement again on the next load. */
export function forgetDismissal(id: string): void;

back-to-top​

Back to top: hidden until the page has scrolled two screens, then shown. Followed, it goes to the top and moves focus there too, so the next Tab is the page's first link (its skip link), not the one after this.

Used by: Back to top. Source: js/back-to-top.js.

TypeScript
/** Wire every .al-back-to-top inside root: hidden until the page has scrolled two screens; followed, it moves focus to the top. */
export function initBackToTop(root?: ParentNode): void;

chart​

A chart's marks from the keyboard: one tab stop for the whole chart, and the arrow keys move from mark to mark (along the reading direction, so reversed in right-to-left); Home and End jump to the ends. Each mark is role="img" with its value and what it is as its name, so focusing one says it; the tooltip shows the same.

initChartKeys(chartElement);

Used by: Charts. Source: js/chart.js.

TypeScript
/** One tab stop for a chart; the arrow keys, Home and End move between its marks. */
export function initChartKeys(chart: HTMLElement, options?: { marks?: string }): void;

copy​

Copy buttons: a [data-copy] button copies the text of the element it names (aria-controls), or else of its code block or command (the panel that's showing, when there are tabs). Then it says so: its label reads "Copied" for a moment, and the block's status region tells screen readers. If the clipboard is refused, the text is selected instead, for the person to copy themselves.

Used by: Code and command. Source: js/copy.js.

TypeScript
/** Wire every [data-copy] button inside root: it copies its code block's, command's or aria-controls target's text, and says so. */
export function initCopy(root?: ParentNode): void;

count​

Character count, the GOV.UK way: what's left, said in words, and how far over once it's over; typing past the limit is allowed (a paste isn't cut), and the message turns into an error. The visible message updates at once; screen readers hear it after a pause, through a polite region.

initCharacterCounts(document, { words: (left) => left >= 0 ? `You have ${left} characters remaining` : `You have ${-left} characters too many`, limit: (max) => `You can enter up to ${max} characters`, });

Over the limit, the field is marked aria-invalid="true"; the mark is removed again only if this helper set it, so validate.js's stays.

Used by: Character count. Source: js/count.js.

TypeScript
export interface CountOptions {
/** The message for what's left (left ≥ 0) or how far over (left < 0). */
words?: (left: number) => string;
/** The static description of the limit, read with the field. */
limit?: (max: number) => string;
}
/** Character counts for every [data-al-maxlength] under root; returns a function that redraws them. */
export function initCharacterCounts(root?: ParentNode, options?: CountOptions): () => void;

datepicker​

Date picker: a text field for a date near today, with a calendar to choose it from (the WAI-ARIA APG date picker dialog). Unlike the browser's , it speaks the page's language: month and day names, the order of the week, the digits people chose (Western or Arabic-Indic) and, in right to left, a grid that runs from the right. The date can always be typed instead, in either digit set; it's kept as an ISO date (2026-11-30) in a hidden field that takes the input's name. Gregorian only.

For example, 30/11/2026
const [due] = initDatePickers(document, { numerals: () => chosenDigits }); due.value; // "2026-11-30"

In the calendar: arrow keys move by day and week (along the reading direction), Home and End to the week's ends, Page Up and Page Down by month (with Shift, by year), Enter or Space chooses, Escape closes; focus goes back to the button. data-min and data-max (ISO) on the input limit what can be chosen.

Used by: Date picker. Source: js/datepicker.js.

TypeScript
import type { Numerals } from "./format.js";

/** A date-only value: "YYYY-MM-DD". */
export type IsoDate = string;
type Live<T> = T | (() => T);

export interface DatePickerLabels {
/** The previous-month button's name; default "Previous month". */
previous?: string;
/** The next-month button's name; default "Next month". */
next?: string;
}
export interface DatePickerOptions {
/** Labels in the page's language; a function is read each time it draws. */
labels?: Live<DatePickerLabels>;
/** Western ("latn", the default) or Arabic-Indic ("arab") digits. */
numerals?: Live<Numerals>;
/** The language; by default the input's nearest lang. */
lang?: Live<string>;
}
export interface DatePicker {
readonly input: HTMLInputElement;
/** The chosen date, or null if none (or what's typed isn't a date). */
value: IsoDate | null;
open(): void;
close(): void;
/** Draw again in the current language and digits. */
refresh(): void;
/** Take the picker away; the input keeps its value and name. */
destroy(): void;
}

/** Every input[data-al-date-picker] under root becomes a date picker. */
export function initDatePickers(root?: ParentNode, options?: DatePickerOptions): DatePicker[];
/** A typed date, day first, in either digit set; null if it isn't a date that exists. */
export function parseDate(text: string): IsoDate | null;
/** A date as it's written in the language and digits: "30/11/2026", "٣٠‏/١١‏/٢٠٢٦". */
export function formatDate(iso: IsoDate, options?: { lang?: string; numerals?: Numerals }): string;
/** The week's first day where the language is used, as JS counts days (0 Sunday … 6 Saturday). */
export function weekStart(lang?: string): number;
/** A month (0–11) as weeks of 7 dates, null outside the month. */
export function monthGrid(year: number, month: number, weekStart?: number): (IsoDate | null)[][];
/** Where a key moves the focused date; null if it doesn't. */
export function step(iso: IsoDate, key: string, options?: { rtl?: boolean; shift?: boolean; start?: number }): IsoDate | null;
/** Today, where the person is. */
export function today(): IsoDate;

dialog​

Dialogs that don't lose work (from Apple's HIG): if closing would throw away what someone typed, the dialog doesn't close; a bar inside it asks to keep editing or discard. Never a second dialog on top.

…fields…
…

import { openDialog } from "./js/dialog.js"; openDialog(document.getElementById("edit"));

Escape is caught on keydown, before the browser's own close: browsers let a page refuse only the first close request, so a second Escape would otherwise close a dialog with unsaved work. If a close gets past anyway, the dialog reopens with what was typed, and asks.

Used by: Dialog. Source: js/dialog.js.

TypeScript
/** Open a modal dialog that won't close over unsaved changes; focus returns to the opener. */
export function openDialog(dialog: HTMLDialogElement, options?: { opener?: Element | null }): void;

format​

Numbers, money and percentages in the page's language, with the digits people chose. Arabic can be written with Western digits (123, the default, as on most Arabic sites and in finance) or Arabic-Indic (١٢٣); it's a preference, kept with the person's other settings, never inferred.

formatNumber(108000, { lang: "ar", numerals: "arab" }) → "١٠٨٬٠٠٠" formatMoney(6010, "GBP", { lang: "ar" }) → "£6,010" formatMoney(6010, "GBP", { lang: "ar", numerals: "arab" }) → "£٦٬٠١٠"

Put the result in (or .al-ltr) inside running text, so it stays whole in right-to-left; where it goes into a string (an aria-label, a tooltip), wrap it in U+2066 … U+2069. With Arabic-Indic digits this matters more: they don't carry "£" with them as Western digits do.

Used by: Field. Source: js/format.js.

TypeScript
export type Numerals = "latn" | "arab";
export interface NumberOptions extends Intl.NumberFormatOptions { lang?: string; numerals?: Numerals }
export interface MoneyOptions extends NumberOptions { signDisplay?: "auto" | "always" | "exceptZero" | "never" }
/** The Intl locale for a language ("en", "ar", "ar-SA"…) and a numeral system. */
export function numberLocale(lang?: string, numerals?: Numerals): string;
export function formatNumber(value: number, options?: NumberOptions): string;
/** The short symbol for a currency (£, $, €), or its code where there's none. */
export function currencySymbol(currency: string): string;
/** The symbol first, in both languages: "£6,010", "£٦٬٠١٠", "SAR 100". */
export function formatMoney(value: number, currency?: string, options?: MoneyOptions): string;
export function formatPercent(fraction: number, options?: NumberOptions): string;
/** Typed numbers in either digit set, and this module's own output; NaN if it isn't one. */
export function parseNumber(text: string): number;

highlight​

Syntax colour: splits code into the kinds of word the system colours (--al-color-syntax-*): keyword, string, number (and true, false, null), comment, function, tag (HTML and JSX tags, types and components) and attribute (attribute and property names). For JavaScript, TypeScript and JSX, HTML, CSS, JSON and the shell; anything else stays plain. Small on purpose: a sample on a page, not an editor.

Used by: Code and command. Source: js/highlight.js.

TypeScript
export type SyntaxKind = "" | "keyword" | "string" | "number" | "comment" | "function" | "tag" | "attribute";
/** Is there syntax colour for this language? (js, ts, jsx, tsx, html, css, json, sh…) */
export function highlights(language: string | undefined): boolean;
/** The code as [kind, text] runs; kind is "" for plain text. */
export function tokenize(code: string, language: string): [SyntaxKind, string][];
/** Colour the code in every <pre><code class="language-…"> inside root, once. */
export function highlightCode(root?: ParentNode): void;

keys​

Keyboard shortcuts that follow the platform (from Apple's HIG): ⌘ on Apple devices, Ctrl elsewhere, and the standard meanings kept (⌘Z undo, ⌘S save, ⌘K or / search, Escape to dismiss). Single-key shortcuts never fire while typing in a field, and can be turned off (WCAG 2.1.4): offer setShortcutsEnabled() as a setting.

K renderModifierKeys(); // fills [data-al-mod] with ⌘ or Ctrl onShortcut("mod+k", () => search.focus()); onShortcut("mod+shift+z", redo); // Shift is matched exactly… onShortcut("?", () => shortcuts.showModal()); // …except for symbols typed with it

On a non-Latin layout (Arabic, say) the key is matched by its position, so ⌘K and / still work.

Used by: Keyboard keys and shortcuts. Source: js/keys.js.

TypeScript
export function isApple(): boolean;
/** "⌘" on Apple devices, "Ctrl" elsewhere. */
export function modifierLabel(): string;
export function renderModifierKeys(root?: ParentNode, options?: { titles?: { apple: string; other: string } }): void;
/** Whether single-key shortcuts are on. */
export function shortcutsEnabled(): boolean;
/** Turn single-key shortcuts on or off (WCAG 2.1.4); remembered. */
export function setShortcutsEnabled(on: boolean): void;
/** combo: "mod+k", "mod+shift+z", "?", "/", "escape". Returns a function that removes it. */
export function onShortcut(combo: string, handler: (event: KeyboardEvent) => void, options?: { target?: EventTarget }): () => void;

Menu button (ARIA APG): a button that opens a list of actions in a popover. Arrow keys move, Home/End jump, Escape closes, and focus goes back to the button. Markup:

Used by: Menu. Source: js/menu.js.

TypeScript
export function initMenus(root?: ParentNode): void;

A navigation menu that folds under the md breakpoint, behind a menu button: a disclosure. The button says whether it's open (aria-expanded); Escape closes it and returns focus to the button; choosing a link closes it. The site header and the sidebar both use it.

Source: js/nav-menu.js.

TypeScript
/** Wire `button` to open and close `menu`: aria-expanded, Escape, closing on a link. `show(open)` does any other showing. */
export function wireNavMenu(button: HTMLElement, menu: HTMLElement, show?: (open: boolean) => void): void;

pattern​

The tessellation: Hankin's "polygons in contact" on the 4.8.8 tiling (regular octagons and squares). From each edge's midpoint two strands leave at the contact angle θ (67.5° by default); the strands leaning towards a vertex meet on its bisector, at (side/2)·sin θ / sin(θ + α/2) from it, α being the polygon's interior angle. One repeat is side·(1+√2) square, centred on a square, with a quarter octagon in each corner; the mark is the same tile centred on an octagon.

Used by: Brand: mark, wordmark, lockup, Construction figure, Pattern. Source: js/pattern.js.

TypeScript
/** One repeat of the tessellation: an SVG path, and its period in px. */
export function tile(side?: number, angle?: number): { d: string; period: number };
/** Fill an element with the pattern (aria-hidden); the geometry comes from the tokens unless given. */
export function drawPattern(el: Element, options?: { side?: number; angle?: number; stroke?: number }): void;

export interface PatternCell {
key: string;
kind: "octagon" | "square";
cx: number;
cy: number;
polygon: string;
star: string;
}
/** Every cell of the tiling over w × h, with an octagon centred on (ox, oy), as SVG paths. */
export function cells(side: number, angle: number, ox: number, oy: number, w: number, h: number): { cells: PatternCell[]; period: number };
/**
* Draw the hero pattern (one quiet line, one focal tile in the accent) into a
* `.al-pattern--hero` section's art, and place its `[data-pattern-caption]`.
* Redraws when its width changes; call again after changing `dir`. `animate`
* draws it in once, never under reduced motion.
*/
export function drawHero(el: Element, options?: { side?: number; angle?: number; focal?: boolean; animate?: boolean }): void;
/** One step (1, 2 or 3) of the construction, as a 200 × 200 SVG in `el`. */
export function drawConstruction(el: Element, step: 1 | 2 | 3, options?: { angle?: number; side?: number }): void;
/** Draw every `[data-construction]` figure, and wire its angle slider if it has one. */
export function initConstruction(root?: ParentNode): void;

popover​

Popover: more about something, opened by a button, in a native popover beside it. The browser does the rest: Escape, a click outside, or the button again closes it, and focus goes back to the button if it was inside. This places it, keeps it with its button while the page scrolls, and keeps the button's aria-expanded true to it. Markup:

Used by: Popover. Source: js/popover.js.

TypeScript
/** Wire each button with a popovertarget whose target is an .al-popover: placed beside it, aria-expanded kept true. */
export function initPopovers(root?: ParentNode): void;

position​

Place a floating element (a menu, a tooltip) next to what it belongs to, on the side text starts from, flipping above or inward if it won't fit.

Source: js/position.js.

TypeScript
/** Place a floating element by its anchor, flipping if it won't fit. */
export function place(floating: HTMLElement, anchor: Element, options?: { side?: "below" | "above"; gap?: number }): void;

Search field: Clear empties it and keeps focus in it; Escape in the field clears it too. Results are the app's job; announce their count in a polite live region ("12 results").

Source: js/search.js.

TypeScript
export function initSearch(root?: ParentNode): void;

The sidebar's menu under the md breakpoint: a disclosure (js/nav-menu.js). The sections and the foot stay in the page at every width; under md, CSS shows them only while the menu button's aria-expanded is true.

Used by: Sidebar. Source: js/sidebar.js.

TypeScript
/** Wire every `.al-sidebar`'s `[data-sidebar-menu-toggle]` to the menu it controls (`aria-controls`): under md, it opens and closes the sections. */
export function initSidebars(root?: ParentNode): void;

The site header's menu under the md breakpoint: a disclosure (js/nav-menu.js), the menu shown and hidden with hidden.

Used by: Site header. Source: js/site-header.js.

TypeScript
/** Wire every `.al-site-header`'s `[data-site-menu-toggle]` to the menu it controls (`aria-controls`). */
export function initSiteHeaders(root?: ParentNode): void;

tabs​

Tabs (ARIA APG, automatic activation): role="tablist" with role="tab" buttons (aria-controls) and role="tabpanel"s. Arrow keys move along the reading direction (reversed in right-to-left), or up and down in a vertical list (aria-orientation="vertical"); Home and End jump.

Used by: Code and command, Tabs and section nav. Source: js/tabs.js.

TypeScript
export function initTabs(root?: ParentNode): void;

theme​

Theme: follow the system's light or dark, unless the person chose one; the choice is remembered, and kept only while it differs from the system (when they agree again, the override goes).

So there's no flash, apply the stored choice with a classic inline script in the (a module is deferred, so it runs after first paint):

then import this module for the toggle. It's safe to import where there's no window (server rendering): nothing runs until a function is called.

Used by: Site header. Source: js/theme.js.

TypeScript
export type Theme = "light" | "dark";
/** The stored choice, or null when following the system. */
export function chosenTheme(): Theme | null;
export function currentTheme(): Theme;
export function applyTheme(): void;
export function setTheme(theme: Theme): void;
export function toggleTheme(): void;

toast​

Toast: say what just happened, briefly, in one polite live region; offer an action (Undo) when there's one. A plain toast stays 4s, and waits while it's hovered or focused. A toast with an action has no time limit (WCAG 2.2.1): it stays until it's used, dismissed, or replaced by the next toast with an action; F6 moves focus to it. Errors that need fixing belong in the page (a notice by the field), not only here. import { toast } from "./js/toast.js"; toast("Process archived", { action: "Undo", onAction: () => restore(), dismissLabel: "Dismiss" });

Used by: Toast. Source: js/toast.js.

TypeScript
export interface ToastOptions {
/** The action's label ("Undo"). A toast with an action stays until it's used or dismissed. */
action?: string;
onAction?: () => void;
/** Milliseconds; without an action, 4000 by default. */
timeout?: number;
/** The accessible name of the dismiss button, in the page's language. */
dismissLabel?: string;
}
/** Show a toast; returns a function that dismisses it. */
export function toast(message: string, options?: ToastOptions): () => void;

toc​

On this page: marks the link to the section being read (aria-current), as the page scrolls: the last whose heading has passed the top quarter of the window, the first before any has, and the last at the very end.

Used by: On this page. Source: js/toc.js.

TypeScript
/** Wire every .al-toc inside root: its link to the section being read gets aria-current as the page scrolls. */
export function initToc(root?: ParentNode): void;

tooltip​

Tooltips for anything with data-al-tip ("value|what it is"): shown on hover and on keyboard focus, hidden on leaving, on blur and on Escape; it stays while the pointer is over it, or crossing to it (WCAG 1.4.13). The text is also the element's accessible description, so it isn't only seen; descriptions the element already has are kept.

Used by: Charts, Tooltip. Source: js/tooltip.js.

TypeScript
export function initTooltips(root?: ParentNode): void;

validate​

Validation, the GOV.UK way: check on submit (not while people type), and when something's wrong, show an error summary at the top of the form, listing each error as a link to its field, focus it, and put "Error: " at the start of the page title. Beside each field, the same message, with a hidden "Error:" for screen readers. Shown even for one error.

import { validateOnSubmit } from "./js/validate.js"; validateOnSubmit(form, [ { field: "#name", check: (v) => v.trim() ? "" : "Enter the project name" }, { field: "#email", check: (v) => /.+@.+..+/.test(v) ? "" : "Enter an email address in the correct format, like name@example.com" }, ], { onValid: (form) => save(form) });

Give the form novalidate, so the browser's own bubbles don't compete.

Used by: Character count, Date picker, Error summary. Source: js/validate.js.

TypeScript
export interface Check {
/** A selector inside the form, or the field itself. */
field: string | HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
/** The error message, or "" when the value is fine. */
check: (value: string, input: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement) => string;
}
type Text = string | (() => string);
export interface ValidateOptions { onValid?: (form: HTMLFormElement) => void; title?: Text; prefix?: Text; sprite?: string }
/** Check on submit, with an error summary and inline errors. Throws at once if a field isn't there. */
export function validateOnSubmit(form: HTMLFormElement, checks: Check[], options?: ValidateOptions): { clear: () => void };