How to use a11y-elements?
Install from npm
Requires Nutin 2.1.1 or later.
npm install a11y-elements
Import each element you use by its own subpath, in src/app/main.ts. There is no default export: import 'a11y-elements' alone fails.
// src/app/main.ts
import 'a11y-elements/overlays/dropdown';
import 'a11y-elements/components/spinner';
The package ships its own types. Importing an element's subpath also types exact-tag lookups, such as document.querySelector('a11y-spinner').
Keep these imports in main.ts, not in a component or view file.
Styles
Default styles are never auto-injected. Load them once, from src/styles/main.scss:
// src/styles/main.scss
@use "pkg:a11y-elements/a11y.css";
Every visual value is a --a11y-<component>-<token> CSS custom property, so retheming is plain CSS:
:root {
--a11y-spinner-color: var(--primary);
}
Use elements in templates
Elements are plain markup in any component's or view's .html template:
<!-- e.g. home.view.html -->
<a11y-spinner></a11y-spinner>
Use overlays inside components
Overlays (<a11y-dropdown>, <a11y-modal>, <a11y-drawer>, ...) move themselves to <body> as soon as they are connected, even while closed. When the elements are already defined, that happens during the component's render - before Nutin processes data-i18n and data-event in its template.
So, for any content inside an overlay:
- Translate text by interpolating
I18nService.translatein the template, not withdata-i18n. - Handle clicks by delegation on the overlay itself, not with
data-event. - Look the overlay up by
idin the wholedocument, not inthis.element. - Remove it yourself when the component is destroyed or re-rendered: it no longer lives inside the component's element.
<!-- src/app/components/menu/menu.component.html -->
<button type="button" id="menu-anchor" aria-haspopup="menu" data-i18n="menu.open"></button>
<a11y-dropdown id="menu-dropdown" anchor="menu-anchor">
<a href="/" role="menuitem">${t('home')}</a>
<a href="/about" role="menuitem">${t('about')}</a>
</a11y-dropdown>
// src/app/components/menu/menu.component.ts
import { Component, I18nService, Navigation } from '../../../core/index.js';
const t = (key: string) => I18nService.translate(`menu.${key}`);
const templateFn = () => `__TEMPLATE_PLACEHOLDER__`;
const DROPDOWN_ID = 'menu-dropdown';
export class MenuComponent extends Component {
private dropdown: HTMLElement | null = null;
constructor(mountTarget: HTMLElement) {
super({ templateFn, mountTarget });
}
protected override onAfterRender(): void {
// A re-render builds a fresh dropdown: drop the previous one first.
this.dropdown?.remove();
this.dropdown = document.getElementById(DROPDOWN_ID);
if (this.dropdown) {
const onClick = (event: Event) => this.onItemClick(event);
this.dropdown.addEventListener('click', onClick);
this.eventListeners.push([this.dropdown, 'click', onClick]);
}
super.onAfterRender();
}
protected override onBeforeDestroy(): void {
this.dropdown?.remove();
this.dropdown = null;
super.onBeforeDestroy();
}
private onItemClick(event: Event): void {
const item = (event.target as HTMLElement).closest<HTMLAnchorElement>('a[role="menuitem"]');
if (!item) return;
event.preventDefault();
Navigation.navigateTo(item.getAttribute('href') ?? '/');
}
}
Listeners pushed to this.eventListeners are removed along with the component's own.
Once moved, a dropdown is wrapped in a .a11y-dropdown-wrapper element labelled by its anchor, which you can target from the component's .scss:
// src/app/components/menu/menu.component.scss
.a11y-dropdown-wrapper[aria-labelledby='menu-anchor'] {
--a11y-dropdown-color-background: var(--surface);
}
Important
- Never put
data-i18non an<a11y-*>element itself. It sets the element'stextContent, wiping out the markup the element builds. - Open and close overlays through the
openattribute (setAttribute('open', '')/removeAttribute('open')), not the.openproperty. A property set before the element is defined shadows its accessor for good.
Translate built-in strings
A few English strings end up in accessible names and announcements (e.g. Loading on <a11y-spinner>, Close dialog on modal close buttons). Replace them with setStrings(), after translations are loaded and on every language change:
// src/app/main.ts
import { setStrings } from 'a11y-elements/core';
const applyA11yStrings = () => setStrings({
loading: I18nService.translate('a11y.loading'),
closeDialog: I18nService.translate('a11y.close-dialog'),
});
document.addEventListener('DOMContentLoaded', async () => {
await I18nService.initTranslations();
applyA11yStrings();
I18nService.onLanguageChange(applyA11yStrings);
new App();
});
With the matching keys in e.g. src/app/a11y/locales/en.json:
{
"loading": "Loading",
"close-dialog": "Close dialog"
}
Elements already on the page update right away. See a11y-elements' README for every key.
SEO files generation
With generateSEOFiles enabled, pre-rendered pages contain the <a11y-*> tags before their elements are defined. Hide overlays until then, so their content never flashes inline:
a11y-dropdown:not(:defined) {
display: none;
}
Alternative: loading from a CDN
Every element also ships as a standalone, self-contained browser bundle, with no build step. Load the stylesheet and each element you use, pinned to an exact version:
<!-- src/index.html -->
<head>
<!-- ... -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/a11y-elements@0.2.0/dist/a11y.css" />
<script type="module" src="https://cdn.jsdelivr.net/npm/a11y-elements@0.2.0/dist/browser/overlays/dropdown/define.js"></script>
</head>
- Bundles live under
dist/browser/components/<name>/define.jsanddist/browser/overlays/<name>/define.js. setStrings()is exported from everydefine.js.- The package's types aren't available to your code: declare the few element APIs you call as structural types, e.g.
type Snackbar = HTMLElement & { notify(message: string): void }. - Everything under Use overlays inside components still applies.
Note: If you're using Nutin's docker feature, you'll have to add the CDN source to nginx CSP map, for both script-src and style-src. Scope it to the version's path (https://cdn.jsdelivr.net/npm/a11y-elements@0.2.0/) rather than the whole CDN.