@apliteni/apliteni-ui 0.27.0 → 0.30.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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/react/README.md +49 -1
  3. package/react/dist/index.css +11 -1
  4. package/react/dist/index.d.ts +61 -2
  5. package/react/dist/index.js +514 -121
  6. package/src/components/back.js +57 -0
  7. package/src/components/command-palette.js +597 -0
  8. package/src/components/confirm.js +3 -2
  9. package/src/components/drawer.js +31 -2
  10. package/src/components/dropdown.js +192 -17
  11. package/src/components/feedback.js +2 -2
  12. package/src/components/index.js +5 -2
  13. package/src/components/loading.js +3 -2
  14. package/src/components/nav.js +15 -7
  15. package/src/components/overlay.js +67 -14
  16. package/src/components/shell.js +11 -2
  17. package/src/components/tabs.js +7 -1
  18. package/src/components/tooltip.js +237 -0
  19. package/src/components/topbar.js +9 -4
  20. package/src/index.css +3 -0
  21. package/src/index.js +3 -0
  22. package/src/inline.js +6 -0
  23. package/src/motion.js +43 -2
  24. package/src/styles/back.css +42 -0
  25. package/src/styles/badge.css +5 -7
  26. package/src/styles/base.css +8 -7
  27. package/src/styles/card.css +12 -8
  28. package/src/styles/code.css +3 -4
  29. package/src/styles/command-palette.css +321 -0
  30. package/src/styles/confirm.css +11 -11
  31. package/src/styles/drawer.css +57 -15
  32. package/src/styles/dropdown.css +70 -11
  33. package/src/styles/feedback.css +2 -0
  34. package/src/styles/footer.css +3 -4
  35. package/src/styles/input.css +1 -1
  36. package/src/styles/layout.css +3 -2
  37. package/src/styles/loading.css +5 -0
  38. package/src/styles/nav.css +12 -8
  39. package/src/styles/success.css +3 -2
  40. package/src/styles/table.css +4 -5
  41. package/src/styles/tabs.css +3 -0
  42. package/src/styles/tooltip.css +65 -0
  43. package/src/styles/topbar.css +3 -4
  44. package/src/tokens/tokens.css +8 -8
@@ -68,6 +68,33 @@ export function drawer({
68
68
  + `</aside></div>`;
69
69
  }
70
70
 
71
+ /**
72
+ * One group inside a drawer's body: an optional heading over label/value rows,
73
+ * then any trailing markup (an action that belongs to the group). Rows render as
74
+ * a <dl>, so a screen reader hears each label with its value.
75
+ *
76
+ * drawer({ title, body: drawerSection({ title: 'Source', rows: [['Statement', '#4102']] }) })
77
+ *
78
+ * why: docs/specification.md#the-drawer
79
+ *
80
+ * @param {object} [o]
81
+ * @param {string} [o.title] group heading (escaped)
82
+ * @param {Array<[string, string | { html: string }]>} [o.rows] [label, value] pairs. Both are
83
+ * escaped, since a value is usually data (a bank feed's description); pass the value as
84
+ * `{ html: '…' }` to write trusted markup as it is
85
+ * @param {string} [o.body] trailing markup after the rows (trusted)
86
+ * @returns {string} html
87
+ */
88
+ export function drawerSection({ title, rows = [], body = '' } = {}) {
89
+ const head = title ? `<h3 class="ui-drawer__section-title">${esc(title)}</h3>` : '';
90
+ const value = (v) => (v !== null && typeof v === 'object' && 'html' in v ? String(v.html) : esc(v));
91
+ const list = rows.length
92
+ ? `<dl class="ui-drawer__rows">${rows.map(([term, detail]) =>
93
+ `<div class="ui-drawer__row"><dt>${esc(term)}</dt><dd>${value(detail)}</dd></div>`).join('')}</dl>`
94
+ : '';
95
+ return `<section class="ui-drawer__section">${head}${list}${body}</section>`;
96
+ }
97
+
71
98
  // ---- Shared behaviour ----------------------------------------------------
72
99
  // One open/close/scrim/close-button implementation for every drawer in the kit;
73
100
  // inertness, Escape and Tab belong to the overlay stack. Per-instance handlers
@@ -92,11 +119,13 @@ export function openDrawer(root, returnFocusTo) {
92
119
  export function closeDrawer(root) {
93
120
  if (!root || !root.classList.contains('is-open')) return;
94
121
  root.classList.remove('is-open');
95
- popOverlay(root);
96
122
  const back = root.__drawerReturn;
97
123
  root.__drawerReturn = null;
98
124
  // The trigger can be gone by now — focus() on a detached node does nothing at
99
- // all, which leaves the reader with no place on the page.
125
+ // all, which leaves the reader with no place on the page. popOverlay is told
126
+ // where focus is headed so it can catch the other way that happens: a trigger
127
+ // still on the page, inside what an overlay left open underneath has re-hidden.
128
+ popOverlay(root, back);
100
129
  returnFocus(back, root.ownerDocument);
101
130
  }
102
131
 
@@ -16,7 +16,8 @@ import { esc, icon } from './index.js';
16
16
 
17
17
  const cx = (...a) => a.filter(Boolean).join(' ');
18
18
 
19
- // A trailing status badge. `badge` is a string ("live") or { text, tone }.
19
+ // A trailing status badge. `badge` is the text shown, in the case it is written
20
+ // ("Live"), or { text, tone }; a string reading "live" in any case takes the live tone.
20
21
  function ddBadge(badge) {
21
22
  if (!badge) return '';
22
23
  const text = typeof badge === 'string' ? badge : badge.text;
@@ -26,8 +27,8 @@ function ddBadge(badge) {
26
27
  }
27
28
 
28
29
  // One item row. `listbox` picks role=option (selectable) vs role=menuitem (action).
29
- function ddItem(it, listbox) {
30
- if (it === '---' || it.separator) return '<div class="ui-dropdown__sep" role="separator"></div>';
30
+ function ddItem(it, listbox, ext) {
31
+ if (it === '---' || it.separator) return `<div class="ui-dropdown__sep" role="separator"${ext?.filtering ? ' hidden' : ''}></div>`;
31
32
  const disabled = !!it.disabled;
32
33
  const selected = !!it.selected;
33
34
  const role = listbox ? 'option' : 'menuitem';
@@ -48,19 +49,89 @@ function ddItem(it, listbox) {
48
49
  disabled ? 'aria-disabled="true"' : '',
49
50
  asLink ? `href="${esc(it.href)}"` : '',
50
51
  asLink && it.target ? `target="${esc(it.target)}"` : '',
52
+ ext?.id ? `id="${esc(ext.id)}"` : '',
53
+ ext?.hidden ? 'hidden' : '',
51
54
  ].filter(Boolean).join(' ');
52
55
  return `<${tag} ${attrs}>${lead}${main}${badge}${tick}</${tag}>`;
53
56
  }
54
57
 
55
- // Render a flat item list or grouped sections ([{ label, items }]).
56
- function ddBody({ items, sections }, listbox) {
58
+ // Render a flat item list or grouped sections ([{ label, items }]). `sx` is the
59
+ // search variant's context; the plain dropdown passes none and its markup is
60
+ // unchanged.
61
+ function ddBody({ items, sections }, listbox, sx) {
62
+ const one = (it) => ddItem(it, listbox, sx && ddRowExt(it, sx));
57
63
  if (sections && sections.length) {
58
64
  return sections.map((s) => {
59
65
  const head = s.label ? `<div class="ui-dropdown__group" role="presentation">${esc(s.label)}</div>` : '';
60
- return `<div class="ui-dropdown__section" role="group"${s.label ? ` aria-label="${esc(s.label)}"` : ''}>${head}${(s.items || []).map((it) => ddItem(it, listbox)).join('')}</div>`;
66
+ const gone = sx && ddFiltering(sx.q) && !(s.items || []).some((it) => ddIsRow(it) && ddMatch(it.label, sx.q));
67
+ return `<div class="ui-dropdown__section" role="group"${s.label ? ` aria-label="${esc(s.label)}"` : ''}${gone ? ' hidden' : ''}>${head}${(s.items || []).map(one).join('')}</div>`;
61
68
  }).join('');
62
69
  }
63
- return (items || []).map((it) => ddItem(it, listbox)).join('');
70
+ return (items || []).map(one).join('');
71
+ }
72
+
73
+ // ---- Search --------------------------------------------------------------
74
+ // The match is a substring of the label, anywhere in it, ignoring case and
75
+ // accents; rows keep their order. The factory and the wiring both ask
76
+ // ddMatch(), so a preset query and a typed one hide the same rows.
77
+ // why: docs/specification.md#a-dropdown-with-a-search-field
78
+ // NFD takes the mark off é or ö; ł, ø, đ and the rest are letters of their own
79
+ // with nothing to take off, so they are mapped by hand.
80
+ const FOLD = { ł: 'l', ø: 'o', đ: 'd', ð: 'd', ß: 'ss', æ: 'ae', œ: 'oe', ı: 'i', þ: 'th' };
81
+ const fold = (s) => String(s == null ? '' : s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
82
+ .replace(/[łøđðßæœıþ]/g, (c) => FOLD[c]);
83
+ const ddFiltering = (q) => fold(q).trim() !== '';
84
+ const ddMatch = (label, q) => fold(label).includes(fold(q).trim());
85
+ const ddIsRow = (it) => it && it !== '---' && !it.separator;
86
+
87
+ let _ddSeq = 0;
88
+
89
+ function ddSearchContext(search, id) {
90
+ const o = search === true ? {} : search;
91
+ return {
92
+ base: id || `ui-dd-${++_ddSeq}`,
93
+ n: 0,
94
+ q: o.query || '',
95
+ label: o.label,
96
+ placeholder: o.placeholder || 'Search',
97
+ empty: o.empty || 'No match for “{q}”',
98
+ hint: o.hint || 'Check the spelling, or try fewer letters.',
99
+ };
100
+ }
101
+
102
+ function ddRowExt(it, sx) {
103
+ if (!ddIsRow(it)) return { filtering: ddFiltering(sx.q) };
104
+ return { id: `${sx.base}-opt-${sx.n++}`, hidden: !ddMatch(it.label, sx.q) };
105
+ }
106
+
107
+ // The no-match state. A function replacer, so a `$&` typed into the field is
108
+ // text rather than a replacement pattern.
109
+ function ddNone(empty, hint, q) {
110
+ return `<span class="ui-dropdown__none-title">${esc(empty.replace('{q}', () => q.trim()))}</span>`
111
+ + `<span class="ui-dropdown__none-hint">${esc(hint)}</span>`;
112
+ }
113
+
114
+ // The field is a combobox that owns the list; the rows stay options, and the
115
+ // one Enter would pick is named by aria-activedescendant, so focus never
116
+ // leaves the field while the reader types.
117
+ function ddSearchBody({ items, sections }, sx, name, scroll) {
118
+ const listId = `${sx.base}-list`;
119
+ const rows = ddBody({ items, sections }, true, sx);
120
+ const flat = (sections ? sections.flatMap((s) => s.items || []) : (items || [])).filter(ddIsRow);
121
+ const shown = flat.some((it) => ddMatch(it.label, sx.q));
122
+ const cap = scroll && scroll !== true ? ` style="max-height:${typeof scroll === 'number' ? scroll + 'px' : esc(scroll)}"` : '';
123
+ const input = [
124
+ 'class="ui-dropdown__search-input"', 'type="text"', 'role="combobox"',
125
+ 'aria-autocomplete="list"', 'aria-expanded="true"', `aria-controls="${esc(listId)}"`,
126
+ `aria-label="${esc(sx.label || `Search ${name}`)}"`, `placeholder="${esc(sx.placeholder)}"`,
127
+ 'autocomplete="off"', 'spellcheck="false"', 'data-dd-search',
128
+ sx.q ? `value="${esc(sx.q)}"` : '',
129
+ ].filter(Boolean).join(' ');
130
+ return `<div class="ui-dropdown__search">`
131
+ + `<span class="ui-dropdown__search-ic" aria-hidden="true">${icon('search')}</span><input ${input}></div>`
132
+ + `<div class="ui-dropdown__list" role="listbox" id="${esc(listId)}" aria-label="${esc(name)}"${cap}>${rows}</div>`
133
+ + `<div class="ui-dropdown__none" role="status" data-dd-none data-dd-empty="${esc(sx.empty)}" data-dd-hint="${esc(sx.hint)}">`
134
+ + `${shown || !ddFiltering(sx.q) ? '' : ddNone(sx.empty, sx.hint, sx.q)}</div>`;
64
135
  }
65
136
 
66
137
  /**
@@ -81,18 +152,22 @@ function ddBody({ items, sections }, listbox) {
81
152
  * @param {boolean|number} [o.scroll] true, or a maxHeight in px, to cap and scroll
82
153
  * @param {boolean} [o.open] render already-open (handy for screenshots)
83
154
  * @param {string} [o.ariaLabel] accessible name for the panel and trigger
155
+ * @param {boolean|object} [o.search] true, or { placeholder, label, empty, hint, query } —
156
+ * a field above the rows that filters them; `empty` may carry {q}
84
157
  * @returns {string} html
85
158
  */
86
159
  export function dropdown({
87
160
  label, value, placeholder = 'Select…', variant, items, sections,
88
161
  header = '', footer = '', triggerContent, triggerClass = '', chevron = true,
89
162
  align = 'start', direction = 'down', portal = false,
90
- scroll = false, open = false, ariaLabel, id, panelClass = '',
163
+ scroll = false, open = false, ariaLabel, id, panelClass = '', search = false,
91
164
  } = {}) {
92
165
  const flat = sections ? sections.flatMap((s) => s.items || []) : (items || []);
93
166
  const isSelect = variant === 'select' || (variant == null && flat.some((it) => it && (it.selected || it.value != null)));
94
167
  const listRole = isSelect ? 'listbox' : 'menu';
95
168
  const cur = value != null ? value : (isSelect ? (flat.find((it) => it && it.selected)?.label) : null);
169
+ const sx = search ? ddSearchContext(search, id) : null;
170
+ const name = ariaLabel || (label ? String(label).replace(/:\s*$/, '') : '') || 'Options';
96
171
 
97
172
  const trig = triggerContent != null
98
173
  ? triggerContent
@@ -102,7 +177,7 @@ export function dropdown({
102
177
  `class="${cx('ui-dropdown__trigger', triggerClass)}"`,
103
178
  'type="button"',
104
179
  'data-dropdown-trigger',
105
- `aria-haspopup="${listRole}"`,
180
+ `aria-haspopup="${sx ? 'dialog' : listRole}"`,
106
181
  `aria-expanded="${open ? 'true' : 'false'}"`,
107
182
  ariaLabel && triggerContent != null ? `aria-label="${esc(ariaLabel)}"` : '',
108
183
  ].filter(Boolean).join(' ');
@@ -114,15 +189,17 @@ export function dropdown({
114
189
  'ui-dropdown__panel',
115
190
  align === 'end' && 'is-end',
116
191
  direction === 'up' && 'is-up',
117
- scroll && 'is-scroll',
192
+ scroll && !sx && 'is-scroll',
193
+ sx && 'ui-dropdown__panel--search',
118
194
  portal && 'ui-dropdown__panel--portal',
119
195
  portal && open && 'is-open',
120
196
  panelClass,
121
197
  )}"`,
122
198
  'data-dropdown-panel',
123
- `role="${listRole}"`,
124
- ariaLabel ? `aria-label="${esc(ariaLabel)}"` : '',
125
- scroll && scroll !== true ? `style="max-height:${typeof scroll === 'number' ? scroll + 'px' : esc(scroll)}"` : '',
199
+ // With search the panel holds a field and a list, which a listbox may not.
200
+ `role="${sx ? 'dialog' : listRole}"`,
201
+ sx ? `aria-label="${esc(name)}"` : (ariaLabel ? `aria-label="${esc(ariaLabel)}"` : ''),
202
+ scroll && scroll !== true && !sx ? `style="max-height:${typeof scroll === 'number' ? scroll + 'px' : esc(scroll)}"` : '',
126
203
  ].filter(Boolean).join(' ');
127
204
 
128
205
  const ddAttrs = 'data-dropdown'
@@ -132,7 +209,7 @@ export function dropdown({
132
209
 
133
210
  return `<div class="${cx('ui-dropdown', open && 'open')}" ${ddAttrs}${id ? ` id="${esc(id)}"` : ''}>` +
134
211
  `<button ${triggerAttrs}>${trig}${chevron ? '<span class="ui-dropdown__chevron" aria-hidden="true"></span>' : ''}</button>` +
135
- `<div ${panelAttrs}>${header}${ddBody({ items, sections }, isSelect)}${footer}</div>` +
212
+ `<div ${panelAttrs}>${header}${sx ? ddSearchBody({ items, sections }, sx, name, scroll) : ddBody({ items, sections }, isSelect)}${footer}</div>` +
136
213
  `</div>`;
137
214
  }
138
215
 
@@ -165,7 +242,59 @@ function ddItemsOf(dd) {
165
242
  const panel = ddPanelOf(dd);
166
243
  if (!panel) return [];
167
244
  return Array.from(panel.querySelectorAll('[data-dd-item]'))
168
- .filter((el) => el.getAttribute('aria-disabled') !== 'true');
245
+ .filter((el) => el.getAttribute('aria-disabled') !== 'true' && !el.hidden);
246
+ }
247
+
248
+ // ---- Search wiring ---------------------------------------------------------
249
+ // why: docs/specification.md#a-dropdown-with-a-search-field
250
+ const ddSearchOf = (dd) => ddPanelOf(dd)?.querySelector('[data-dd-search]') || null;
251
+ const ddActiveOf = (dd) => ddPanelOf(dd)?.querySelector('[data-dd-item].is-active') || null;
252
+ const ddComposing = (e) => e.isComposing || e.keyCode === 229;
253
+
254
+ // Mark the row Enter would pick and keep it inside the list's scroll box,
255
+ // without scrolling the page the way scrollIntoView() would.
256
+ function ddSetActive(dd, row) {
257
+ const search = ddSearchOf(dd);
258
+ if (!search) return;
259
+ ddActiveOf(dd)?.classList.remove('is-active');
260
+ if (!row) { search.removeAttribute('aria-activedescendant'); return; }
261
+ row.classList.add('is-active');
262
+ search.setAttribute('aria-activedescendant', row.id);
263
+ const list = row.closest('.ui-dropdown__list');
264
+ if (!list || !list.clientHeight) return;
265
+ const top = row.offsetTop - list.offsetTop;
266
+ if (top < list.scrollTop) list.scrollTop = top;
267
+ else if (top + row.offsetHeight > list.scrollTop + list.clientHeight) list.scrollTop = top + row.offsetHeight - list.clientHeight;
268
+ }
269
+
270
+ function ddFilter(dd) {
271
+ const panel = ddPanelOf(dd);
272
+ const search = ddSearchOf(dd);
273
+ if (!panel || !search) return;
274
+ const q = search.value;
275
+ let shown = 0;
276
+ panel.querySelectorAll('[data-dd-item]').forEach((row) => {
277
+ row.hidden = !ddMatch((row.querySelector('.ui-dropdown__label') || row).textContent, q);
278
+ if (!row.hidden) shown += 1;
279
+ });
280
+ panel.querySelectorAll('.ui-dropdown__sep').forEach((el) => { el.hidden = ddFiltering(q); });
281
+ panel.querySelectorAll('.ui-dropdown__section').forEach((el) => { el.hidden = !el.querySelector('[data-dd-item]:not([hidden])'); });
282
+ const none = panel.querySelector('[data-dd-none]');
283
+ if (none) {
284
+ none.innerHTML = shown || !ddFiltering(q) ? ''
285
+ : ddNone(none.getAttribute('data-dd-empty') || '', none.getAttribute('data-dd-hint') || '', q);
286
+ }
287
+ ddSetActive(dd, ddItemsOf(dd)[0] || null);
288
+ }
289
+
290
+ // Every open starts from the whole list. Reset here rather than on close, so
291
+ // a panel fading out after a pick does not flash back to every row.
292
+ function ddResetSearch(dd, panel, search) {
293
+ search.value = '';
294
+ ddFilter(dd);
295
+ panel.style.minWidth = '';
296
+ // Hold the width the whole list needs, so the panel does not narrow as rows go.
297
+ if (panel.offsetWidth) panel.style.minWidth = `${panel.offsetWidth}px`;
169
298
  }
170
299
 
171
300
  // `auto` is the only direction the wiring decides; `up` and the default are the
@@ -225,12 +354,22 @@ function closeAllDropdowns(except) {
225
354
  function openDropdown(dd, focusIdx) {
226
355
  closeAllDropdowns(dd);
227
356
  const panel = ddPanelOf(dd);
357
+ const search = ddSearchOf(dd);
358
+ if (panel && search) ddResetSearch(dd, panel, search);
228
359
  if (panel) {
229
360
  ddResolveDirection(dd, panel);
230
361
  if (dd.__ddPanel) { positionPortalPanel(dd, panel); panel.classList.add('is-open'); }
231
362
  }
232
363
  dd.classList.add('open');
233
364
  dd.querySelector('[data-dropdown-trigger]')?.setAttribute('aria-expanded', 'true');
365
+ // With search, focus goes to the field however the panel was opened, and
366
+ // the selected row (or the first) is the one Enter would pick.
367
+ if (search) {
368
+ const items = ddItemsOf(dd);
369
+ ddSetActive(dd, items.find((el) => el.getAttribute('aria-selected') === 'true') || items[0] || null);
370
+ search.focus();
371
+ return;
372
+ }
234
373
  if (focusIdx != null) {
235
374
  const items = ddItemsOf(dd);
236
375
  const sel = items.findIndex((el) => el.getAttribute('aria-selected') === 'true');
@@ -241,7 +380,9 @@ function openDropdown(dd, focusIdx) {
241
380
  // Single-select: reflect the picked option into aria-selected + the trigger value.
242
381
  function selectOption(dd, item) {
243
382
  if (!dd.hasAttribute('data-dropdown-select')) return;
244
- ddItemsOf(dd).forEach((el) => el.setAttribute('aria-selected', el === item ? 'true' : 'false'));
383
+ // Every enabled row, the ones a search query has hidden included.
384
+ ddPanelOf(dd)?.querySelectorAll('[data-dd-item]:not([aria-disabled="true"])')
385
+ .forEach((el) => el.setAttribute('aria-selected', el === item ? 'true' : 'false'));
245
386
  ddPanelOf(dd)?.querySelectorAll('[data-dd-item].is-selected').forEach((el) => el.classList.remove('is-selected'));
246
387
  item.classList.add('is-selected');
247
388
  const valueEl = dd.querySelector('[data-dropdown-trigger] .ui-dropdown__value');
@@ -292,11 +433,45 @@ export function wireDropdown(root = document) {
292
433
  closeDropdown(dd);
293
434
  trigger.focus();
294
435
  });
436
+ const field = panel.querySelector('[data-dd-search]');
437
+ if (field) {
438
+ field.addEventListener('input', () => ddFilter(dd));
439
+ // The pointer moves the pick too, so Enter takes the row under it. A
440
+ // move to the same point is the browser's own after a scroll, not the
441
+ // reader's, and would snatch the pick from the arrows.
442
+ let at = '';
443
+ panel.addEventListener('mousemove', (e) => {
444
+ const here = `${e.clientX},${e.clientY}`;
445
+ if (here === at) return;
446
+ at = here;
447
+ const row = e.target.closest('[data-dd-item]');
448
+ if (row && row.getAttribute('aria-disabled') !== 'true' && !row.classList.contains('is-active')) ddSetActive(dd, row);
449
+ });
450
+ }
295
451
  }
296
452
 
297
453
  const onKeydown = (e) => {
298
454
  const open = dd.classList.contains('open');
299
455
  const onTrigger = e.target === trigger;
456
+ const search = ddSearchOf(dd);
457
+ // While an IME is composing, its keys commit or steer the text, not the
458
+ // list. Safari's committing Enter carries keyCode 229, not isComposing.
459
+ if (e.target === search && ddComposing(e)) return;
460
+ if (search && open) {
461
+ // Arrows walk the rows still showing; focus stays in the field.
462
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
463
+ e.preventDefault();
464
+ const items = ddItemsOf(dd);
465
+ if (!items.length) return;
466
+ const i = items.indexOf(ddActiveOf(dd));
467
+ const next = e.key === 'ArrowDown' ? (i + 1) % items.length : (i <= 0 ? items.length - 1 : i - 1);
468
+ ddSetActive(dd, items[next]);
469
+ return;
470
+ }
471
+ if (e.target === search && e.key === 'Enter') { e.preventDefault(); ddActiveOf(dd)?.click(); return; }
472
+ // Home and End move the caret in a text field; they are not the list's.
473
+ if (e.target === search && (e.key === 'Home' || e.key === 'End')) return;
474
+ }
300
475
  if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && (onTrigger || open)) {
301
476
  e.preventDefault();
302
477
  if (!open) return openDropdown(dd, e.key === 'ArrowDown' ? 0 : 'selected');
@@ -337,7 +512,7 @@ export function wireDropdown(root = document) {
337
512
  _ddGlobalWired = true;
338
513
  document.addEventListener('click', () => closeAllDropdowns());
339
514
  document.addEventListener('keydown', (e) => {
340
- if (e.key !== 'Escape') return;
515
+ if (e.key !== 'Escape' || ddComposing(e)) return;
341
516
  const open = document.querySelector('[data-dropdown].open');
342
517
  if (open) { closeDropdown(open); open.querySelector('[data-dropdown-trigger]')?.focus(); }
343
518
  });
@@ -9,6 +9,7 @@
9
9
  // (onSend does the POST / issue / whatever) and any deep-link shaping. Styles
10
10
  // ship in styles/feedback.css (part of the kit stylesheet). Accent-aware.
11
11
  import { esc } from './index.js';
12
+ import { playEntrance } from '../motion.js';
12
13
 
13
14
  // Decorative, like every glyph in the kit — the pill and the buttons carry their
14
15
  // own text or aria-label, so the SVGs stay out of the accessibility tree.
@@ -16,7 +17,6 @@ const IC_MSG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strok
16
17
  const IC_LINES = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false"><path d="M4 5h16M4 12h10M4 19h7"/></svg>';
17
18
  const IC_X = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M6 6l12 12M18 6L6 18"/></svg>';
18
19
  const CHECK = '<svg class="ui-fbck" viewBox="0 0 150 150" aria-hidden="true"><path class="ui-fbck-t" d="M40 78l24 24 46-50"/><path class="ui-fbck-m" d="M40 78l24 24 46-50"/><path class="ui-fbck-s" d="M40 78l24 24 46-50"/></svg>';
19
-
20
20
  // The widget markup — append once to the page (e.g. document.body). Copy is
21
21
  // static here; behaviour + the dynamic chip/quote come from wireFeedback().
22
22
  export function feedbackWidget({
@@ -164,7 +164,7 @@ export function wireFeedback(opts = {}) {
164
164
 
165
165
  function fail(msg) {
166
166
  errEl.textContent = msg || 'Could not send just now — try again in a moment.';
167
- errEl.classList.add('show'); sendLb.textContent = 'Send feedback'; sendBtn.disabled = false;
167
+ errEl.classList.add('show'); playEntrance(errEl); sendLb.textContent = 'Send feedback'; sendBtn.disabled = false;
168
168
  }
169
169
  sendBtn.addEventListener('click', async () => {
170
170
  if (sendBtn.disabled || !pending) return;
@@ -56,11 +56,14 @@ export function statusDot(live = false) {
56
56
  }
57
57
 
58
58
  // ---- Card ----------------------------------------------------------------
59
- export function card({ title, sub, body = '', variant, pad, icon: ic } = {}) {
59
+ // The title is a heading one level under the page's h1 unless `level` says
60
+ // otherwise. why: docs/specification.md#labels-and-titles
61
+ export function card({ title, sub, body = '', variant, pad, icon: ic, level = 2 } = {}) {
60
62
  const cls = cx('ui-card', variant && `ui-card--${variant}`, pad && `ui-card--pad-${pad}`);
63
+ const h = [2, 3, 4, 5, 6].includes(Number(level)) ? `h${Number(level)}` : 'h2';
61
64
  // title/sub are trusted markup (may carry a badge/icon) — not escaped.
62
65
  const head = title
63
- ? `<div class="ui-card__title">${ic ? `<span class="ui-card__icon">${icon(ic)}</span>` : ''}${title}</div>${sub ? `<div class="ui-card__sub">${sub}</div>` : ''}`
66
+ ? `<${h} class="ui-card__title">${ic ? `<span class="ui-card__icon">${icon(ic)}</span>` : ''}${title}</${h}>${sub ? `<div class="ui-card__sub">${sub}</div>` : ''}`
64
67
  : '';
65
68
  return `<div class="${cls}">${head}${body}</div>`;
66
69
  }
@@ -8,9 +8,9 @@
8
8
  // why: docs/specification.md#pending-and-denied-states
9
9
  import { icon } from '../assets/icons.js';
10
10
  import { button, esc } from './index.js';
11
+ import { playEntrance } from '../motion.js';
11
12
 
12
13
  const cx = (...a) => a.filter(Boolean).join(' ');
13
-
14
14
  // ---- Skeleton ------------------------------------------------------------
15
15
  // The placeholder shape. `lines` is a count, or an array of widths when the
16
16
  // varied ragged edge of real prose matters (['100%','92%','60%']). `height`
@@ -80,7 +80,8 @@ export function setBusy(root, { busy = false, message, body } = {}) {
80
80
  region.setAttribute('aria-busy', busy ? 'true' : 'false');
81
81
  if (body != null) {
82
82
  const slot = region.querySelector('[data-busy-body]');
83
- if (slot) slot.innerHTML = body;
83
+ // The new body fades in. Only here — the region the page loads with is still.
84
+ if (slot) { slot.innerHTML = body; playEntrance(slot); }
84
85
  }
85
86
  const msg = region.querySelector('[data-busy-msg]');
86
87
  if (msg) {
@@ -7,6 +7,7 @@
7
7
  // <nav> + <a aria-current="page">, not role="tablist" (that is what segmented()
8
8
  // is for). Only the collapsible sidebar groups need JS; wire with wireNav().
9
9
  import { esc, icon } from './index.js';
10
+ import { playEntrance } from '../motion.js';
10
11
 
11
12
  const cx = (...a) => a.filter(Boolean).join(' ');
12
13
 
@@ -44,7 +45,7 @@ const leafName = (label, collapsed, badge) => {
44
45
 
45
46
  // One sidebar leaf: a link (or a plain, aria-disabled span). `collapsed` also
46
47
  // hides the label visually, leaving the icon hoverable.
47
- function sideLeaf(it, active, { collapsed, sub } = {}) {
48
+ function sideLeaf(it, active, { collapsed, sub, current = 'page' } = {}) {
48
49
  const on = it.id != null && it.id === active;
49
50
  const disabled = !!it.disabled;
50
51
  const label = it.label || '';
@@ -60,7 +61,7 @@ function sideLeaf(it, active, { collapsed, sub } = {}) {
60
61
  `class="${cls}"`,
61
62
  `href="${esc(it.href || '#' + (it.id ?? ''))}"`,
62
63
  it.target ? `target="${esc(it.target)}"` : '',
63
- on ? 'aria-current="page"' : '',
64
+ on ? `aria-current="${current}"` : '',
64
65
  name.trim(),
65
66
  ].filter(Boolean).join(' ');
66
67
  return `<li><a ${attrs}>${lead}${text}${badge}</a></li>`;
@@ -69,14 +70,14 @@ function sideLeaf(it, active, { collapsed, sub } = {}) {
69
70
  // A collapsible group: a toggle button (aria-expanded/-controls) over a nested
70
71
  // list. In collapsed (icon-only) mode groups don't expand, so we render the
71
72
  // group head as a plain, non-collapsing icon row.
72
- function sideGroup(it, active, { collapsed } = {}) {
73
+ function sideGroup(it, active, { collapsed, current } = {}) {
73
74
  const listId = nextId('nav-grp');
74
75
  const childActive = (it.items || []).some((c) => c.id != null && c.id === active);
75
76
  const open = collapsed ? false : (it.open != null ? !!it.open : childActive);
76
77
  const lead = it.icon ? `<span class="ui-nav__ic">${icon(it.icon)}</span>` : '';
77
78
  const label = it.label || '';
78
79
  const text = `<span class="ui-nav__label">${esc(label)}</span>`;
79
- const kids = (it.items || []).map((c) => sideLeaf(c, active, { collapsed, sub: true })).join('');
80
+ const kids = (it.items || []).map((c) => sideLeaf(c, active, { collapsed, sub: true, current })).join('');
80
81
  const name = leafName(label, collapsed);
81
82
  const btn =
82
83
  `<button type="button" class="${cx('ui-nav__item', 'ui-nav__toggle', childActive && 'is-current')}"` +
@@ -94,13 +95,17 @@ function sideItem(it, active, opts) {
94
95
  // items/section items: { id, label, icon?, href?, target?, badge?, danger?,
95
96
  // disabled?, items?, open? } (items ⇒ collapsible group)
96
97
  // active id of the current item (gets aria-current="page")
98
+ // activeIs 'page', or 'section' when the page on screen sits below the active
99
+ // row: the row then carries aria-current="true", because "page" would
100
+ // announce the list as the page the reader is on
97
101
  // collapsed icon-only rail; every item is aria-labelled at every width, and
98
102
  // collapsed adds the hover tooltip on top
99
103
  // footer trusted HTML pinned below a divider (e.g. a sign-out link)
100
104
  // ariaLabel accessible name for the <nav> landmark
101
105
  export function sidebarNav({
102
- sections, items, active, collapsed = false, footer = '', ariaLabel = 'Sidebar', id,
106
+ sections, items, active, activeIs = 'page', collapsed = false, footer = '', ariaLabel = 'Sidebar', id,
103
107
  } = {}) {
108
+ const current = activeIs === 'section' ? 'true' : 'page';
104
109
  const blocks = (sections && sections.length)
105
110
  ? sections
106
111
  : [{ items: items || [] }];
@@ -110,7 +115,7 @@ export function sidebarNav({
110
115
  ? `<div class="ui-nav__cap" id="${capId}"${collapsed ? ' aria-hidden="true"' : ''}>${esc(sec.label)}</div>`
111
116
  : '';
112
117
  const list = `<ul class="ui-nav__list"${capId ? ` aria-labelledby="${capId}"` : ''}>` +
113
- (sec.items || []).map((it) => sideItem(it, active, { collapsed })).join('') + `</ul>`;
118
+ (sec.items || []).map((it) => sideItem(it, active, { collapsed, current })).join('') + `</ul>`;
114
119
  return `<div class="ui-nav__section">${cap}${list}</div>`;
115
120
  }).join('');
116
121
  const foot = footer ? `<div class="ui-nav__foot">${footer}</div>` : '';
@@ -182,7 +187,10 @@ function toggleGroup(btn) {
182
187
  const open = btn.getAttribute('aria-expanded') === 'true';
183
188
  btn.setAttribute('aria-expanded', open ? 'false' : 'true');
184
189
  if (li) li.classList.toggle('is-open', !open);
185
- if (list) { if (open) list.setAttribute('hidden', ''); else list.removeAttribute('hidden'); }
190
+ if (list) {
191
+ if (open) list.setAttribute('hidden', '');
192
+ else { list.removeAttribute('hidden'); playEntrance(list); }
193
+ }
186
194
  }
187
195
 
188
196
  export function wireNav(root = document) {