@hypermedia-components/core 0.0.1-alpha.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ /** @param {Element} control */
2
+ export function fieldOf(control: Element): Element;
3
+ /**
4
+ * Append `id` to the control's `aria-describedby` token list (idempotent).
5
+ *
6
+ * @param {Element} control
7
+ * @param {string} id
8
+ */
9
+ export function ensureDescribedBy(control: Element, id: string): void;
10
+ /**
11
+ * Remove `id` from the control's `aria-describedby` token list, dropping
12
+ * the attribute entirely when no tokens remain.
13
+ *
14
+ * @param {Element} control
15
+ * @param {string} id
16
+ */
17
+ export function pruneDescribedBy(control: Element, id: string): void;
18
+ /**
19
+ * Find or create the field's `.hc-field__error` element, give it a stable
20
+ * id, and point the control's `aria-describedby` at it.
21
+ *
22
+ * @param {Element} field
23
+ * @param {Element} control
24
+ * @returns {Element}
25
+ */
26
+ export function getOrCreateError(field: Element, control: Element): Element;
@@ -0,0 +1,71 @@
1
+ // Shared field-error plumbing for `installValidation()` (native constraint
2
+ // validation) and `installFieldErrors()` (server-sent validation errors).
3
+ //
4
+ // Both behaviors surface a message inside a field's `.hc-field__error`
5
+ // element with identical ARIA wiring (`aria-describedby` on the control,
6
+ // `aria-live` on the message). Keeping the mechanics in one module
7
+ // guarantees the two error sources stay byte-identical instead of
8
+ // drifting copies. Internal module — not part of the public entry.
9
+
10
+ let errorIdSeq = 0;
11
+
12
+ /** @param {Element} control */
13
+ export function fieldOf(control) {
14
+ return control?.closest?.('.hc-field') ?? null;
15
+ }
16
+
17
+ /**
18
+ * Append `id` to the control's `aria-describedby` token list (idempotent).
19
+ *
20
+ * @param {Element} control
21
+ * @param {string} id
22
+ */
23
+ export function ensureDescribedBy(control, id) {
24
+ const existing = (control.getAttribute('aria-describedby') ?? '')
25
+ .split(/\s+/)
26
+ .filter(Boolean);
27
+ if (!existing.includes(id)) {
28
+ existing.push(id);
29
+ control.setAttribute('aria-describedby', existing.join(' '));
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Remove `id` from the control's `aria-describedby` token list, dropping
35
+ * the attribute entirely when no tokens remain.
36
+ *
37
+ * @param {Element} control
38
+ * @param {string} id
39
+ */
40
+ export function pruneDescribedBy(control, id) {
41
+ const existing = (control.getAttribute('aria-describedby') ?? '')
42
+ .split(/\s+/)
43
+ .filter(Boolean)
44
+ .filter((token) => token !== id);
45
+ if (existing.length) {
46
+ control.setAttribute('aria-describedby', existing.join(' '));
47
+ } else {
48
+ control.removeAttribute('aria-describedby');
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Find or create the field's `.hc-field__error` element, give it a stable
54
+ * id, and point the control's `aria-describedby` at it.
55
+ *
56
+ * @param {Element} field
57
+ * @param {Element} control
58
+ * @returns {Element}
59
+ */
60
+ export function getOrCreateError(field, control) {
61
+ let error = field.querySelector('.hc-field__error');
62
+ if (!error) {
63
+ error = field.ownerDocument.createElement('p');
64
+ error.className = 'hc-field__error';
65
+ error.setAttribute('aria-live', 'polite');
66
+ field.appendChild(error);
67
+ }
68
+ if (!error.id) error.id = `hc-field-error-${(errorIdSeq += 1)}`;
69
+ ensureDescribedBy(control, error.id);
70
+ return error;
71
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Install the field-errors behavior: distribute server-sent
3
+ * `[data-hc-field-errors]` fragments (see the `field-errors` recipe) to
4
+ * the form fields they name, with the same ARIA wiring
5
+ * `installValidation()` uses.
6
+ *
7
+ * Fragments are picked up when swapped in by htmx (`htmx:afterSwap` /
8
+ * `htmx:oobAfterSwap`), when inserted by any other means
9
+ * (`MutationObserver`), and once at install time for a full-page render.
10
+ *
11
+ * @param {Document|Element} [root]
12
+ * The root to watch. Defaults to the global document when available.
13
+ * @returns {() => void} an idempotent uninstaller.
14
+ */
15
+ export function installFieldErrors(root?: Document | Element): () => void;
@@ -0,0 +1,274 @@
1
+ // installFieldErrors — distribute a server-sent validation-error fragment
2
+ // to the form fields it names (recipe `field-errors`).
3
+ //
4
+ // The canonical fragment (the server returns this on e.g. a 422; htmx
5
+ // swaps it into a container inside — or pointed at — the form):
6
+ //
7
+ // <div class="hc-alert" data-variant="error" role="alert" data-hc-field-errors>
8
+ // <p class="hc-alert__title">Unprocessable Entity</p>
9
+ // <ul class="hc-alert__errors">
10
+ // <li class="hc-alert__error" data-field="email" data-code="duplicate"
11
+ // data-message-key="members.email.duplicate">email: duplicate</li>
12
+ // </ul>
13
+ // <p class="hc-alert__body">optional hint line</p>
14
+ // </div>
15
+ //
16
+ // `data-hc-field-errors` is the opt-in: empty means "distribute into the
17
+ // closest form"; a non-empty value is a CSS selector for the form (for
18
+ // out-of-band swaps or an alert rendered outside the form).
19
+ //
20
+ // For each `.hc-alert__error[data-field]` whose name matches a control in
21
+ // the form, the behavior writes the message into the field's
22
+ // `.hc-field__error` (creating one after a bare control), sets
23
+ // `aria-invalid` + `aria-describedby` on the control and `data-invalid`
24
+ // on the field — the same wiring installValidation() uses — and marks the
25
+ // item `data-distributed` so the summary doesn't read it twice. Items
26
+ // naming no known control stay visible in the summary. The first invalid
27
+ // control is focused (opt out with `data-focus="none"` on the alert).
28
+ //
29
+ // Message resolution per item: `data-message-key` found in the i18n
30
+ // catalog → `t(key, { field, code })`; otherwise the item's own text;
31
+ // otherwise `t('fieldErrors.unknown')`. Localize once via `setMessages()`.
32
+ //
33
+ // Server errors are stale the moment the user edits the field or
34
+ // resubmits: cleared on first `input`/`change` per field, on `submit` /
35
+ // `reset` of the form, and before re-distributing a newly swapped-in
36
+ // fragment. Native constraint validation outranks a server error on the
37
+ // same control (the native message reflects the current value).
38
+ //
39
+ // The behavior never makes a request — htmx owns the network. State lives
40
+ // in HTML attributes; install is idempotent and returns an uninstaller.
41
+
42
+ import {
43
+ ensureDescribedBy,
44
+ fieldOf,
45
+ getOrCreateError,
46
+ pruneDescribedBy,
47
+ } from './field-error-core.js';
48
+ import { hasMessage, t } from './i18n.js';
49
+
50
+ const INSTALL_KEY = '__hcFieldErrorsUninstall';
51
+
52
+ function escapeName(name) {
53
+ if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(name);
54
+ return name.replace(/["\\]/g, '\\$&');
55
+ }
56
+
57
+ // Resolve the scope (normally the <form>) an alert distributes into.
58
+ function scopeOf(alert, root) {
59
+ const selector = alert.getAttribute('data-hc-field-errors');
60
+ if (selector) {
61
+ const doc = root.nodeType === 9 ? root : root.ownerDocument;
62
+ return doc.querySelector(selector);
63
+ }
64
+ return alert.closest('form');
65
+ }
66
+
67
+ // First control in the scope whose `name` matches. `form.elements`
68
+ // handles radio/checkbox groups natively (RadioNodeList → first member).
69
+ function controlFor(scope, name) {
70
+ let found;
71
+ if (scope.elements && typeof scope.elements.namedItem === 'function') {
72
+ found = scope.elements.namedItem(name);
73
+ } else {
74
+ found = scope.querySelector(`[name="${escapeName(name)}"]`);
75
+ }
76
+ if (found && found.tagName == null && typeof found.length === 'number') {
77
+ found = found[0] ?? null; // RadioNodeList
78
+ }
79
+ return found ?? null;
80
+ }
81
+
82
+ function resolveMessage(item) {
83
+ const key = item.getAttribute('data-message-key');
84
+ const params = {
85
+ field: item.getAttribute('data-field') ?? '',
86
+ code: item.getAttribute('data-code') ?? '',
87
+ };
88
+ if (key && hasMessage(key)) return t(key, params);
89
+ const text = item.textContent.trim();
90
+ if (text) return text;
91
+ return t('fieldErrors.unknown', params);
92
+ }
93
+
94
+ let ownedIdSeq = 0;
95
+
96
+ // Write one or more messages (one per line) into the field's error slot —
97
+ // or into a created slot right after a control that has no `.hc-field`.
98
+ function applyErrors(control, messages) {
99
+ const doc = control.ownerDocument;
100
+ const field = fieldOf(control);
101
+ let error;
102
+ if (field) {
103
+ error = getOrCreateError(field, control);
104
+ field.setAttribute('data-invalid', 'true');
105
+ } else {
106
+ error = doc.createElement('p');
107
+ error.className = 'hc-field__error';
108
+ error.setAttribute('aria-live', 'polite');
109
+ // "owned" = created by this behavior next to a bare control; removed
110
+ // entirely on clear (a field's shared slot is only emptied).
111
+ error.setAttribute('data-hc-server-error-owned', '');
112
+ error.id = `hc-server-error-${(ownedIdSeq += 1)}`;
113
+ control.insertAdjacentElement('afterend', error);
114
+ ensureDescribedBy(control, error.id);
115
+ }
116
+ error.textContent = '';
117
+ messages.forEach((message, index) => {
118
+ if (index > 0) error.appendChild(doc.createElement('br'));
119
+ error.appendChild(doc.createTextNode(message));
120
+ });
121
+ error.setAttribute('data-hc-server-error', '');
122
+ control.setAttribute('aria-invalid', 'true');
123
+ control.dataset.hcServerInvalid = '';
124
+ }
125
+
126
+ // Clear every server error inside the scope (errors created by us are
127
+ // removed; errors living in a field's shared slot are emptied).
128
+ function clearServerErrors(scope) {
129
+ for (const control of scope.querySelectorAll('[data-hc-server-invalid]')) {
130
+ clearServerErrorFor(control);
131
+ }
132
+ }
133
+
134
+ function clearServerErrorFor(control) {
135
+ delete control.dataset.hcServerInvalid;
136
+ control.removeAttribute('aria-invalid');
137
+ const field = fieldOf(control);
138
+ if (field) field.removeAttribute('data-invalid');
139
+ // The error element(s) this control points at via aria-describedby.
140
+ const doc = control.ownerDocument;
141
+ const ids = (control.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean);
142
+ for (const id of ids) {
143
+ const error = doc.getElementById(id);
144
+ if (!error || !error.hasAttribute('data-hc-server-error')) continue;
145
+ if (error.hasAttribute('data-hc-server-error-owned')) {
146
+ pruneDescribedBy(control, id);
147
+ error.remove();
148
+ } else {
149
+ error.textContent = '';
150
+ error.removeAttribute('data-hc-server-error');
151
+ }
152
+ }
153
+ }
154
+
155
+ function distribute(alert, root) {
156
+ if (alert.dataset.distributed != null) return;
157
+
158
+ const scope = scopeOf(alert, root);
159
+ if (!scope) {
160
+ // No form to distribute into — everything stays in the summary.
161
+ alert.dataset.distributed = 'none';
162
+ return;
163
+ }
164
+
165
+ clearServerErrors(scope);
166
+
167
+ const items = alert.querySelectorAll('.hc-alert__error[data-field]');
168
+ // Group by control so several errors for one field render one per line.
169
+ const perControl = new Map();
170
+ let distributed = 0;
171
+ let total = 0;
172
+ for (const item of items) {
173
+ total += 1;
174
+ const control = controlFor(scope, item.getAttribute('data-field'));
175
+ if (!control) continue; // unknown field — stays visible in the summary
176
+ if (!perControl.has(control)) perControl.set(control, []);
177
+ perControl.get(control).push(resolveMessage(item));
178
+ item.setAttribute('data-distributed', 'true');
179
+ distributed += 1;
180
+ }
181
+
182
+ for (const [control, messages] of perControl) {
183
+ applyErrors(control, messages);
184
+ }
185
+
186
+ alert.dataset.distributed =
187
+ distributed === 0 ? 'none' : distributed === total ? 'all' : 'partial';
188
+
189
+ if (alert.getAttribute('data-focus') !== 'none') {
190
+ const first = perControl.keys().next().value;
191
+ try {
192
+ first?.focus?.();
193
+ } catch {
194
+ /* unfocusable control — the wiring still stands */
195
+ }
196
+ }
197
+ }
198
+
199
+ function scan(node, root) {
200
+ if (!node || typeof node.querySelectorAll !== 'function') return;
201
+ if (typeof node.matches === 'function' && node.matches('[data-hc-field-errors]')) {
202
+ distribute(node, root);
203
+ }
204
+ for (const alert of node.querySelectorAll('[data-hc-field-errors]')) {
205
+ distribute(alert, root);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Install the field-errors behavior: distribute server-sent
211
+ * `[data-hc-field-errors]` fragments (see the `field-errors` recipe) to
212
+ * the form fields they name, with the same ARIA wiring
213
+ * `installValidation()` uses.
214
+ *
215
+ * Fragments are picked up when swapped in by htmx (`htmx:afterSwap` /
216
+ * `htmx:oobAfterSwap`), when inserted by any other means
217
+ * (`MutationObserver`), and once at install time for a full-page render.
218
+ *
219
+ * @param {Document|Element} [root]
220
+ * The root to watch. Defaults to the global document when available.
221
+ * @returns {() => void} an idempotent uninstaller.
222
+ */
223
+ export function installFieldErrors(root = (typeof document !== 'undefined' ? document : null)) {
224
+ if (!root) return () => {};
225
+ if (root[INSTALL_KEY]) return root[INSTALL_KEY];
226
+
227
+ function onSwap(event) {
228
+ scan(event.target, root);
229
+ }
230
+
231
+ function onInput(event) {
232
+ const el = event.target;
233
+ if (el?.dataset?.hcServerInvalid != null) clearServerErrorFor(el);
234
+ }
235
+
236
+ function onSubmitOrReset(event) {
237
+ const form = event.target;
238
+ if (form && typeof form.querySelectorAll === 'function') clearServerErrors(form);
239
+ }
240
+
241
+ root.addEventListener('htmx:afterSwap', onSwap);
242
+ root.addEventListener('htmx:oobAfterSwap', onSwap);
243
+ root.addEventListener('input', onInput);
244
+ root.addEventListener('change', onInput);
245
+ root.addEventListener('submit', onSubmitOrReset, true);
246
+ root.addEventListener('reset', onSubmitOrReset, true);
247
+
248
+ const observer = new MutationObserver((mutations) => {
249
+ for (const mutation of mutations) {
250
+ for (const node of mutation.addedNodes) {
251
+ if (node.nodeType === 1) scan(node, root);
252
+ }
253
+ }
254
+ });
255
+ const observeTarget = root.nodeType === 9 ? root.documentElement : root;
256
+ observer.observe(observeTarget, { childList: true, subtree: true });
257
+
258
+ // A full-page error render (no swap) is distributed immediately.
259
+ scan(observeTarget, root);
260
+
261
+ const uninstall = () => {
262
+ if (root[INSTALL_KEY] !== uninstall) return;
263
+ root.removeEventListener('htmx:afterSwap', onSwap);
264
+ root.removeEventListener('htmx:oobAfterSwap', onSwap);
265
+ root.removeEventListener('input', onInput);
266
+ root.removeEventListener('change', onInput);
267
+ root.removeEventListener('submit', onSubmitOrReset, true);
268
+ root.removeEventListener('reset', onSubmitOrReset, true);
269
+ observer.disconnect();
270
+ delete root[INSTALL_KEY];
271
+ };
272
+ root[INSTALL_KEY] = uninstall;
273
+ return uninstall;
274
+ }
package/dist/hc-alert.css CHANGED
@@ -1,7 +1,10 @@
1
1
  /* hc-alert — block-level notice / status message.
2
2
  *
3
3
  * Variants via data-variant: info (default) | success | warning | error
4
- * Optional parts: .hc-alert__title, .hc-alert__body
4
+ * Optional parts: .hc-alert__title, .hc-alert__body,
5
+ * .hc-alert__errors > .hc-alert__error (field-error list — see the
6
+ * `field-errors` recipe; `installFieldErrors()` distributes the items
7
+ * to their fields and marks them `data-distributed`).
5
8
  *
6
9
  * Accessibility: set role="status" (or aria-live="polite") on the
7
10
  * element for transient updates; role="alert" for urgent ones. The
@@ -34,6 +37,31 @@
34
37
  margin: 0;
35
38
  }
36
39
 
40
+ /* Field-error list (server validation fragment — `field-errors` recipe). */
41
+ .hc-alert__errors {
42
+ margin: 0;
43
+ padding-inline-start: 1.25em;
44
+ }
45
+
46
+ /* An item that installFieldErrors() has distributed to its field is
47
+ * hidden in the summary so the message isn't read twice. Items naming
48
+ * no known control keep rendering here. */
49
+ .hc-alert[data-hc-field-errors] .hc-alert__error[data-distributed="true"] {
50
+ display: none;
51
+ }
52
+
53
+ /* …and when every item was distributed, the now-empty list collapses. */
54
+ .hc-alert[data-hc-field-errors]
55
+ .hc-alert__errors:not(:has(.hc-alert__error:not([data-distributed="true"]))) {
56
+ display: none;
57
+ }
58
+
59
+ /* Opt-in: a fragment that carries only field errors (no standalone
60
+ * summary worth keeping) hides itself entirely once fully distributed. */
61
+ .hc-alert[data-hc-field-errors][data-summary="auto"][data-distributed="all"] {
62
+ display: none;
63
+ }
64
+
37
65
  .hc-alert[data-variant="success"] {
38
66
  background: var(--hc-alert-success-bg);
39
67
  color: var(--hc-alert-success-fg);
@@ -0,0 +1,39 @@
1
+ /* hc-chip — a quiet, pill-shaped token for facts and attributes
2
+ * (capabilities, tags, applied filters). Neutral surface + border by
3
+ * design: for *status* coloring use hc-badge (variant pills) or the
4
+ * .hc-status utility instead.
5
+ *
6
+ * <ul class="hc-chips">
7
+ * <li class="hc-chip">read:members</li>
8
+ * <li class="hc-chip">write:members</li>
9
+ * </ul>
10
+ *
11
+ * Pure CSS, no behavior. A chip with a trailing remove control is the
12
+ * multicombobox tag's job — chips here are presentational.
13
+ */
14
+ @layer hc.components {
15
+ .hc-chip {
16
+ display: inline-flex;
17
+ align-items: center;
18
+ gap: var(--hc-space-1);
19
+ padding-inline: var(--hc-chip-padding-x);
20
+ padding-block: var(--hc-chip-padding-y);
21
+ border: 1px solid var(--hc-chip-border);
22
+ border-radius: var(--hc-chip-radius);
23
+ background: var(--hc-chip-bg);
24
+ color: var(--hc-chip-fg);
25
+ font-size: var(--hc-chip-font-size);
26
+ line-height: 1.4;
27
+ }
28
+
29
+ /* List wrapper — strips the list chrome and wraps chips on a gap. The
30
+ * <ul>/<li> form keeps the set announceable as a list ("3 items"). */
31
+ .hc-chips {
32
+ display: flex;
33
+ flex-wrap: wrap;
34
+ gap: var(--hc-chip-gap);
35
+ margin: 0;
36
+ padding: 0;
37
+ list-style: none;
38
+ }
39
+ }
package/dist/hc-item.css CHANGED
@@ -52,9 +52,11 @@
52
52
  outline-offset: -2px;
53
53
  }
54
54
 
55
- /* Selected — option / list selection. */
55
+ /* Selected — option / list selection — and the current page in a nav
56
+ * list (aria-current="page" on the active sidebar link). */
56
57
  .hc-item[aria-selected="true"],
57
- .hc-item[data-selected] {
58
+ .hc-item[data-selected],
59
+ .hc-item[aria-current]:not([aria-current="false"]) {
58
60
  background: var(--hc-item-selected-bg);
59
61
  color: var(--hc-item-selected-fg);
60
62
  }
package/dist/hc-table.css CHANGED
@@ -38,6 +38,27 @@
38
38
  padding-block: var(--hc-space-1);
39
39
  }
40
40
 
41
+ /* Key-value variant — a two-column definition table for detail views:
42
+ *
43
+ * <table class="hc-table" data-variant="kv">
44
+ * <tbody>
45
+ * <tr><th scope="row">Realm id</th><td>local</td></tr>
46
+ * <tr><th scope="row">Created</th><td>2026-06-01</td></tr>
47
+ * </tbody>
48
+ * </table>
49
+ *
50
+ * Keys are row headers (<th scope="row">) on a fixed inline size with
51
+ * muted text; values keep the body styling. No <thead> needed. */
52
+ .hc-table[data-variant="kv"] tbody th {
53
+ inline-size: var(--hc-table-kv-key-width);
54
+ color: var(--hc-table-kv-key-fg);
55
+ font-weight: var(--hc-table-header-weight);
56
+ }
57
+
58
+ .hc-table[data-variant="kv"] tbody tr:hover {
59
+ background: none;
60
+ }
61
+
41
62
  /* Horizontal-scroll wrapper for wide tables on narrow viewports. A
42
63
  * data table has no intrinsic way to shrink below its columns' content
43
64
  * width, so wrap it in this element to confine the overflow to a
@@ -27,5 +27,7 @@ import { installSplitter } from './splitter.js';
27
27
  import { installShell } from './shell.js';
28
28
  import { installDatagrid } from './datagrid.js';
29
29
  import { installValidation } from './validation.js';
30
- export { installConfirm, installToast, installCloseDialog, installClosePopover, installRemoteDialog, installTabs, installMenu, installMenubar, installNavmenu, installTooltip, installPopover, installSlider, installCombobox, installMulticombobox, installDrawer, installHovercard, installToggleGroup, installCarousel, installToolbar, installAvatar, installPasswordToggle, installContextMenu, installCommand, installCalendar, installInputOtp, installSplitter, installShell, installDatagrid, installValidation };
31
- export { setMessages, resetMessages, getMessages, DEFAULT_MESSAGES } from "./i18n.js";
30
+ import { installThemeToggle } from './theme-toggle.js';
31
+ import { installFieldErrors } from './field-errors.js';
32
+ export { installConfirm, installToast, installCloseDialog, installClosePopover, installRemoteDialog, installTabs, installMenu, installMenubar, installNavmenu, installTooltip, installPopover, installSlider, installCombobox, installMulticombobox, installDrawer, installHovercard, installToggleGroup, installCarousel, installToolbar, installAvatar, installPasswordToggle, installContextMenu, installCommand, installCalendar, installInputOtp, installSplitter, installShell, installDatagrid, installValidation, installThemeToggle, installFieldErrors };
33
+ export { setMessages, resetMessages, getMessages, hasMessage, DEFAULT_MESSAGES } from "./i18n.js";
@@ -36,6 +36,8 @@ import { installSplitter } from './splitter.js';
36
36
  import { installShell } from './shell.js';
37
37
  import { installDatagrid } from './datagrid.js';
38
38
  import { installValidation } from './validation.js';
39
+ import { installThemeToggle } from './theme-toggle.js';
40
+ import { installFieldErrors } from './field-errors.js';
39
41
 
40
42
  function init() {
41
43
  installConfirm();
@@ -67,6 +69,8 @@ function init() {
67
69
  installShell();
68
70
  installDatagrid();
69
71
  installValidation();
72
+ installThemeToggle();
73
+ installFieldErrors();
70
74
  }
71
75
 
72
76
  if (typeof document !== 'undefined') {
@@ -107,9 +111,11 @@ export {
107
111
  installShell,
108
112
  installDatagrid,
109
113
  installValidation,
114
+ installThemeToggle,
115
+ installFieldErrors,
110
116
  };
111
117
 
112
118
  // i18n — set the locale before this module's auto-init runs (e.g. inline
113
119
  // before the script that imports it), or import the named installers from
114
120
  // the main entry for full control over ordering.
115
- export { setMessages, resetMessages, getMessages, DEFAULT_MESSAGES } from './i18n.js';
121
+ export { setMessages, resetMessages, getMessages, hasMessage, DEFAULT_MESSAGES } from './i18n.js';