@apliteni/apliteni-ui 0.9.1 → 0.11.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.9.1",
3
+ "version": "0.11.1",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
@@ -0,0 +1,47 @@
1
+ // The one account navigation definition, and the one way to hand it to a sink
2
+ // that interpolates raw. It lives here rather than in shell.js because both
3
+ // shell.js and topbar.js need it and shell.js already imports topbar.js — the
4
+ // other direction would be a cycle. shell.js re-exports ACCOUNT_NAV, which is
5
+ // the published name docs/library.md documents.
6
+ // why: docs/adr/0007-one-page-shell-built-from-the-kits-own-nav.md
7
+ import { esc } from './index.js';
8
+
9
+ // nav.js item objects. Labels are raw text — every nav primitive escapes, so a
10
+ // pre-escaped `&` would come out as `&`. `key` means credentials;
11
+ // `plug` means integration. These two are the kit's default, so each is a live
12
+ // link on a consumer's /account page — a screen the kit does not ship belongs
13
+ // in the caller's own nav, not here.
14
+ export const ACCOUNT_NAV = [
15
+ { id: 'prefs', icon: 'gear', label: 'Preferences' },
16
+ { id: 'access', icon: 'key', label: 'Access & agents' },
17
+ ];
18
+
19
+ // topbar()'s account menu speaks [id, icon, label, href?, target?] tuples, and
20
+ // it interpolates every field raw — the label, the href and the target alike.
21
+ // Escape all three on the way in rather than sending the menu a string the rail
22
+ // would have escaped a second time.
23
+ export const toMenuTuple = (it) => [
24
+ esc(it.id ?? ''), it.icon, esc(it.label || ''),
25
+ it.href ? esc(it.href) : it.href,
26
+ it.target ? esc(it.target) : it.target,
27
+ ];
28
+
29
+ // What accountMenu() falls back to. Derived on call, not frozen at import, so
30
+ // the menu and the rail cannot answer differently about the same definition.
31
+ export const accountMenuNav = () => ACCOUNT_NAV.map(toMenuTuple);
32
+
33
+ // One reader, one pair of initials. The rail (shell.js) and the topbar's
34
+ // account menu (topbar.js) both draw an avatar for the same person, and each
35
+ // computed it for itself: the rail preferred the display name, the menu only
36
+ // ever read the email's local part. So the /account preset — the one screen
37
+ // with both on it — said "AL" in the rail and "A" in the topbar. This lives
38
+ // beside ACCOUNT_NAV for the same reason ACCOUNT_NAV lives here: both files
39
+ // need it and shell.js already imports topbar.js.
40
+ //
41
+ // The display name is what a reader recognises, so it wins; the address is the
42
+ // fallback. `?` is what nobody at all comes out as.
43
+ export const initials = (name, email) => {
44
+ const from = String(name ?? '').trim() || String(email ?? '').split('@')[0] || '';
45
+ const parts = from.split(/[\s._-]+/).filter(Boolean).map((w) => w[0]);
46
+ return (parts.slice(0, 2).join('') || '?').toUpperCase();
47
+ };
@@ -0,0 +1,131 @@
1
+ // Confirm — the kit's confirmation dialog: a question the page stops for.
2
+ //
3
+ // container.innerHTML = confirm({ title, body, confirmLabel, cancelLabel });
4
+ // wireConfirm(container); // scrim/Esc/answers + focus trap
5
+ //
6
+ // A page trigger opens it by id: <button data-confirm-open="ID">…</button>
7
+ // (or call openConfirm(rootEl) directly). Pass `open: true` to render it already
8
+ // open, or `specimen: true` for a picture of one on a documentation page.
9
+ //
10
+ // Answering is the caller's job: both answers close the dialog, and a listener
11
+ // on [data-confirm-accept] is where the destructive work goes. Focus opens on
12
+ // the SAFE answer, never the destructive one, so a reader who hits Enter out of
13
+ // habit keeps what they have.
14
+ //
15
+ // Inertness, Escape and the focus trap come from ./overlay.js — one stack for
16
+ // every overlay on the page, so a confirm over a drawer never has to guess which
17
+ // of the two the keyboard belongs to.
18
+ import { button, esc } from './index.js';
19
+ import { OVERLAY_LAYER, adoptOverlay, focusablesIn, popOverlay, pushOverlay, returnFocus, syncOverlays } from './overlay.js';
20
+
21
+ const cx = (...a) => a.filter(Boolean).join(' ');
22
+
23
+ // Module counter (never Date/random) → stable, unique ids for aria-labelledby.
24
+ let _uid = 0;
25
+ const nextId = (p = 'confirm') => `${p}-${++_uid}`;
26
+
27
+ // The public factory. Returns an HTML string; wire it with wireConfirm().
28
+ // title the question — also the dialog's accessible name
29
+ // body the consequence — the dialog's accessible description
30
+ // confirmLabel the destructive answer (default 'Confirm')
31
+ // cancelLabel the safe answer (default 'Cancel')
32
+ // variant 'danger' (default) | 'primary' — which button the answer is
33
+ // open render already-open, as a real dialog: wireConfirm() adopts it
34
+ // onto the overlay stack, so the page behind it goes inert, Tab
35
+ // is trapped in the panel and Escape closes it
36
+ // specimen render open as a *picture* of the dialog: same markup, minus
37
+ // the data-confirm hook and aria-modal, so no wiring and no key
38
+ // handler can reach it. A documentation page shows several at
39
+ // once and none of them owns the page or answers Escape — an
40
+ // answered specimen would erase itself with nothing to bring it
41
+ // back, and three modal dialogs on one page trap the reader in
42
+ // the first. `open` is the one to use when the dialog is real.
43
+ // id root id — a [data-confirm-open="id"] trigger targets it
44
+ export function confirm({
45
+ title = 'Are you sure?', body = '', confirmLabel = 'Confirm', cancelLabel = 'Cancel',
46
+ variant = 'danger', id, open = false, specimen = false,
47
+ } = {}) {
48
+ const titleId = nextId('confirm-title');
49
+ const bodyId = body ? nextId('confirm-body') : null;
50
+ const describedBy = bodyId ? ` aria-describedby="${bodyId}"` : '';
51
+
52
+ // The safe answer comes FIRST in the DOM, so the trap's first stop and the
53
+ // reader's first Tab both land on the answer that changes nothing.
54
+ const cancelBtn = button({ label: cancelLabel, variant: 'ghost' })
55
+ .replace('<button ', '<button data-confirm-cancel ');
56
+ const acceptBtn = button({ label: confirmLabel, variant })
57
+ .replace('<button ', '<button data-confirm-accept ');
58
+
59
+ const rootCls = cx('ui-confirm', (open || specimen) && 'is-open');
60
+ return `<div class="${rootCls}"${specimen ? '' : ' data-confirm'}${id ? ` id="${esc(id)}"` : ''}>`
61
+ + `<div class="ui-confirm__scrim" data-confirm-scrim></div>`
62
+ + `<div class="ui-confirm__panel" role="alertdialog"${specimen ? '' : ' aria-modal="true"'}`
63
+ + ` aria-labelledby="${titleId}"${describedBy} tabindex="-1" data-confirm-panel>`
64
+ + `<h2 class="ui-confirm__title" id="${titleId}">${esc(title)}</h2>`
65
+ + (bodyId ? `<p class="ui-confirm__body" id="${bodyId}">${esc(body)}</p>` : '')
66
+ + `<div class="ui-confirm__acts">${cancelBtn}${acceptBtn}</div>`
67
+ + `</div></div>`;
68
+ }
69
+
70
+ // ---- Shared behaviour ----------------------------------------------------
71
+ // Per-instance handlers are attached once (guarded by a flag on the node);
72
+ // the [data-confirm-open] delegation is attached once per document. Safe to
73
+ // call repeatedly (Storybook re-renders).
74
+
75
+ export function openConfirm(root, returnFocusTo) {
76
+ if (!root || root.classList.contains('is-open')) return;
77
+ root.__confirmReturn = returnFocusTo
78
+ || (document.activeElement instanceof HTMLElement ? document.activeElement : null);
79
+ root.classList.add('is-open');
80
+ const panel = root.querySelector('[data-confirm-panel]');
81
+ pushOverlay(root, panel, () => closeConfirm(root), OVERLAY_LAYER.confirm);
82
+ // The safe answer, else the first control, else the panel itself. Asked for by
83
+ // name and not by position: DOM order puts the safe answer first today, and a
84
+ // preference that only agrees with the order proves nothing about either.
85
+ const safe = panel && (panel.querySelector('[data-confirm-cancel]') || focusablesIn(panel)[0]);
86
+ (safe || panel)?.focus();
87
+ }
88
+
89
+ export function closeConfirm(root) {
90
+ if (!root || !root.classList.contains('is-open')) return;
91
+ root.classList.remove('is-open');
92
+ popOverlay(root);
93
+ const back = root.__confirmReturn;
94
+ root.__confirmReturn = null;
95
+ // The destructive work a caller hangs off [data-confirm-accept] usually deletes
96
+ // the row the trigger stood in, so the trigger can be detached by now — and
97
+ // focus() on a detached node is a silent no-op that strands the reader.
98
+ returnFocus(back, root.ownerDocument);
99
+ }
100
+
101
+ export function wireConfirm(scope = document) {
102
+ const root = scope === document ? document : scope;
103
+ root.querySelectorAll('[data-confirm]').forEach((cf) => {
104
+ if (cf.__confirmWired) return;
105
+ cf.__confirmWired = true;
106
+
107
+ cf.querySelector('[data-confirm-scrim]')?.addEventListener('click', () => closeConfirm(cf));
108
+ cf.querySelectorAll('[data-confirm-cancel],[data-confirm-accept]').forEach((btn) =>
109
+ btn.addEventListener('click', () => closeConfirm(cf)));
110
+
111
+ // Rendered with `open: true`, so nothing called openConfirm() and nothing
112
+ // put it on the stack. Adopting it here is what makes its aria-modal true.
113
+ adoptOverlay(cf, cf.querySelector('[data-confirm-panel]'), () => closeConfirm(cf), OVERLAY_LAYER.confirm);
114
+ });
115
+
116
+ const doc = scope === document ? document : (scope.ownerDocument || document);
117
+ if (!doc.__confirmGlobalWired) {
118
+ doc.__confirmGlobalWired = true;
119
+ // Any [data-confirm-open="ID"] trigger opens the matching dialog.
120
+ doc.addEventListener('click', (e) => {
121
+ const opener = e.target.closest?.('[data-confirm-open]');
122
+ if (!opener) return;
123
+ e.preventDefault();
124
+ const target = doc.getElementById(opener.getAttribute('data-confirm-open'));
125
+ if (target) openConfirm(target, opener);
126
+ });
127
+ }
128
+ // This runs on every re-render, which is the moment to notice that an overlay
129
+ // was destroyed while it was open and hand the page back.
130
+ syncOverlays(doc);
131
+ }
@@ -8,14 +8,14 @@
8
8
  //
9
9
  // A page trigger opens it by id: <button data-drawer-open="ID">…</button>
10
10
  // (or call openDrawer(rootEl) directly). Pass `open: true` to render it already
11
- // open handy for specimens/screenshots.
11
+ // open, or `specimen: true` for a picture of one on a documentation page.
12
12
  //
13
- // This deliberately shares its scrim + focus-trap concept with a future Modal /
14
- // Dialog: both are "content over a scrim, focus-trapped, Esc-dismissable". The
15
- // data hooks ([data-drawer]/[data-drawer-scrim]/[data-drawer-close]) and the
16
- // wireDrawer trap below are the primitive a Modal would reuse (centered instead
17
- // of edge-anchored). Kept as one component for now per issue #34.
13
+ // Inertness, Escape and the focus trap are shared with confirm(): both are
14
+ // "content over a scrim, focus-trapped, Esc-dismissable", and they push onto one
15
+ // stack in ./overlay.js so the two can never disagree about which of them the
16
+ // keyboard currently belongs to.
18
17
  import { esc, icon } from './index.js';
18
+ import { OVERLAY_LAYER, adoptOverlay, focusablesIn, popOverlay, pushOverlay, returnFocus, syncOverlays } from './overlay.js';
19
19
 
20
20
  const cx = (...a) => a.filter(Boolean).join(' ');
21
21
 
@@ -29,14 +29,20 @@ const nextId = (p = 'drawer') => `${p}-${++_uid}`;
29
29
  // title header heading (also the dialog's accessible name)
30
30
  // body scrollable body HTML (trusted markup)
31
31
  // footer pinned footer actions HTML (trusted markup)
32
- // open render already-open (specimens / screenshots)
32
+ // open render already-open, as a real panel: wireDrawer() adopts it onto
33
+ // the overlay stack, so the page behind it goes inert, Tab is
34
+ // trapped in the panel and Escape closes it
35
+ // specimen render open as a *picture* of the panel: same markup, minus the
36
+ // data-drawer hook and aria-modal, so no wiring and no key handler
37
+ // can reach it. See confirm() for why a documentation page wants
38
+ // that; `open` is the one to use when the drawer is real.
33
39
  // id root id — a [data-drawer-open="id"] trigger targets it
34
40
  // ariaLabel accessible name when there's no visible title
35
41
  // dismissible show the close button + allow scrim/Esc dismiss (default true)
36
42
  // closeLabel accessible name for the close button (default 'Close')
37
43
  export function drawer({
38
44
  side = 'right', size = 'md', title, body = '', footer = '',
39
- open = false, id, ariaLabel, dismissible = true, closeLabel = 'Close',
45
+ open = false, specimen = false, id, ariaLabel, dismissible = true, closeLabel = 'Close',
40
46
  } = {}) {
41
47
  const titleId = title ? nextId('drawer-title') : null;
42
48
  const nameAttr = titleId
@@ -54,71 +60,31 @@ export function drawer({
54
60
  const bodyEl = `<div class="ui-drawer__body">${body}</div>`;
55
61
  const footEl = footer ? `<footer class="ui-drawer__footer">${footer}</footer>` : '';
56
62
 
57
- const rootCls = cx('ui-drawer', `ui-drawer--${side}`, `ui-drawer--${size}`, open && 'is-open');
58
- return `<div class="${rootCls}" data-drawer data-drawer-side="${esc(side)}"`
63
+ const rootCls = cx('ui-drawer', `ui-drawer--${side}`, `ui-drawer--${size}`, (open || specimen) && 'is-open');
64
+ return `<div class="${rootCls}"${specimen ? '' : ' data-drawer'} data-drawer-side="${esc(side)}"`
59
65
  + `${dismissible ? '' : ' data-drawer-static'}${id ? ` id="${esc(id)}"` : ''}>`
60
66
  + `<div class="ui-drawer__scrim" data-drawer-scrim></div>`
61
- + `<aside class="ui-drawer__panel" role="dialog" aria-modal="true" ${nameAttr} tabindex="-1" data-drawer-panel>`
67
+ + `<aside class="ui-drawer__panel" role="dialog"${specimen ? '' : ' aria-modal="true"'} ${nameAttr} tabindex="-1" data-drawer-panel>`
62
68
  + `${header}${bodyEl}${footEl}`
63
69
  + `</aside></div>`;
64
70
  }
65
71
 
66
72
  // ---- Shared behaviour ----------------------------------------------------
67
- // One open/close/scrim/Esc/focus-trap implementation for every drawer in the
68
- // kit. Per-instance handlers are attached once (guarded by a flag on the node);
69
- // document-level Esc + the [data-drawer-open] delegation are attached once per
70
- // document (guarded by a flag on the document node, so multiple documents each
71
- // get their own). Safe to call repeatedly (Storybook re-renders).
72
-
73
- const FOCUSABLE = [
74
- 'a[href]', 'button:not([disabled])', 'input:not([disabled])',
75
- 'select:not([disabled])', 'textarea:not([disabled])',
76
- '[tabindex]:not([tabindex="-1"])',
77
- ].join(',');
78
-
79
- // Focusable controls inside the panel, in DOM order. Scoped to the panel, so
80
- // nothing outside the trap is returned; hidden closed drawers are never queried
81
- // (we only call this while open).
82
- function focusablesIn(panel) {
83
- return Array.from(panel.querySelectorAll(FOCUSABLE));
84
- }
85
-
86
- // Hide everything *outside* the drawer from AT + tab order: walk root→body and
87
- // mark each ancestor's other children inert + aria-hidden, remembering prior
88
- // state so close can restore it exactly. The scrim + panel live inside the root,
89
- // so they stay interactive. This is the focus-trap primitive a Modal reuses.
90
- function inertOutside(root, on) {
91
- if (on) {
92
- const touched = [];
93
- let node = root;
94
- while (node && node.parentElement && node !== document.body) {
95
- for (const sib of node.parentElement.children) {
96
- if (sib === node || sib.hasAttribute('data-drawer')) continue;
97
- touched.push([sib, sib.getAttribute('aria-hidden'), sib.hasAttribute('inert')]);
98
- sib.setAttribute('aria-hidden', 'true');
99
- sib.setAttribute('inert', '');
100
- sib.inert = true;
101
- }
102
- node = node.parentElement;
103
- }
104
- root.__drawerInert = touched;
105
- } else {
106
- (root.__drawerInert || []).forEach(([el, ariaHidden, hadInert]) => {
107
- if (ariaHidden == null) el.removeAttribute('aria-hidden');
108
- else el.setAttribute('aria-hidden', ariaHidden);
109
- if (!hadInert) { el.removeAttribute('inert'); el.inert = false; }
110
- });
111
- root.__drawerInert = null;
112
- }
113
- }
73
+ // One open/close/scrim/close-button implementation for every drawer in the kit;
74
+ // inertness, Escape and Tab belong to the overlay stack. Per-instance handlers
75
+ // are attached once (guarded by a flag on the node); the [data-drawer-open]
76
+ // delegation is attached once per document (guarded by a flag on the document
77
+ // node, so multiple documents each get their own). Safe to call repeatedly
78
+ // (Storybook re-renders).
114
79
 
115
80
  export function openDrawer(root, returnFocusTo) {
116
81
  if (!root || root.classList.contains('is-open')) return;
117
82
  root.__drawerReturn = returnFocusTo
118
83
  || (document.activeElement instanceof HTMLElement ? document.activeElement : null);
119
84
  root.classList.add('is-open');
120
- inertOutside(root, true);
121
85
  const panel = root.querySelector('[data-drawer-panel]');
86
+ const dismissible = !root.hasAttribute('data-drawer-static');
87
+ pushOverlay(root, panel, dismissible ? () => closeDrawer(root) : null, OVERLAY_LAYER.drawer);
122
88
  // Focus the first focusable control, else the panel itself.
123
89
  const first = panel && focusablesIn(panel)[0];
124
90
  (first || panel)?.focus();
@@ -127,33 +93,12 @@ export function openDrawer(root, returnFocusTo) {
127
93
  export function closeDrawer(root) {
128
94
  if (!root || !root.classList.contains('is-open')) return;
129
95
  root.classList.remove('is-open');
130
- inertOutside(root, false);
96
+ popOverlay(root);
131
97
  const back = root.__drawerReturn;
132
98
  root.__drawerReturn = null;
133
- if (back && typeof back.focus === 'function') back.focus();
134
- }
135
-
136
- // Trap Tab within the panel while open; wrap at both ends.
137
- function trapTab(root, e) {
138
- if (e.key !== 'Tab') return;
139
- const panel = root.querySelector('[data-drawer-panel]');
140
- if (!panel) return;
141
- const items = focusablesIn(panel);
142
- if (!items.length) { e.preventDefault(); panel.focus(); return; }
143
- const first = items[0];
144
- const last = items[items.length - 1];
145
- const active = document.activeElement;
146
- if (e.shiftKey && (active === first || active === panel)) {
147
- e.preventDefault(); last.focus();
148
- } else if (!e.shiftKey && active === last) {
149
- e.preventDefault(); first.focus();
150
- }
151
- }
152
-
153
- // Topmost open drawer (last in document order) — Esc closes that one.
154
- function topOpenDrawer(doc = document) {
155
- const open = doc.querySelectorAll('[data-drawer].is-open');
156
- return open.length ? open[open.length - 1] : null;
99
+ // The trigger can be gone by now — focus() on a detached node does nothing at
100
+ // all, which leaves the reader with no place on the page.
101
+ returnFocus(back, root.ownerDocument);
157
102
  }
158
103
 
159
104
  export function wireDrawer(root = document) {
@@ -169,14 +114,10 @@ export function wireDrawer(root = document) {
169
114
  dr.querySelectorAll('[data-drawer-close]').forEach((btn) =>
170
115
  btn.addEventListener('click', () => closeDrawer(dr)));
171
116
 
172
- dr.addEventListener('keydown', (e) => {
173
- if (!dr.classList.contains('is-open')) return;
174
- if (e.key === 'Escape' && dismissible) {
175
- e.preventDefault(); e.stopPropagation(); closeDrawer(dr);
176
- } else if (e.key === 'Tab') {
177
- trapTab(dr, e);
178
- }
179
- });
117
+ // Rendered with `open: true`, so nothing called openDrawer() and nothing put
118
+ // it on the stack. Adopting it here is what makes its aria-modal true.
119
+ adoptOverlay(dr, dr.querySelector('[data-drawer-panel]'), dismissible ? () => closeDrawer(dr) : null,
120
+ OVERLAY_LAYER.drawer);
180
121
  });
181
122
 
182
123
  const doc = root === document ? document : (root.ownerDocument || document);
@@ -190,11 +131,8 @@ export function wireDrawer(root = document) {
190
131
  const target = doc.getElementById(opener.getAttribute('data-drawer-open'));
191
132
  if (target) openDrawer(target, opener);
192
133
  });
193
- // Esc closes the topmost open drawer even when focus escaped the panel.
194
- doc.addEventListener('keydown', (e) => {
195
- if (e.key !== 'Escape') return;
196
- const top = topOpenDrawer(doc);
197
- if (top && !top.hasAttribute('data-drawer-static')) { e.preventDefault(); closeDrawer(top); }
198
- });
199
134
  }
135
+ // This runs on every re-render, which is the moment to notice that an overlay
136
+ // was destroyed while it was open and hand the page back.
137
+ syncOverlays(doc);
200
138
  }
@@ -30,15 +30,33 @@ const nextId = (p = 'nav') => `${p}-${++_uid}`;
30
30
 
31
31
  // A trailing counter/badge on a nav item. `badge` is a number, a string, or
32
32
  // { text, tone }. Tones map to the badge token set (accent | live | neutral…).
33
- function navBadge(badge) {
33
+ const badgeText = (badge) => {
34
34
  if (badge == null || badge === '') return '';
35
35
  const text = typeof badge === 'object' ? badge.text : badge;
36
+ return text == null || text === '' ? '' : String(text);
37
+ };
38
+
39
+ function navBadge(badge) {
40
+ const text = badgeText(badge);
41
+ if (!text) return '';
36
42
  const tone = typeof badge === 'object' && badge.tone ? badge.tone : 'neutral';
37
43
  return `<span class="${cx('ui-nav__badge', `is-${tone}`)}">${esc(text)}</span>`;
38
44
  }
39
45
 
40
- // One sidebar leaf: a link (or a plain, aria-disabled span). `collapsed` folds
41
- // the label into an aria-label + title so the icon stays named and hoverable.
46
+ // An item's accessible name. It is emitted at every width, not only when
47
+ // `collapsed`, so a shell may fold `.ui-nav__label` out of view in CSS and keep
48
+ // the icon named. An always-on name overrides the row's own text, so the badge
49
+ // has to be spelled into it — otherwise "Pending 3" narrows to "Pending". The
50
+ // `title` tooltip is for the collapsed rail only — a narrow touch device has no
51
+ // hover to show it with, and the badge is folded away there too.
52
+ const leafName = (label, collapsed, badge) => {
53
+ const count = badgeText(badge);
54
+ const name = esc(count ? `${label} ${count}` : label);
55
+ return ` aria-label="${name}"${collapsed ? ` title="${name}"` : ''}`;
56
+ };
57
+
58
+ // One sidebar leaf: a link (or a plain, aria-disabled span). `collapsed` also
59
+ // hides the label visually, leaving the icon hoverable.
42
60
  function sideLeaf(it, active, { collapsed, sub } = {}) {
43
61
  const on = it.id != null && it.id === active;
44
62
  const disabled = !!it.disabled;
@@ -47,7 +65,7 @@ function sideLeaf(it, active, { collapsed, sub } = {}) {
47
65
  const text = `<span class="ui-nav__label">${esc(label)}</span>`;
48
66
  const badge = navBadge(it.badge);
49
67
  const cls = cx('ui-nav__item', sub && 'ui-nav__item--sub', on && 'is-active', it.danger && 'is-danger', disabled && 'is-disabled');
50
- const name = collapsed ? ` aria-label="${esc(label)}" title="${esc(label)}"` : '';
68
+ const name = leafName(label, collapsed, it.badge);
51
69
  if (disabled) {
52
70
  return `<li><span class="${cls}" aria-disabled="true"${name}>${lead}${text}${badge}</span></li>`;
53
71
  }
@@ -72,7 +90,7 @@ function sideGroup(it, active, { collapsed } = {}) {
72
90
  const label = it.label || '';
73
91
  const text = `<span class="ui-nav__label">${esc(label)}</span>`;
74
92
  const kids = (it.items || []).map((c) => sideLeaf(c, active, { collapsed, sub: true })).join('');
75
- const name = collapsed ? ` aria-label="${esc(label)}" title="${esc(label)}"` : '';
93
+ const name = leafName(label, collapsed);
76
94
  const btn =
77
95
  `<button type="button" class="${cx('ui-nav__item', 'ui-nav__toggle', childActive && 'is-current')}"` +
78
96
  ` data-nav-toggle aria-expanded="${open ? 'true' : 'false'}" aria-controls="${listId}"${name}>` +
@@ -89,7 +107,8 @@ function sideItem(it, active, opts) {
89
107
  // items/section items: { id, label, icon?, href?, target?, badge?, danger?,
90
108
  // disabled?, items?, open? } (items ⇒ collapsible group)
91
109
  // active id of the current item (gets aria-current="page")
92
- // collapsed icon-only rail; labels fold into aria-label + title
110
+ // collapsed icon-only rail; every item is aria-labelled at every width, and
111
+ // collapsed adds the hover tooltip on top
93
112
  // footer trusted HTML pinned below a divider (e.g. a sign-out link)
94
113
  // ariaLabel accessible name for the <nav> landmark
95
114
  export function sidebarNav({