@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.
- package/dist/field-error-core.d.ts +26 -0
- package/dist/field-error-core.js +71 -0
- package/dist/field-errors.d.ts +15 -0
- package/dist/field-errors.js +274 -0
- package/dist/hc-alert.css +29 -1
- package/dist/hc-chip.css +39 -0
- package/dist/hc-item.css +4 -2
- package/dist/hc-table.css +21 -0
- package/dist/hc.behaviors.d.ts +4 -2
- package/dist/hc.behaviors.js +7 -1
- package/dist/hc.behaviors.min.js +2 -2
- package/dist/hc.core.css +33 -0
- package/dist/hc.core.min.css +1 -1
- package/dist/hc.css +204 -3
- package/dist/hc.min.css +1 -1
- package/dist/hc.min.js +2 -2
- package/dist/hc.tokens.core.css +33 -0
- package/dist/hc.tokens.css +61 -0
- package/dist/hc.tokens.neutral-neutral.css +7 -0
- package/dist/hc.tokens.neutral-slate.css +7 -0
- package/dist/hc.tokens.neutral-stone.css +7 -0
- package/dist/hc.tokens.neutral-zinc.css +7 -0
- package/dist/hc.utilities.css +49 -0
- package/dist/i18n.d.ts +12 -0
- package/dist/i18n.js +15 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/theme-toggle.d.ts +8 -0
- package/dist/theme-toggle.js +130 -0
- package/dist/validation.js +12 -30
- package/package.json +1 -1
- package/src/tokens/component.tokens.json +13 -1
- package/src/tokens/theme.dark.tokens.json +1 -0
|
@@ -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);
|
package/dist/hc-chip.css
ADDED
|
@@ -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
|
package/dist/hc.behaviors.d.ts
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
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";
|
package/dist/hc.behaviors.js
CHANGED
|
@@ -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';
|