npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@aweftjs/ui

v0.1.1

Published

Components and theming: a wrapping h, the theme engine, contexts, popups, suspend and marks.

Readme

@aweftjs/ui

Components and theming on top of @aweftjs/dom. h is dom's h plus the handful of things that need to know where the element sits: its theme, its state cells, its listeners. A theme is data you define once and match against, not a stylesheet you write. Everything one page owns is one object made per render, so a build can render two pages at the same time and neither can see the other's classes.

Quickstart

import { h, mount } from '@aweftjs/ui';
import { mutable } from '@aweftjs/core';

const Counter = () => {
	const clicks = mutable(0);
	const hovered = mutable(false);
	return (
		<button theme={['button', hovered.bool('hovered', null)]} isHovered={hovered}
			onClick={() => clicks.set(clicks.get() + 1)}>
			clicked {clicks} times
		</button>
	);
};

mount(document.body, <Counter />);

recipes/ui/ is the whole of this README as a running page. npx vite --configLoader native recipes/ui serves it.

Five rules

Each is stated again where it belongs. They are here together because each is easy to get wrong and the symptom does not name the cause.

  1. A theme value is a list of segments. theme={['button', 'quiet']} reaches button and button_quiet. theme="button_quiet" is one token naming one entry, which is how a part is reached: the element gets the variant's colours and none of button's box. (The theme.)
  2. each builds one shape per list. The row component under each renders the same tags in the same order on every row; a button on some rows and nothing on others is wrong, with no message. Vary a value, not the shape. Table's cell is not under that rule: it may answer a button on one row and null on the next. (Navigation and data.)
  3. The root entry paints nothing. Your own page entry sets background, color and a minHeight of the viewport, and the page's HTML carries <style>body { margin: 0 }</style>, because a theme entry cannot reach body and the host's white margin shows as a band around a dark page. (The look.)
  4. validate is handed the cell, not its value. Read it with cell.get(); a formatter writes back with cell.set(). (Composites, under Validate.)
  5. theme appends, on every component. <Card theme="tight"> is the card entry plus card_tight; the segments you pass never replace the component's own. (What every component takes.)

Source is .tsx

This package's own source is .tsx, compiled by this stack's own transform. JSX becomes plain h calls resolved by ordinary scope, so h has to be in scope in every file that writes JSX. Node cannot load .tsx on its own, so a suite that imports one runs with node --import @aweftjs/build/loader; the root gate does that for you.

An application bundles with aweft() from @aweftjs/build, which is one line in a vite config and is what recipes/ui/vite.config.ts shows. Pass aweft({ defaultH: '@aweftjs/ui' }) if your pages are ui pages: a file that writes JSX and imports no h of its own is given one, and without that setting it is dom's, which writes theme out as an attribute nothing reads.

Getting the packages. Nothing is published to a registry yet. An application takes this repo as a git submodule and its own package manager resolves @aweftjs/* through the workspace, which is how the five applications that use the stack do it. A program written outside a checkout has nothing to resolve those specifiers to, so recipes/ and the tests live inside the repo.

h, svg and html

h(tag, props, ...children) is dom's, with eight prop names taken off it:

| prop | what happens | |---|---| | theme | a string, a list, a cell, or lists of those; flattened at mount into a class list and matched against the theme | | class | kept, and joined in front of the classes the theme generated. Without a theme it is an ordinary attribute | | style | an object (or a string); a bare number in a size property gets px, and $var and $fn() resolve against the element's own theme chain | | isHovered, isFocused, isClicked, isTouched | a cell this package writes from real events. isFocused is the element's own focus, and isTouched is a pointer event that says it was a finger | | onClick, onInput, onKeyDown, … | on and an uppercase letter: a handler, handed to dom as $on<type> so a hydration replays it, and gone when its element goes. Your own $onclick beside it runs first |

Everything else goes to dom unchanged: a bare name is an attribute, $name is a property.

An element with none of those is dom's h exactly, node and all, so const box = h('div', { class: 'box' }) is an element. An element with any of them is a mounter, not a node: the theme it gets depends on where it is mounted, and dom hands the mount context to a mounter and to nothing else. Hand it to mount as you would anything else.

svg is the same in the SVG namespace, with no theme: class and style stay plain attributes. html is dom's template-literal tag bound to this h.

On a component, each:name renames the loop item: <Row each:row={rows} /> hands each item to the component as props.row rather than props.each.

The theme

A theme is a flat object whose keys are _-joined selector paths.

Theme.define({
	'*': { $brand: '#1b6ef3' },
	tile: { padding: '$space4', borderRadius: '$radius', background: '$brand' },
	tile_flat: { boxShadow: 'none' },
});

An element's theme prop flattens to a class list with * in front of it. An entry matches when its segments appear in that list in order, with gaps allowed. So card_hovered matches an element themed card primary hovered, and hovered_card does not match anything themed card hovered. Entries are ordered by where their last segment matched, ties going to the longer entry, and later in that order wins.

A class token holding _ names one entry: that is how you reach a part. A part is a different element from its component, and it must not wear the component's own box, so theme="card_title" reaches card_title and not the bare card. A modifier is a state or a variant of the same element and stays a segment of its own, so theme={['card', 'tight']} reaches card and card_tight. Both spellings mix: theme={['card_title', 'muted']} reaches card_title and card_title_muted, and an element that wants a part and its component says both tokens.

Values. $name is a variable, $fn(a, b) is a call, $$ is a literal $, and $size$px is the variable followed by the text px. A bare number in one of sizeProperties gets px; lineHeight is not one of them, because a bare number there is a multiple of the font size, which is how CSS reads it (design 292). What a variable holds is text, and that text is not read again: $a: '$b' writes the four characters $b into the CSS rather than following them. Point a declaration at the variable you mean.

Variables and functions are the same namespace. A $name whose value is a function is a function; anything else is a variable. Both are found by walking the matched chain from the most specific entry down, so a generic entry writes background: '$hover' once and every component supplies its own $hover. And because a function is theme data, an application defines its own and a nested theme replaces one:

Theme.define({ '*': { $em: (args) => `${Number(args[0]) * 16}px` } });

The colour functions ($shiftBrightness, $brightness, $saturate, $hue, $alpha, $invert, $contrast_text, $luminance) and the arithmetic ones ($add, $sub, $mul, $div, $mod, $min, $max, $floor, $ceil, $round, $if) ship in the default theme, so every one of them can be replaced the same way. A $name( nothing defines is written back out as it was read, so calc() and rgb() survive untouched.

Directives live inside an entry, keyed _name_rest:

| directive | compiles to | |---|---| | extends: 'other' or ['a', 'b'] | what it extends applies first, so this entry wins over it; a list starting * replaces the inherited one | | _elem_button | button .awN { … } | | _children_span | .awN > span { … } | | _cssProp_focus | .awN:focus { … }; a pseudo-element gets ::, and a key written with its own colons is used as written | | _media_(min-width: 40em) | that entry's body inside the query | | _container_(min-width: 28rem) | that entry's body inside a container query | | _starting_ | that entry's body inside @starting-style, the style a transition starts from. It takes nothing after the name | | _keyframes_spin | @keyframes spin-<id>, with $spin bound to the generated name | | _fontFace_body | @font-face { … } | | _import_fonts | @import url(…) layer(aweft); |

@font-face, @keyframes and @import are emitted once per definition per render rather than with the entry's own rules, so two class chains reaching one entry emit the font once.

A directive block holds declarations and one more directive. A _media_ or a _container_ may hold a _cssProp_ and a _starting_; a _cssProp_ may hold a _starting_. That is what lets a dialog's ::backdrop fade: its transition has to reach .awN::backdrop and has to stay inside the reduced-motion query. A third level compiles to nothing, and an enclosing rule is dropped wherever its own body came out empty. _keyframes_, _fontFace_ and _import_ leave the entry body rather than wrapping the rule, so they are read at the entry's own level only.

A declaration whose value is a list is emitted once per item, in order. appearance: ['none', 'base-select'] writes appearance: none; appearance: base-select;, and a host that cannot read the second keeps the first. That is the only way an entry, which is an object, can say one property twice.

Every rule is inside @layer aweft, and this package ships zero !important. Unlayered styles beat every layer, so an application's own stylesheet overrides the library with a one-class selector and no specificity fight. packages/ui/tests/browser.test.ts asserts that in Chromium.

Two themes on one page. <Theme value={partial}> merges a partial theme onto whatever is above it for its subtree, and that subtree generates its own classes. <ThemeContext value="brand"> is different: it sets a prefix that ThemeContext.use(h => component) puts in front of every theme an element below asks for. An element with no theme prop stays plain; write theme="" to take the cascade and nothing else.

Values in a generated class are literals, not custom properties. The element's class attribute holds one minted name per distinct list, not the segments themselves, and that name's rules are written one block per matched entry, so a stylesheet has as many blocks for .aw1 as entries matched it.

The per-render object

const ui = context();
const body = await render(<App />, { context: ui });
const page = `<!doctype html><html><head><style data-aweft>${ui.theme.markup()}</style></head><body>${body}</body></html>`;

mount, render and hydrate each make one and thread it through dom's context. It carries theme (this render's class cache and stylesheet), ids (the counter behind an aria-labelledby, counting from zero per render so a server and a browser agree), popups, head (the page's head tags, and head.markup()), stage (one entry per live StageContext), and the language the page shows: context({ locale, catalog }), which the text tokens below look up. usedText(ui) answers the keys a render's tokens looked up, which is what a static walk reads to say what a catalog lacks.

render holds the head list and the stage list for the length of the call, so a caller can read both after the page has been taken down. A render object is therefore for one page.

mount puts the stylesheet in the document head and takes it back out when it unmounts. hydrate adopts the one the server wrote rather than making a second. render returns the item's markup only; the CSS is context.theme.markup(), which the page puts in its own head.

The element the mount puts there carries the sheet as it stood then, and nothing writes it again. A class compiled later (a hover state the pointer reaches, a theme cell that moves) arrives as a <style> of its own holding only what that compile added, and every one of them comes out with the mount. So reading the sheet back out of the DOM means every style[data-aweft], each carrying its own @layer aweft wrapper, while theme.markup() is the same rules as one stylesheet without reading the DOM at all. theme.watch(fn) hears each compile's CSS as it is added.

The reason is webfonts. A browser registers an @font-face by name when it parses the sheet that declares it. Change a stylesheet the document already holds and Chromium drops every face on the page and registers them again, whether the change is a rewrite, a rule put in through the CSSOM, or a write to a different element entirely; Firefox does the same when a sheet's text changes. Until the data is back, the page's text falls back to a system font. Nothing in this package declares a face, so nothing here showed it; an application that does hits it on the first hover (design 257).

Two mount calls into one page share that page's render, so two widgets on one page cannot mint the same class name for two different themes. Passing a context() by name asks for a render of your own instead, which is what a static render wants; two named renders in one page each count their classes from zero.

A component hydrates by identity, and the elements it makes on the way are dropped. hydrate builds the client's tree and pairs it against the nodes the server wrote, keeping the server's; the fresh elements it made to compare against are thrown away. So a hydration is not free of createElement, and counting those calls counts the client's tree, not a defect. packages/ui/tests/controls.test.ts pins both halves: every server element is kept by identity, and nothing the client made ends up in the page.

A hydrated page is live. Nothing this package computes is written onto an element: a handler is a $on<type> property and the class and the style are cells handed to dom as attributes, and dom carries all three onto the node the hydration adopted (design 133). So a click, a keystroke, a focus, a hover, a theme cell and an inline style all reach the page a server sent. browser.test.ts clicks, types, focuses and hovers for real in Chromium and reads the hovered tint back off the button the server wrote.

A theme mismatch across a hydration is not reported. dom catches a structural mismatch loudly, but if the client's theme registry says something different from the server's, the client rewrites the adopted <style> and says nothing. Define your theme in a module both sides import.

Reaching a ui system from a mount that has none is an assert naming what to call instead.

Contexts

const Tone = createContext('plain');
const Label = Tone.use((tone) => (props) => <span theme={['label', tone]}>{props.children}</span>);

mount(document.body, <Tone value="accent"><Label>hi</Label></Tone>);

createContext(def, transform) returns the provider, with def, read(context), node(context) and use(build) on it. transform(raw, parentValue, children) runs on first read and again after raw changes; raw arrives exactly as written, so a cell arrives as a cell.

use(build) calls build once per mount of the component it hands back, with the value read at that mount's own place in the tree. So two mounts under two different providers each get their own component, and a mount already on the page does not call build again when the value above it moves. A value that moves is read with read(context) or held in a cell the built component follows, not through use.

A node has id, parent, children and value(). children is an observable array in mount order, so something above can watch what appears below it. node(context) answering null is how a component asks whether there is a provider above it at all.

node(context) reads the tree at that point, not from the root: the node it answers is the nearest provider above the mount whose context you handed it, and parent walks up from there. There is no way in from outside, and that is the design: to introspect from elsewhere, capture a context inside the tree (a component's fourth argument, or a mounter's) and start from that.

Marks

A component that takes more than one slot of children reads them with mark and categories.

<Shown value={open}>
	<p>shown</p>
	<mark.else><p>hidden</p></mark.else>
</Shown>

A mark is a value, never a node, and never reaches mount. Inside a component:

const [popup, anchor] = categories(props.children, ['popup', 'anchor'], 'anchor');

Each category has items and a props merged from every mark of that name. An unknown slot, or a bare child with no default slot, is an assert naming the slots the component knows.

mark.name is declared for the eight slots this package reads (then, else, case, default, popup, anchor, tabs, panels). Any other name works at run time; in a TypeScript file write it as mark('name', props, ...children).

Control flow

Shown takes a condition, a then (the default slot) and a <mark.else>, with invert to flip it. Switch takes a value matched against <mark.case value=…> with a <mark.default>, or a cases object of cells where the first truthy key wins. Neither builds an element or keeps state.

Loading

const Article = suspend(Spinner, async ({ id }) => <Body article={await fetchArticle(id)} />, ErrorPanel);

The fallback shows, the loader runs, and what it resolves to replaces the fallback. The promise is declared pending, so a static render waits for it.

A rejection never leaves the fallback on screen. The call's own failed component shows it, or the LoaderContext's, and with neither the slot goes empty and the rejection is rethrown so the host reports it. LoaderContext carries { loading, failed } and inherits them one field at a time.

Every wait in this package shows the same loader (design 219). suspend while its loader runs, a Button while a promise its onClick returned is pending, and a FileDrop entry an application moved to status: 'loading' all show LoaderContext.loading, and LoadingDots when nothing named one. So naming a loader once names it for all three:

<LoaderContext value={{ loading: MySpinner }}><App /></LoaderContext>

No drawing ships (design 144). LoadingDots is three spans and a keyframes block; for an animated spinner, @aweftjs/icons says which set to install and shows the one line that puts it here.

Popups

<PopupContext>
	<App />
</PopupContext>

<Detached enabled={open} dismiss>
	<button onClick={() => open.set(!open.get())}>details</button>
	<mark.popup><Card>what floats</Card></mark.popup>
</Detached>

Everything that floats is a popup: a Tooltip, a Menu, a Select, and Popup itself. Every one of them has somewhere to go without being told. A popup's sink is the nearest <dialog> above where it was written, then the sink a PopupContext gave it, then the element the page was mounted into. So a page needs no wrapper to open one, and a menu opened inside a modal draws inside that dialog, which is what makes its rows clickable: a dialog's top layer swallows every pointer event aimed at anything outside it.

PopupContext is how a page chooses a sink on purpose. It renders its children and then the popups, so a popup is after the page in DOM order. The first one on a page takes the render's own sink; a second one, nested or beside it, makes its own, so a nested PopupContext renders its popups at the end of its own subtree rather than at the end of the page. Popup renders nothing where it is written and puts its element in whichever sink it found. Detached measures its own children, scores twelve placements and picks one, re-measures every animation frame, and closes when the anchor moves, on the reading that the page scrolled. With dismiss it also closes on Escape and on a press outside the anchor and the popup, and Escape gives focus back to what had it when it opened (design 303). Without it nothing but the open cell and a scroll closes it, which is what Menu, Select and Tooltip want, since each closes itself.

There is no z-index in this package. A popup asks for the top layer with the popover attribute where the host has one, and falls back to DOM order where it does not. Below the top layer a page with its own stacking context can put something over a popup, and that is the page's decision to make.

Detached mounts its anchor where the anchor was written and reads the anchor's nodes back out of the document, so a page taken over from a server adopts the anchor the server sent (design 153). trackedMount() is the other way of reaching children a component does not own: it returns the array the real nodes appear in and a mounter to render where they belong, and children mounted that way are built fresh rather than adopted.

Detached needs a real browser to open. It places its popup from the anchor's measured rectangle and asks for the next animation frame, and the light tree has neither layout nor frames. So in Node, and in a static render, a Detached renders its anchor, and its popup's children are in the markup inside a display: none container rather than left out of it. That is the right server output, and it is also why the interaction tests for it are in browser.test.ts rather than in the light tree. Popup on its own has no such need: give it a placement and it renders where you put it, anywhere.

Other exports

InputContext is where an input event goes: InputContext.fire(context, 'click', payload) calls the generic on and then on<Type>, with the application's meta merged in last. useAbort(fn) runs fn with a fresh AbortSignal and hands back the abort. sizeProperties is the set of property names a bare number is given px for; lineHeight is not in it.

What every component takes

Four props mean the same thing on every component in this package, controls, composites, display pieces and grouping pieces alike.

  • theme appends your own segments to the ones the component writes, so <Card theme="tight"> is the card entry plus card_tight and <Button theme={cell}> follows a cell.
  • class appends a plain class name, leaving the generated one alone.
  • element hands in the node to decorate instead of building one. An element of the wrong tag is an assert naming both tags, because a <div> wearing a checkbox's theme is a checkbox that does nothing.
  • Anything else goes to the element, so id, aria-* and a handler the component does not name land where you would expect them.

Every state prop is a cell, or absent. Given one, the component writes it and follows it; given none, it keeps its own. A display prop takes a value or a cell. There are no imperative handles.

Controls

Every control here is one native element with a theme on it. There is nothing in this package that draws a control out of divs, so the keyboard, the role, the form participation and the autofill are the platform's and not ours (design 128).

import { Button, Select, TextField, Toggle } from '@aweftjs/ui';
import { mutable } from '@aweftjs/core';

const email = mutable('');
const problem = mutable(null);

<TextField label="Email" value={email} error={problem} description="Work address" />
<Toggle label="Email me" value={subscribed} />
<Select label="Owner" value={owner} options={users} display={(user) => user.name} placeholder="Pick someone" />
<Button label="Save" onClick={() => save(email.get())} />

| component | the element | its own props | size | example | |---|---|---|---|---| | Button | <button>, or <a> with an href | label, type, icon, iconPosition, disabled, loading, round, inline, href, hrefNewTab, onClick, track | sm, lg, icon, icon-sm, icon-lg | button | | TextField | <input>, in a <div> on input_group when it was given an addon | value, label, description, error, leading, trailing, placeholder, password, onEnter, onKeyDown, disabled, type | sm, lg | text-field | | TextArea | <textarea> | the same, plus maxHeight; a style object keeps its declarations but height and maxHeight, which are this component's; a string or a cell for the whole style is refused | sm, lg | text-area | | Checkbox | <input type="checkbox"> | value, label, invert, indeterminate, disabled, onChange | sm, lg | checkbox | | Radio | <input type="radio"> | value (the group's), option (this one's), label, disabled, onChange | sm, lg | radio | | Toggle | <input type="checkbox" role="switch"> | value, label, disabled, onChange, type | sm, lg | toggle | | Slider | <input type="range"> | value (a number), min, max, step, disabled, track | sm, lg | slider | | Select | a <button role="combobox"> in a <span> with its arrow, over a hidden <select> | value, options, display, placeholder, open, disabled, name, autocomplete, onChange | sm, lg | select | | LoadingDots | three dots | type, size, label | a CSS length | loading-dots | | Icon | <svg> | name, size, label, rot | a CSS length | icon |

The last column is the file under recipes/ui/examples/, without its .example.tsx ending. Each one renders every state, type and size of its component, in both modes, on a page of its own at catalogue.html#/<Component>, and the recipe's driver reads it there (designs 197 and 226).

size is one theme segment, right after type (design 194): sm is 32px tall, nothing is 36px and lg is 40px, and the entries are button_sm, input_lg, checkbox_sm and so on, the same way quiet and danger are. A small control takes the smaller text step with it. Button also takes icon, icon-sm and icon-lg, which are a square of that height with no padding, for a button whose label is an icon. It is a value or a cell, like every other display prop. LoadingDots and Icon have a size of their own, which is a CSS length and not this axis.

A label is what gives a control a name. Give one and the component renders a <label for> next to the element, both inside one <div>, mints the ids off the render (so a server and its hydration agree), and wires aria-describedby and aria-invalid for you. Give none and you get the bare element, and naming it is yours. packages/ui/tests/controls.test.ts finds every control by its role and its name.

A Button with no name throws where it mounts, in development (design 266). Its name is its label, its text, an aria-label or title on it, or a label on the Icon inside it; a button that is only an unlabelled icon has none, a screen reader says "button" and nothing else, and the mount throws with those three ways to name it. The button is read once it and what is inside it are on the page, so a labelled Icon counts, and a static render() reads it the same way. A release build has no check: the statements are gone.

One document is one render. The ids come off the render's counter, which starts at zero every time (design 109), so two named renders mounted into the same document mint the same ids and their labels point at each other's controls. Two mount calls into one page share that page's render and do not collide; a second context() is a second render and belongs in a second document.

What a control starts with is in the markup. Checkbox, Radio and Toggle write the state they start in as the checked attribute; TextField writes its text as value and TextArea as its content. So a server page shows a ticked box and a filled field before any script runs. The property follows the cell once the page is alive, which is what the platform does with these attributes too: they say what the control started as, not what it holds now.

An error cell is announced when it arrives. While it says something the control carries aria-invalid, its aria-describedby names the message, and the message is a live region. Once the error clears the attribute is removed rather than set to false.

A promise makes a button busy. A promise onClick returns disables the button and shows the LoaderContext loader until it settles, however it settles, so a double click cannot submit twice.

A radio group is a group because its members share a name, minted once per value cell. So the arrow keys, the wrapping and the roving focus are the platform's. browser.test.ts presses the real keys: Space on a checkbox, the arrows in a radio group, Home and End on a slider.

TextField takes leading and trailing, and builds a box only when it was given one (design 210). With neither, the markup is the <input> and nothing else. With either, the input goes inside a <div> on input_group that carries the border, the radius, the fill and the height, and the input carries none of them, so the two read as one control. The focus ring is on the box, so tabbing into the input rings the whole thing. Text is wrapped on input_group_addon for you, and anything else is mounted as it is, which is what lets an Icon or a Button of size="icon" go there.

<TextField label="Price" leading="$" trailing="CAD" value={price} />
<TextField label="Search" leading={<Icon name="search" />} value={query} />

Select draws its own list, on every host (design 224). The closed control is a <button role="combobox">; the open list is a <div role="listbox"> of <div role="option"> rows in a popup placed under the control at the control's width. So it looks the same everywhere, the theme reaches every part of it, and open is a cell you can read and write. The list goes where every popup in this package goes, so it needs no wrapper above it.

It opens on a click, on ArrowDown, ArrowUp, Enter and Space, and on typing a letter, which opens it and jumps to the first row beginning with that letter in the one press. Inside it the arrows move and wrap, Home and End go to the ends, typing keeps searching, Enter and Space choose, and Escape closes and gives the keyboard back to the control. The focus stays on the control the whole time and aria-activedescendant says which row the keys are on.

A hidden <select> sits under it, off the screen and out of the reading order, carrying the same options and the same choice. It takes name and autocomplete, so a form the control is in posts the value and autofill has a real control to find; anything that writes it writes your cell. What autofill cannot do is draw its own highlight over the button, because the element it filled is one pixel square.

Select holds items, not the text a row reads as. options is a list of anything, display says what a person reads, and the cell holds the item you put in the list, so adding, removing or reordering leaves the choice on its own item. An item that is a string or a number carries itself as the hidden option's value; an object carries none, and the platform then posts what the row reads as.

Select draws its own arrow (design 195). Every host draws a different one and no theme can reach any of them, so the entry tells every host to draw none and the component renders the control inside a <span> with an empty box at the right of it. The select_chevron entry draws the arrow the way checkbox draws its tick: a $chevron square with two of its sides in $mutedForeground, turned a quarter turn. So it needs no icon pack, and an application that wants another arrow gives select_chevron its own rules. It is aria-hidden and takes no pointer events, so a screen reader reads the combobox and a click reaches the element under it.

Icon is built from icon data, in the shape the icon sets publish: body, width, height, left, top, and optional rotate, hFlip and vFlip. name is that data, or a name looked up through the Icons context.

This package ships no drawings (design 144). Icons starts empty, and an application supplies a pack, a resolver, or both. This is not only about the Icon you write yourself: DropDown, FileDrop, Modal and Validate each mount one of their own, by name, so a page that mounts any of them and has no Icons provider above it asserts on its first render rather than rendering without the glyph. Validate is the one that surprises people, because its icon is a default nobody asked for. Give the page a provider, or give the component its own icon prop. (Select needs none: its arrow is drawn by the theme, design 195.) @aweftjs/icons turns an installed icon set into exactly that:

import { Icon, Icons } from '@aweftjs/ui';
import standard from '@aweftjs/icons/lucide/+standard';

<Icons value={standard}><App /></Icons>
<Icon name="check" label="done" />

A provider stacks a pack or a resolver in front of what it inherited, newest first, so a pack an application puts up answers before anything under it. A pack is { prefix, icons, aliases?, width?, height? }, where the root width and height are the box every icon in it that states none is drawn in; the sets state it once at the root, so without it a real set renders clipped. A resolver is (name) => data | Promise<data> | null, and a promise is declared pending so a static render waits for it. A resolver that answers null passes the name on to the next source, and so does one that answers a promise of null, so a resolver in front of a pack does not stop the pack behind it being asked. An icon with no label is aria-hidden, because an icon beside the word it means is otherwise read out twice. A name nothing answers is an assert naming the icon, how many sources were asked, and how to answer it: wrap the page in Icons with a pack or resolver that has it.

The components here ask for names, never for drawings. standardIcons is that list, in the spelling the sets publish: chevron-down, chevron-up, chevron-left, chevron-right, check, x, triangle-alert, search, upload. Give one of them a drawing of your own by putting a pack of your own in front, which is what Icons is for.

Laying things out is a theme entry, not a component (design 132): row and column, each with fill, center, start, end, spread, wrap and tight, and divider. center, start and end mean across the page on both.

<div theme={['row', 'fill', 'spread']}><span>left</span><span>right</span></div>

recipes/ui/catalogue.html is every one of them in every state, in both modes, one page per component at #/<Component>, driven in Chromium by recipes/ui/main.ts with axe-core over every page.

Laying a form out

There is no Field component. A form is the theme entries and a bare <label> (design 209): every page that laid a form out reached past the components, for the same reason each time, so the components went and the entries stayed.

import { Checkbox, TextField } from '@aweftjs/ui';

<div theme="field_group">
  <fieldset theme="field_set">
    <legend theme="field_legend">Where to send it</legend>
    <div theme={['field', 'responsive']}>
      <label for="street" theme="field_label">Street</label>
      <TextField id="street" value={street} />
    </div>
    <div theme={['field', 'inline']}>
      <Checkbox id="post" value={post} />
      <label for="post" theme="field_label">Post it rather than email it</label>
    </div>
  </fieldset>
  <TextField label="Notes" value={notes} description="Anything else" />
</div>

| entry | what it lays out | |---|---| | field | one field: a control, whatever labels it, and whatever is said under it, in a column | | field_inline | the same field as one $control-tall row | | field_responsive | a column that turns into that row from 28rem of its container | | field_group | the stack a form is, $space6 apart, declaring itself the container above measures | | field_set | the same stack on a <fieldset>, with the host's border, padding and minimum width off | | field_legend | the <legend> inside one | | field_label, field_hint, field_error | the three things written around a control |

A control still labels itself. label, description and error stay on the control, and a control given any of the three wraps itself in its own <div theme="field">. So the last line of the form above needs no box around it: a Field around a control that lays itself out is a column of one thing.

field_inline and field_responsive lay out the box's own children, so they are for a bare control and the <label for> you wrote beside it.

field_responsive measures the nearest ancestor that declares itself a container, which in this package is field_group and nothing else. With no group above it, a query with no container answers false and the field stays a column at every width.

A label under an ancestor carrying data-invalid takes the colour its message has. Nothing writes that attribute for you; write it from the same cell you pass to error if you want it.

Composites

Ten more components, each built out of the controls above and the behaviours underneath them.

import { ColorPicker, Country, Default, DropDown, FileDrop, Menu, Modal, Region, Tooltip, Validate, ValidateContext } from '@aweftjs/ui';

| component | what it is | its own props | example | |---|---|---|---| | Modal | a stage template: the act inside a native <dialog> | label, noEsc, noClickEsc, type (sheet), side | modal | | Default | the stage template that adds nothing | none | modal | | Tooltip | Detached plus the hover and focus trigger | label, enabled, locations, type | tooltip | | DropDown | a <details> whose <summary> wears the button theme | open, label, icon, iconOpen, iconClose, arrow, name, type, disabled | drop-down | | FileDrop | a drop zone with a real file input in it | files, extensions, multiple, limit, clickable, disabled, onDrop, ready, type | file-drop | | Validate | a check around a control, and the message it shows | value, validate, signal, valid, error, showError, icon, type | validate | | ValidateContext | the form's answer: every Validate below it | value | validate | | ColorPicker | a saturation and brightness square, the hue and the alpha as Sliders, and a swatch | value, hasAlpha, disabled, type | color-picker | | Country | a button that opens a searched grid of every country | value, locale, priority, flags, suggest | country | | Region | the same, over one country's subdivisions | value, country | country | | Menu | a button and the list of actions it opens | items, open, label, icon, type, size, disabled, locations | menu |

These ask for chevron-up, chevron-down, x, triangle-alert and upload by name, so a page using them answers those five through Icons; @aweftjs/icons/<set>/+standard does. A Menu asks for none: its rows hold whatever icon you give them.

A Menu is a button and the actions under it. items is a list of { label, icon?, type?, disabled?, onSelect }, and { heading, items } anywhere in that list draws a small heading over its own group. type: 'danger' draws a row in the danger colour, which is what a delete belongs in.

<Menu label="Quick Actions" items={[{
	heading: 'Conversation',
	items: [
		{ label: 'Mute Conversation', onSelect: mute },
		{ label: 'Mark as Read', onSelect: read },
		{ label: 'Delete Conversation', type: 'danger', onSelect: remove },
	],
}]} />

The keys are the ones a Select has, because they are the same behaviour: the arrows move and wrap, Home and End go to the ends, typing moves by what a row reads, Enter and Space choose, Escape and an outside click close it, and Escape and choosing put the focus back on the button. The list is role="menu" and the rows are role="menuitem", named by the button. The list is in the page while the menu is closed, inside the box the sink places, which is what hides it; a script that looks for an open one asks by role with hidden elements left out, not for the first role="menu" it finds.

Opening it moves the focus onto the role="menu" element, which is where aria-activedescendant names the row the keys are on. That is the ARIA menu-button pattern, and it is the only shape ARIA allows: the attribute may not sit on the button that opened the menu. Escape and choosing a row put the focus back on that button.

The anchor is the button this component builds, so the ARIA is in the markup rather than written onto a node it does not own. Children go inside that button, and element hands one in. A group is { heading, items }, and a heading makes it one whether or not it has any items yet.

A modal is a stage template, so back closes it.

stage.open({ name: 'edit', history: true, template: Modal });

A stage calls a template as h(template, props, act), where props is what the open carried past name, template, history and children (design 213). So the dialog is named at the call, and Modal goes in as the template itself:

stage.open({ name: 'edit', history: true, template: Modal, label: 'Edit the thing' });

The act is handed the same props, plus the stage after them. An act reached from the URL carried nothing, so the template the stage was given is called with nothing.

Modal reads the props it names and forwards none of the others. label, noEsc, noClickEsc, type, side, element, theme and class are its own; anything else an open carried goes to the act and no further, so a row handed to the act is not written on the <dialog> as an attribute. A prop the act wants that is spelled like one of the eight is read by Modal too: that is what one prop bag means, and it is visible in the call.

Escape (through the element's own cancel event), a mousedown on the backdrop and the close button all call the stage's close(), so a modal that owns a history entry goes down the same way whichever one you used (design 124). noEsc and noClickEsc turn the first two off. A Modal with no stage above it is an assert naming the call that shows one. A popup opened inside the dialog goes in the dialog, so a Select or a Menu in a modal works with nothing else to write.

A sheet is a Modal with type="sheet", against an edge instead of in the middle, sliding in from it (design 202). side says which edge, right by default, and is read for no other type. Everything else is the same: the same stage, the same three ways to close it, the same backdrop.

stage.open({ name: 'filters', template: Modal, label: 'Filters', type: 'sheet' });

A stack of disclosures that keeps one open is a run of DropDowns sharing one name, which is the platform's own behaviour for a group of <details>. There is no component for it (design 212): give each drop-down the same name and you have one.

A tip is on hover and on focus, and the anchor names it. The children are the anchor, and a <mark.popup> replaces the label with markup. The panel sits inside the box Detached placed and wears no popover of its own: the box is already a popover, and a popover inside a popover is put in the top layer and laid out by the browser, which takes the panel out of the box and lands it in the middle of the screen (design 135). The pause before a hover shows it belongs to the behaviour, so every tip on a page waits the same time and there is no prop for it; focus shows it at once. Each element in the anchor gets aria-describedby naming the panel, written when the page comes alive. A static render leaves it out, because nothing on the client can write it before the pairing walk reaches the anchor, so markup carrying it could not be taken over (design 153). It is taken off again when the component unmounts.

A Tooltip takes over server markup and can sit inside an act a stage swaps away, both since design 153.

A drop down is a disclosure, not a floating menu. The content is the children, in the page's flow. The open cell goes both ways: writing it opens and closes the element, and a person opening it writes the cell. Space, Enter, the button role and the expanded state are the platform's, because the element is a <details>. A floating menu under a button is Detached with dismiss and a Button anchor, which is one call and no new component.

The country fields are a button and a dialog, and they ship no country data.

import { Countries, Country, Region } from '@aweftjs/ui';
import { countryData } from '@aweftjs/ui/countries';

const data = await countryData();

<Countries value={data}>
  <Country label="Country" value={country} name="country" autocomplete="country"
           priority={['CA', 'US']} placeholder="Pick a country" />
  <Region label="Province" value={region} country={country} name="region" />
</Countries>

Each is a button that opens a modal dialog with a search box over a grid of rows. Country's cell holds a two-letter code, Region's holds a subdivision's short code, and under each is a hidden <select> carrying the same options, so a form posts the code and autofill has a control to find. Nothing is chosen to begin with and nothing ever chooses for the person.

The names come from the host, the codes and the subdivisions from you. Intl.DisplayNames has a country's name in the page's own language, so no list of names ships here and locale is the only thing that decides which language they are in; the flags are the two letters of the code as regional indicator symbols, so no flags ship either, and flags={false} turns them off where a host draws none. What is left is the codes and the subdivisions, which nothing derives: countryData() reads the optional country-region-data peer, and CountryData is a shape you can build yourself instead if the form allows five countries rather than 249. A field with no Countries above it is a loud assert naming both.

A search matches the name, the code and this package's aliases, without accents: uk finds the United Kingdom, usa and america find the United States, cote finds Côte d'Ivoire. It matches anywhere in the word rather than only the start of it, so uk finds Ukraine as well. The aliases are countryAliases on the same subpath, and CountryData.aliases is the whole map rather than an addition to it: to add your own words, spread the shipped map into yours.

const data = await countryData();
<Countries value={{ ...data, aliases: { ...countryAliases, nz: ['aotearoa'] } }}>

A row is chosen with a click on it, or with Enter or Space while the keyboard is on it, which is the listbox map every list in this package shares: the arrows and Home and End move, Escape closes, and the search box keeps every printable character. Writing the cell chooses too, and that is the whole of the control's state: a page that knows the country already writes country.set('CA') and never opens the dialog.

The rows are ordered, and the first one is a suggestion. Intl.Locale(navigator.language).region says which country the browser's language settings point at, so a browser set to en-CA has Canada first, marked; a language with no region in it is maximized by the host, so pt is Brazil. It is the language setting and not the location: nothing is asked for, nothing is fetched, and a person filling a form for somewhere else is ordinary, which is why it suggests and never selects. suggest={false} turns it off. After it comes priority, in the order you wrote it, and then every other country by its name in the page's language.

Region is the same control and not a dropdown, because one country has 217 subdivisions and the middle one has 11 (design 251). Handed no country it is disabled; the country changing does not clear it, because a form clearing a field a person filled is the page's decision. Its names are English, which is what the data has.

Known limits. The grid is drawn the first time the dialog opens, so a static render emits the control, its dialog and the element a form posts, and none of the rows. A country that is already chosen when a page is rendered statically has its name in the markup, and if the server's ICU data and the browser's disagree about that name, the hydration refuses with both names in the message rather than showing the wrong one.

A file drop holds entries, and no upload.

const picked = mutableArray();
<FileDrop files={picked} extensions={['image/png', '.csv']} limit={4_000_000} ready={file} />

An entry is { name, file, status, error, reason }. A file the zone accepted starts as ready and one it refused as error, with error the sentence a person reads and reason the code you branch on: type for a file the extensions do not cover, size for one over limit, and count for a second file while multiple is false (design 214). An accepted entry carries neither. A refused file stays in the list rather than disappearing. Move status to loading while you upload by writing the entry back, files[0] = { ...files[0], status: 'loading' }, which is the edit a list can hear; that row then shows the LoaderContext's loader beside the file's name, and the dots when nothing named one (design 219). ready is written null while anything is loading, and otherwise the file, or the array of files when multiple is true, counting every entry that is not in error. The transport is yours: ui decides nothing about storage, transport or the server, so the entry carries the platform File.

With multiple false, a second pick replaces the entry that is there, in place, so a watch on the list hears a replace and not an add. A page listening for add alone sees the first file and none of the ones after it.

limit is bytes, and every sentence naming it is written for a person: KB, MB or GB, 1024 to a step, so a limit of 4_000_000 reads as 3.8 MB. The prompt names the accepted types the same way, so image/png reads as png, image/* reads as image and .csv reads as csv.

The zone listens for dragenter, dragleave and drop, and reads the dropped files off the event's dataTransfer.files; the input listens for change and reads its own files. Those four are the whole of what reaches this component from the host.

Children replace the prompt line and the listing. The input itself is visually hidden rather than display: none, so it is still focusable, and its label is the zone's prompt whichever chrome is showing. A FileDrop.Button standing on its own names its own input the same way, with an offscreen <label> carrying the button's label or its aria-label, because the button beside it is what shows the words. look.test.ts reads the filedrop_picker rule and fails if it ever becomes display: none.

FileDrop.Button is two things, decided by where it is (design 214). Inside a FileDrop it is a Button that opens that zone's input, and it takes no checking props of its own, because the zone already has them; giving it one is an assert. Outside a zone it is the picker with no chrome: its own hidden input, the same checks, and files, extensions, multiple, limit, onDrop and ready on the button itself.

<FileDrop.Button label="Change photo" extensions={['image/*']} multiple={false} ready={photo} />

A check is given the cell, not the value.

<ValidateContext value={allValid}>
	<Validate value={email} validate="email" signal={submitted}>
		<TextField label="Email" value={email} />
	</Validate>
</ValidateContext>

validate is handed the cell, not what the cell holds. So a check reads it with cell.get(), and one that formats what was typed writes it back with cell.set(...), which is how four of the eight built-ins work. It returns the problem as a string, or '' or null for no problem.

<Validate value={confirm} validate={(cell) => (cell.get() === password.get() ? '' : 'They do not match.')}>

validate is that function, or the name of one of eight built-ins: phone, email, pan, expDate, postalCode, date, number and float. Four of them write a formatted value back into the cell: phone, pan, expDate and postalCode. Each answers nothing for an empty value, so a field is not invalid before anybody has typed in it. They are small on purpose, each doing what its name promises and no more: email is a shape check, date is YYYY-MM-DD, postalCode is the Canadian one, and pan is a Luhn check on thirteen to nineteen digits. Write a function for anything else.

A signal is read, not counted. While that cell holds something falsy nothing is checked, and while it is truthy every change to value is checked (design 208). So a form that clears itself after a successful submit writes submitted back to false and goes quiet again, rather than marking every emptied field. Going quiet clears the message, writes valid true and writes error null. With no signal, checking is live from the start.

Under a ValidateContext, a check runs again when any cell that form is checking changes. That is what confirm-must-match needs: the validator above reads the other password's cell, and nothing else would tell it that the other password moved. A validator that reads a cell no Validate in the same form is checking is not re-run for it; put that control in a Validate too.

The message is rendered once, after the children, as a live region. It also reaches the control: a control of this package that was given no error of its own goes aria-invalid and its aria-describedby names that message. A Validate around a plain <input> still shows and announces the message and leaves the input unmarked, because nothing read it. showError false takes the message off the screen and leaves it announced.

A Validate wraps one control. Every control under it takes the message, so a Validate around two of them marks both invalid and points both at the one message. Two controls want two Validates.

A validator that throws is reported on a microtask the host sees, the way every handler a page wrote is reported here, and the value counts as invalid with the error's message. A form is never quietly valid because its check crashed.

ValidateContext writes its value cell true while every Validate under it is happy. Each one registers when it mounts and leaves when it unmounts, so a field that goes away stops holding the form invalid.

A colour picker is a square and one or two sliders. value is a cell holding CSS colour text, anything readColour reads, and it is written back as rgb() or rgba(); text that is not a colour is an assert naming the text. Saturation and brightness are one place in a square, dragged with a pointer or driven with the arrows; hue and opacity, the last only when hasAlpha is not false, are labelled range inputs with the platform's keyboard on them. The swatch beside them is aria-hidden, because it says what the rest already say. There is no eyedropper and no hex field; a TextField on the same cell is the hex field, because the cell is text.

The square's thumb is the one element in this package with a role written on it, because there is no two-axis role in ARIA (design 222). It is role="slider", focusable, and says both axes: aria-valuenow carries the saturation and aria-valuetext reads saturation 40%, brightness 80%. Left and right move saturation, up and down move brightness, Home and End take saturation to its ends, and Shift makes any of them coarse. A press anywhere in the square moves the thumb there, and a drag that leaves the square keeps working.

A drag, a key or a slider writes the cell, and nothing else does, so mounting a picker on a colour leaves that colour and its notation alone. With hasAlpha false a write keeps the alpha the cell already had: nothing on the screen can change an alpha nobody can see.

The square is $planeSize on each side and there is no size prop: it is a composite with no one height, so an application that wants a bigger square redefines $planeSize. The two gradients over it are the colorpicker_plane entry's, and the hue track's six hues are colorpicker_track_hue's. What the component writes is the colour that is chosen now, which does not exist until it runs: the hue under the square, the thumb's own fill and the opacity track are inline style. The theme check reads source text, and each of those is arithmetic rather than a value anybody typed, so there is nothing there for it to refuse.

A state prop takes a cell. open, enabled, value and files are cells or absent; give one a plain value and it is a loud assert naming the prop and the fix, because a component that quietly kept a cell of its own would look as though it had honoured what you asked for.

The theme entries these add, on top of the ones above: dialog with its ::backdrop, tooltip, disclosure and disclosure_summary, filedrop with dragging, prompt, list and entry, validate, and colorpicker with swatch, plane, plane_thumb, track and hue. The country fields add chooser and its parts: wrap, chosen, panel, head, search, grid, option with selected, active and suggested, flag, lines, note and none. offscreen is one more, and it is yours to use: it takes an element off the screen and leaves it in the reading order.

Each of them has a page on recipes/ui/catalogue.html, in both modes, driven in Chromium by recipes/ui/main.ts with axe-core over it.

Text

import { TextModifiers, Typography } from '@aweftjs/ui';

| export | what it is | its own props | example | |---|---|---|---| | Typography | one run of themed text: one element on the text entry | type, label, element, theme | typography | | TextModifiers | a context holding the list Typography runs over its label | value | typography | | TypographyProps | what Typography takes | | | | TextModifier | one entry of that list: { check, return } | | |

type is theme segments joined by _, and the first segment also picks the element. <Typography type="h2_bold" label="Today" /> is an <h2> themed text h2 bold, so text_h2 and text_bold both apply and the later segment wins where they disagree. h1 to h6 give that heading, p, p1 and p2 give a <p>, and everything else gives a <span>, so a word this package never defined is a segment like any other and your own text_eyebrow needs nothing from here. With no type it is a <span> on text alone. type may be a cell: the theme follows it, and the element does not, because an element lasts as long as the component.

label and children both render, label first, and neither is required. label may be a cell. element hands in the node to decorate instead of building one, which is how a heading's look goes on a level the document outline wanted instead.

TextModifiers turns parts of a label into something else. A modifier is { check, return }: check is a plain string, matched everywhere and case-insensitively, or a global regex, and return(match) answers whatever that piece becomes. Matches are collected in the order the modifiers are written, sorted by where they start, and an overlap goes to the one that started first, a tie to the one written first. A gap between matches is text.

<TextModifiers value={[{ check: /@\w+/g, return: (name) => <Tooltip label={who(name)}>{name}</Tooltip> }]}>
	<Typography type="p1" label={note} />
</TextModifiers>

Keys beyond those two are ignored, so a list written for something else passes through. A provider replaces the list above it rather than adding to it; a subtree that wants both writes both. A list held in a cell is read when each Typography below it mounts, as Icons is; a later write reaches what mounts after it. The list runs over label only, because a child is already markup and has nothing for a pattern to run over, and a label that is neither a string nor a number renders as given. A regex without the g flag is a loud assert naming the flag, because a pattern that finds one match is almost never what was meant. A cell label runs the pass again when it changes.

The label goes through one resolve step first. A text token resolves to its string in the render's language before the modifiers run, so a modifier matches the translated word; anything else is the label as given (design 278).

What it never does. No editing: nothing swaps an input in on a click and nothing measures text with a span, because editing is TextField's job and a page composes the two. No width cap: a measure is yours, as maxWidth on text_p1. No fonts: the theme engine already emits @font-face and @import from your own theme's directives.

recipes/ui/preview.html shows the whole family in both modes, and the gallery's modifier demo is recipes/ui/page.tsx, both driven by recipes/ui/main.ts.

The text a page shows

import { context, isText, localeOf, mount, text, textOf } from '@aweftjs/ui';

mount(document.body, <App />, undefined, context({ locale: 'fr', catalog: fr }));

text('Save') is a token: what a string a person reads becomes, so it can be looked up where the page mounts. With the build's text option on (packages/build/README.md), every literal a page shows is one already, and a page writes the call itself for a string built in code, a plural, or a sentence with an element inside it. A render with no catalog shows every token's source, so a page written this way runs before it has a second language.

| export | what it is | |---|---| | text(source, values?) | a token: mountable as a child, and resolved by h in a prop, by Typography in a label, and by a head component in a tag | | context({ locale, catalog }) | the language a render shows and its translations, a plain object from key to message | | textOf(context, value) | the string a token, a string or a number shows in a render, for code that needs characters: a document title set by hand. Takes the mount context or the render context() made, so a server module resolves against a render of its own | | localeOf(context) | the render's BCP 47 tag, for a date or a number formatted with Intl | | isText(value) | whether a value is a token | | usedText(render) | the keys a render's tokens looked up |

A token resolves where it mounts. As a child it is a component, so dom mounts it and a hydration pairs it beside a static sibling. An act module's title may be a token too, and the stage announces it in the page's language. In any prop, h claims it the way it claims theme: the resolved string is written into a cell dom binds under the same name, so placeholder, title, alt and aria-label carry the translation on the server's node after a hydration and on a fresh one after a mount. Typography resolves a token label before its modifiers, and a head component resolves a token child or attribute when it declares its tag. A token that mounts under dom's own mount, with no ui systems, shows its source. The key is the source string, or source|context when the call names a context word: text('Close', { context: 'dialog' }) is the key Close|dialog, which is how one word with two meanings gets two entries.

The message syntax is a subset of ICU MessageFormat, read here with no dependency:

| written | means | |---|---| | Hello {name} | the v