@apliteni/apliteni-ui 0.4.0 → 0.6.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/package.json +8 -3
- package/src/assets/brand.generated/apliteni-logo-dark.svg +27 -0
- package/src/assets/brand.generated/apliteni-logo.svg +27 -0
- package/src/assets/brand.generated/apliteni-mark.svg +19 -0
- package/src/assets/brand.generated/index.js +5 -0
- package/src/assets/icons.js +106 -24
- package/src/assets/illustrations.js +68 -0
- package/src/components/drawer.js +200 -0
- package/src/components/dropdown.js +229 -0
- package/src/components/feedback.js +6 -0
- package/src/components/footer.js +95 -0
- package/src/components/index.js +46 -26
- package/src/components/nav.js +190 -0
- package/src/components/success.js +126 -0
- package/src/components/toasts.js +94 -0
- package/src/components/topbar.js +16 -15
- package/src/index.css +8 -1
- package/src/index.js +5 -0
- package/src/inline.js +19 -5
- package/src/motion.js +68 -0
- package/src/styles/base.css +0 -6
- package/src/styles/button.css +40 -11
- package/src/styles/callout.css +79 -12
- package/src/styles/drawer.css +166 -0
- package/src/styles/dropdown.css +133 -0
- package/src/styles/empty.css +84 -0
- package/src/styles/feedback.css +1 -1
- package/src/styles/footer.css +123 -0
- package/src/styles/motion.css +166 -0
- package/src/styles/nav.css +218 -0
- package/src/styles/success.css +189 -0
- package/src/styles/table.css +2 -15
- package/src/tokens/brand.generated.css +121 -0
- package/src/tokens/tokens.css +18 -5
- package/src/styles/aurora.css +0 -93
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Dropdown — the kit's one popover-list primitive. A trigger (label + optional
|
|
2
|
+
// value + rotating chevron) opens a panel of item rows (label, optional
|
|
3
|
+
// description, optional leading icon, optional trailing badge, selected +
|
|
4
|
+
// disabled state). Two flavours share the same panel + the same open/close JS:
|
|
5
|
+
//
|
|
6
|
+
// variant: 'select' → role="listbox" / role="option", value shown in trigger
|
|
7
|
+
// variant: 'menu' → role="menu" / role="menuitem", action list
|
|
8
|
+
//
|
|
9
|
+
// "dropdown" (not "menu" or "select") is deliberate: `menu` implies actions
|
|
10
|
+
// only and `select` implies a form control bound to a value — this factory is
|
|
11
|
+
// the umbrella both of those are specialisations of, and it's the word the
|
|
12
|
+
// issue and the topbar already use. The topbar's version switcher and account
|
|
13
|
+
// menu are thin consumers of the SAME wiring (wireDropdown) so there is exactly
|
|
14
|
+
// one open/close/click-outside/Esc/keyboard implementation in the kit.
|
|
15
|
+
//
|
|
16
|
+
// container.innerHTML = dropdown({ label: 'version:', value: '…', items });
|
|
17
|
+
// wireDropdown(container); // or let wireTopbar() do it (it calls this)
|
|
18
|
+
import { esc, icon } from './index.js';
|
|
19
|
+
|
|
20
|
+
const cx = (...a) => a.filter(Boolean).join(' ');
|
|
21
|
+
|
|
22
|
+
// A trailing status badge. `badge` is a string ("live") or { text, tone }.
|
|
23
|
+
function ddBadge(badge) {
|
|
24
|
+
if (!badge) return '';
|
|
25
|
+
const text = typeof badge === 'string' ? badge : badge.text;
|
|
26
|
+
let tone = typeof badge === 'string' ? '' : (badge.tone || '');
|
|
27
|
+
if (!tone) tone = /^live$/i.test(text) ? 'live' : 'neutral';
|
|
28
|
+
return `<span class="${cx('ui-dropdown__badge', `is-${tone}`)}">${esc(text)}</span>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// One item row. `listbox` picks role=option (selectable) vs role=menuitem (action).
|
|
32
|
+
function ddItem(it, listbox) {
|
|
33
|
+
if (it === '---' || it.separator) return '<div class="ui-dropdown__sep" role="separator"></div>';
|
|
34
|
+
const disabled = !!it.disabled;
|
|
35
|
+
const selected = !!it.selected;
|
|
36
|
+
const role = listbox ? 'option' : 'menuitem';
|
|
37
|
+
const asLink = !!it.href && !disabled && !listbox;
|
|
38
|
+
const tag = asLink ? 'a' : 'div';
|
|
39
|
+
const lead = it.icon ? `<span class="ui-dropdown__ic">${icon(it.icon)}</span>` : '';
|
|
40
|
+
const desc = it.description ? `<span class="ui-dropdown__desc">${esc(it.description)}</span>` : '';
|
|
41
|
+
const main = `<span class="ui-dropdown__main"><span class="ui-dropdown__label">${esc(it.label)}</span>${desc}</span>`;
|
|
42
|
+
const badge = ddBadge(it.badge);
|
|
43
|
+
const tick = listbox ? `<span class="ui-dropdown__tick" aria-hidden="true">${icon('check')}</span>` : '';
|
|
44
|
+
const attrs = [
|
|
45
|
+
`class="${cx('ui-dropdown__item', selected && 'is-selected', disabled && 'is-disabled', it.danger && 'is-danger')}"`,
|
|
46
|
+
'data-dd-item',
|
|
47
|
+
`role="${role}"`,
|
|
48
|
+
'tabindex="-1"',
|
|
49
|
+
it.value != null ? `data-value="${esc(it.value)}"` : '',
|
|
50
|
+
listbox ? `aria-selected="${selected ? 'true' : 'false'}"` : '',
|
|
51
|
+
disabled ? 'aria-disabled="true"' : '',
|
|
52
|
+
asLink ? `href="${esc(it.href)}"` : '',
|
|
53
|
+
asLink && it.target ? `target="${esc(it.target)}"` : '',
|
|
54
|
+
].filter(Boolean).join(' ');
|
|
55
|
+
return `<${tag} ${attrs}>${lead}${main}${badge}${tick}</${tag}>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Render a flat item list or grouped sections ([{ label, items }]).
|
|
59
|
+
function ddBody({ items, sections }, listbox) {
|
|
60
|
+
if (sections && sections.length) {
|
|
61
|
+
return sections.map((s) => {
|
|
62
|
+
const head = s.label ? `<div class="ui-dropdown__group" role="presentation">${esc(s.label)}</div>` : '';
|
|
63
|
+
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>`;
|
|
64
|
+
}).join('');
|
|
65
|
+
}
|
|
66
|
+
return (items || []).map((it) => ddItem(it, listbox)).join('');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The public factory. Returns an HTML string; wire it with wireDropdown().
|
|
70
|
+
// label muted prefix in the trigger (e.g. "version:")
|
|
71
|
+
// value current value shown in the trigger (single-select)
|
|
72
|
+
// placeholder shown when there's no value
|
|
73
|
+
// variant 'select' (listbox) | 'menu' (default inferred from items)
|
|
74
|
+
// items [{ label, value?, description?, icon?, badge?, selected?, disabled?, href?, danger? }]
|
|
75
|
+
// sections [{ label, items }] — grouped alternative to items
|
|
76
|
+
// header/footer raw HTML blocks pinned top/bottom of the panel
|
|
77
|
+
// align 'start' (default) | 'end' — which edge the panel hugs
|
|
78
|
+
// scroll true | maxHeight px — cap panel height and scroll
|
|
79
|
+
// open render already-open (handy for screenshots)
|
|
80
|
+
// ariaLabel accessible name for the panel (and trigger, if no visible text)
|
|
81
|
+
export function dropdown({
|
|
82
|
+
label, value, placeholder = 'Select…', variant, items, sections,
|
|
83
|
+
header = '', footer = '', triggerContent, triggerClass = '', chevron = true,
|
|
84
|
+
align = 'start', scroll = false, open = false, ariaLabel, id, panelClass = '',
|
|
85
|
+
} = {}) {
|
|
86
|
+
const flat = sections ? sections.flatMap((s) => s.items || []) : (items || []);
|
|
87
|
+
const isSelect = variant === 'select' || (variant == null && flat.some((it) => it && (it.selected || it.value != null)));
|
|
88
|
+
const listRole = isSelect ? 'listbox' : 'menu';
|
|
89
|
+
const cur = value != null ? value : (isSelect ? (flat.find((it) => it && it.selected)?.label) : null);
|
|
90
|
+
|
|
91
|
+
const trig = triggerContent != null
|
|
92
|
+
? triggerContent
|
|
93
|
+
: `${label ? `<span class="ui-dropdown__pre">${esc(label)}</span>` : ''}` +
|
|
94
|
+
`<span class="ui-dropdown__value">${esc(cur != null ? cur : placeholder)}</span>`;
|
|
95
|
+
const triggerAttrs = [
|
|
96
|
+
`class="${cx('ui-dropdown__trigger', triggerClass)}"`,
|
|
97
|
+
'type="button"',
|
|
98
|
+
'data-dropdown-trigger',
|
|
99
|
+
`aria-haspopup="${listRole}"`,
|
|
100
|
+
`aria-expanded="${open ? 'true' : 'false'}"`,
|
|
101
|
+
ariaLabel && triggerContent != null ? `aria-label="${esc(ariaLabel)}"` : '',
|
|
102
|
+
].filter(Boolean).join(' ');
|
|
103
|
+
|
|
104
|
+
const panelAttrs = [
|
|
105
|
+
`class="${cx('ui-dropdown__panel', align === 'end' && 'is-end', scroll && 'is-scroll', panelClass)}"`,
|
|
106
|
+
'data-dropdown-panel',
|
|
107
|
+
`role="${listRole}"`,
|
|
108
|
+
ariaLabel ? `aria-label="${esc(ariaLabel)}"` : '',
|
|
109
|
+
scroll && scroll !== true ? `style="max-height:${typeof scroll === 'number' ? scroll + 'px' : esc(scroll)}"` : '',
|
|
110
|
+
].filter(Boolean).join(' ');
|
|
111
|
+
|
|
112
|
+
return `<div class="${cx('ui-dropdown', open && 'open')}" data-dropdown${isSelect ? ' data-dropdown-select' : ''}${id ? ` id="${esc(id)}"` : ''}>` +
|
|
113
|
+
`<button ${triggerAttrs}>${trig}${chevron ? '<span class="ui-dropdown__chevron" aria-hidden="true"></span>' : ''}</button>` +
|
|
114
|
+
`<div ${panelAttrs}>${header}${ddBody({ items, sections }, isSelect)}${footer}</div>` +
|
|
115
|
+
`</div>`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---- Shared behaviour ----------------------------------------------------
|
|
119
|
+
// One implementation for every dropdown in the kit — the generic component AND
|
|
120
|
+
// the topbar's version switcher / account menu (they emit the same data hooks:
|
|
121
|
+
// [data-dropdown] > [data-dropdown-trigger] + [data-dropdown-panel], toggling
|
|
122
|
+
// `.open` on the container so each keeps its own visual CSS).
|
|
123
|
+
//
|
|
124
|
+
// Per-instance trigger + keyboard handlers are attached once (guarded by a flag
|
|
125
|
+
// on the element). Document-level click-outside + Esc are attached once per
|
|
126
|
+
// document. Safe to call repeatedly (e.g. Storybook re-renders).
|
|
127
|
+
let _ddGlobalWired = false;
|
|
128
|
+
|
|
129
|
+
function ddItemsOf(dd) {
|
|
130
|
+
const panel = dd.querySelector('[data-dropdown-panel]');
|
|
131
|
+
if (!panel) return [];
|
|
132
|
+
return Array.from(panel.querySelectorAll('[data-dd-item]'))
|
|
133
|
+
.filter((el) => el.getAttribute('aria-disabled') !== 'true');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function closeDropdown(dd) {
|
|
137
|
+
if (!dd.classList.contains('open')) return;
|
|
138
|
+
dd.classList.remove('open');
|
|
139
|
+
dd.querySelector('[data-dropdown-trigger]')?.setAttribute('aria-expanded', 'false');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function closeAllDropdowns(except) {
|
|
143
|
+
document.querySelectorAll('[data-dropdown].open').forEach((dd) => { if (dd !== except) closeDropdown(dd); });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function openDropdown(dd, focusIdx) {
|
|
147
|
+
closeAllDropdowns(dd);
|
|
148
|
+
dd.classList.add('open');
|
|
149
|
+
dd.querySelector('[data-dropdown-trigger]')?.setAttribute('aria-expanded', 'true');
|
|
150
|
+
if (focusIdx != null) {
|
|
151
|
+
const items = ddItemsOf(dd);
|
|
152
|
+
const sel = items.findIndex((el) => el.getAttribute('aria-selected') === 'true');
|
|
153
|
+
(items[focusIdx === 'selected' && sel >= 0 ? sel : (focusIdx === 'selected' ? 0 : focusIdx)] || items[0])?.focus();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Single-select: reflect the picked option into aria-selected + the trigger value.
|
|
158
|
+
function selectOption(dd, item) {
|
|
159
|
+
if (!dd.hasAttribute('data-dropdown-select')) return;
|
|
160
|
+
ddItemsOf(dd).forEach((el) => el.setAttribute('aria-selected', el === item ? 'true' : 'false'));
|
|
161
|
+
dd.querySelectorAll('[data-dd-item].is-selected').forEach((el) => el.classList.remove('is-selected'));
|
|
162
|
+
item.classList.add('is-selected');
|
|
163
|
+
const valueEl = dd.querySelector('[data-dropdown-trigger] .ui-dropdown__value');
|
|
164
|
+
const label = item.querySelector('.ui-dropdown__label');
|
|
165
|
+
if (valueEl && label) valueEl.textContent = label.textContent;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function wireDropdown(root = document) {
|
|
169
|
+
const scope = root === document ? document : root;
|
|
170
|
+
scope.querySelectorAll('[data-dropdown]').forEach((dd) => {
|
|
171
|
+
if (dd.__ddWired) return;
|
|
172
|
+
dd.__ddWired = true;
|
|
173
|
+
const trigger = dd.querySelector('[data-dropdown-trigger]');
|
|
174
|
+
const panel = dd.querySelector('[data-dropdown-panel]');
|
|
175
|
+
if (!trigger) return;
|
|
176
|
+
|
|
177
|
+
trigger.addEventListener('click', (e) => {
|
|
178
|
+
e.stopPropagation();
|
|
179
|
+
if (dd.classList.contains('open')) closeDropdown(dd);
|
|
180
|
+
else openDropdown(dd);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (panel) {
|
|
184
|
+
// Clicks inside the panel shouldn't reach the document close handler;
|
|
185
|
+
// activating an item selects (single-select) + closes.
|
|
186
|
+
panel.addEventListener('click', (e) => {
|
|
187
|
+
e.stopPropagation();
|
|
188
|
+
const item = e.target.closest('[data-dd-item]');
|
|
189
|
+
if (!item || item.getAttribute('aria-disabled') === 'true') return;
|
|
190
|
+
selectOption(dd, item);
|
|
191
|
+
closeDropdown(dd);
|
|
192
|
+
trigger.focus();
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
dd.addEventListener('keydown', (e) => {
|
|
197
|
+
const open = dd.classList.contains('open');
|
|
198
|
+
const onTrigger = e.target === trigger;
|
|
199
|
+
if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && (onTrigger || open)) {
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
if (!open) return openDropdown(dd, e.key === 'ArrowDown' ? 0 : 'selected');
|
|
202
|
+
const items = ddItemsOf(dd);
|
|
203
|
+
const i = items.indexOf(document.activeElement);
|
|
204
|
+
const next = e.key === 'ArrowDown' ? (i + 1) % items.length : (i - 1 + items.length) % items.length;
|
|
205
|
+
items[next < 0 ? 0 : next]?.focus();
|
|
206
|
+
} else if (e.key === 'Home' && open) {
|
|
207
|
+
e.preventDefault(); ddItemsOf(dd)[0]?.focus();
|
|
208
|
+
} else if (e.key === 'End' && open) {
|
|
209
|
+
e.preventDefault(); const it = ddItemsOf(dd); it[it.length - 1]?.focus();
|
|
210
|
+
} else if ((e.key === 'Enter' || e.key === ' ') && open && e.target.matches('[data-dd-item]')) {
|
|
211
|
+
e.preventDefault(); e.target.click();
|
|
212
|
+
} else if (e.key === 'Escape' && open) {
|
|
213
|
+
e.preventDefault(); e.stopPropagation(); closeDropdown(dd); trigger.focus();
|
|
214
|
+
} else if (e.key === 'Tab' && open) {
|
|
215
|
+
closeDropdown(dd);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (!_ddGlobalWired) {
|
|
221
|
+
_ddGlobalWired = true;
|
|
222
|
+
document.addEventListener('click', () => closeAllDropdowns());
|
|
223
|
+
document.addEventListener('keydown', (e) => {
|
|
224
|
+
if (e.key !== 'Escape') return;
|
|
225
|
+
const open = document.querySelector('[data-dropdown].open');
|
|
226
|
+
if (open) { closeDropdown(open); open.querySelector('[data-dropdown-trigger]')?.focus(); }
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -23,6 +23,12 @@ export function feedbackWidget({
|
|
|
23
23
|
doneTitle = 'Feedback sent. Thank you!',
|
|
24
24
|
doneBody = 'Thanks — your note is on its way.',
|
|
25
25
|
} = {}) {
|
|
26
|
+
// The composer's Cancel/Send/Close use .ui-fbbtn, NOT the kit's button() — an
|
|
27
|
+
// intentional exception. They're internal to this self-contained widget and
|
|
28
|
+
// carry composer-scoped treatment button() doesn't model: a tighter footer
|
|
29
|
+
// size, the inline send-spinner, and a disabled-until-valid state wired by
|
|
30
|
+
// data-attributes. Promoting them to .ui-btn would regress the widget's look
|
|
31
|
+
// for no consumer gain, since the app never renders these buttons directly.
|
|
26
32
|
return `
|
|
27
33
|
<div class="ui-fbpill" data-fb-pill>${IC_MSG}${esc(label)}</div>
|
|
28
34
|
<div class="ui-fbscrim" data-fb-scrim></div>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Footer factory — the canonical site/app footer as an HTML string, matching the
|
|
2
|
+
// topbar idiom so any server-render consumer adopts it with no framework.
|
|
3
|
+
// Composes a brand lockup, grouped link columns, a legal/copyright row, optional
|
|
4
|
+
// social icons and an optional theme/accent switcher slot. Three variants:
|
|
5
|
+
// full — multi-column marketing footer (brand + columns + legal bar)
|
|
6
|
+
// slim — a single legal/copyright row
|
|
7
|
+
// app — compact, in-product (tight padding, surface background)
|
|
8
|
+
import { brand as brandLockup } from '../assets/brand.js';
|
|
9
|
+
import { esc } from './index.js';
|
|
10
|
+
|
|
11
|
+
const cx = (...a) => a.filter(Boolean).join(' ');
|
|
12
|
+
|
|
13
|
+
// Minimal brand-glyph set for the optional social row. Feather-ish where generic,
|
|
14
|
+
// simplified brand marks otherwise. Kept local so the footer is self-contained
|
|
15
|
+
// (the shared icon set is line-only and has no brand glyphs).
|
|
16
|
+
const SOCIAL = {
|
|
17
|
+
github: '<path d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.7c-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.9-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.9 1.53 2.34 1.09 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.64 0 0 .84-.27 2.75 1.02a9.5 9.5 0 0 1 5 0c1.91-1.29 2.75-1.02 2.75-1.02.55 1.37.2 2.39.1 2.64.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.68-4.57 4.93.36.31.68.92.68 1.85v2.74c0 .27.18.58.69.48A10 10 0 0 0 12 2z"/>',
|
|
18
|
+
x: '<path d="M4 3h4.5l4 5.5L17.5 3H21l-6.5 8.5L21 21h-4.5l-4.3-5.9L7 21H3.5l6.8-8.9z"/>',
|
|
19
|
+
linkedin: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 10v6M8 7v.01M12 16v-3a2 2 0 0 1 4 0v3" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>',
|
|
20
|
+
mail: '<rect x="3" y="5" width="18" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="m3 7 9 6 9-6" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function socialLink({ label, href = '#', icon: ic = 'github' } = {}) {
|
|
24
|
+
const glyph = SOCIAL[ic] || SOCIAL.github;
|
|
25
|
+
const fill = ic === 'github' || ic === 'x' ? 'currentColor' : 'none';
|
|
26
|
+
return `<a class="ui-footer__social" href="${href}" aria-label="${esc(label || ic)}">` +
|
|
27
|
+
`<svg viewBox="0 0 24 24" fill="${fill}" aria-hidden="true">${glyph}</svg></a>`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function linkList(links = []) {
|
|
31
|
+
return `<ul class="ui-footer__links">${links.map(({ label, href = '#', target }) =>
|
|
32
|
+
`<li><a href="${href}"${target ? ` target="${target}" rel="noreferrer"` : ''}>${esc(label)}</a></li>`).join('')}</ul>`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function column({ title, links = [] } = {}) {
|
|
36
|
+
return `<div class="ui-footer__col">` +
|
|
37
|
+
(title ? `<h4 class="ui-footer__col-title">${esc(title)}</h4>` : '') +
|
|
38
|
+
linkList(links) + `</div>`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Inline legal row (label + legal links + switcher slot). Shared by every variant.
|
|
42
|
+
function legalBar({ legal, legalLinks = [], switcher = '' } = {}) {
|
|
43
|
+
const links = legalLinks.length
|
|
44
|
+
? `<nav class="ui-footer__legal-links" aria-label="Legal">` +
|
|
45
|
+
legalLinks.map(({ label, href = '#' }) => `<a href="${href}">${esc(label)}</a>`).join('') + `</nav>`
|
|
46
|
+
: '';
|
|
47
|
+
const sw = switcher ? `<div class="ui-footer__switcher">${switcher}</div>` : '';
|
|
48
|
+
return `<div class="ui-footer__bar">` +
|
|
49
|
+
`<span class="ui-footer__legal">${esc(legal)}</span>` +
|
|
50
|
+
`${links}${sw}</div>`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Full footer. Pass which pieces to include; everything but `variant` is optional.
|
|
54
|
+
// brand — lockup options object ({ word, href }) or false to hide it
|
|
55
|
+
// tagline — short line under the brand (full variant only)
|
|
56
|
+
// columns — [{ title, links: [{ label, href, target }] }]
|
|
57
|
+
// social — [{ label, href, icon }] (icon: github|x|linkedin|mail)
|
|
58
|
+
// legal — copyright text (defaults to "© <year> Apliteni")
|
|
59
|
+
// legalLinks — [{ label, href }] inline row links
|
|
60
|
+
// switcher — trusted HTML slot (e.g. accentPicker()+themeToggle()), or ''
|
|
61
|
+
export function footer({
|
|
62
|
+
variant = 'full',
|
|
63
|
+
brand = { word: 'Strategy' },
|
|
64
|
+
tagline = '',
|
|
65
|
+
columns = [],
|
|
66
|
+
social = [],
|
|
67
|
+
legal = `© ${new Date().getFullYear()} Apliteni`,
|
|
68
|
+
legalLinks = [],
|
|
69
|
+
switcher = '',
|
|
70
|
+
} = {}) {
|
|
71
|
+
const cls = cx('ui-footer', `ui-footer--${variant}`);
|
|
72
|
+
|
|
73
|
+
// slim + app: a single legal row, no brand block or columns.
|
|
74
|
+
if (variant === 'slim' || variant === 'app') {
|
|
75
|
+
return `<footer class="${cls}" role="contentinfo"><div class="ui-footer__in">` +
|
|
76
|
+
legalBar({ legal, legalLinks, switcher }) +
|
|
77
|
+
`</div></footer>`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// full: brand block + link columns on top, legal bar below a divider.
|
|
81
|
+
const socialRow = social.length
|
|
82
|
+
? `<div class="ui-footer__socials">${social.map(socialLink).join('')}</div>` : '';
|
|
83
|
+
const brandBlock = brand !== false
|
|
84
|
+
? `<div class="ui-footer__brand">${brandLockup(brand)}` +
|
|
85
|
+
(tagline ? `<p class="ui-footer__tagline">${esc(tagline)}</p>` : '') +
|
|
86
|
+
socialRow + `</div>`
|
|
87
|
+
: '';
|
|
88
|
+
const nav = columns.length
|
|
89
|
+
? `<nav class="ui-footer__nav" aria-label="Footer">${columns.map(column).join('')}</nav>` : '';
|
|
90
|
+
|
|
91
|
+
return `<footer class="${cls}" role="contentinfo"><div class="ui-footer__in">` +
|
|
92
|
+
`<div class="ui-footer__top">${brandBlock}${nav}</div>` +
|
|
93
|
+
legalBar({ legal, legalLinks, switcher }) +
|
|
94
|
+
`</div></footer>`;
|
|
95
|
+
}
|
package/src/components/index.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
// apliteni-ui component factories — each returns an HTML string, matching the
|
|
2
2
|
// viz/ server-render idiom so the portal can adopt them with no framework.
|
|
3
3
|
import { icon } from '../assets/icons.js';
|
|
4
|
+
import { illo } from '../assets/illustrations.js';
|
|
4
5
|
|
|
5
6
|
const cx = (...a) => a.filter(Boolean).join(' ');
|
|
6
7
|
export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
7
8
|
|
|
8
9
|
// ---- Button --------------------------------------------------------------
|
|
10
|
+
// `iconSvg` is a raw leading-icon SVG string (trusted markup, not escaped) for
|
|
11
|
+
// branded glyphs the kit's icon set doesn't own — e.g. a Google "G". It takes
|
|
12
|
+
// the leading slot; `icon` is the named-icon shorthand for kit glyphs. This is
|
|
13
|
+
// what lets third-party buttons go through button() instead of hand-rolled markup.
|
|
9
14
|
export function button({
|
|
10
|
-
label = 'Button', variant = 'secondary', size = 'md', icon: ic, iconRight,
|
|
15
|
+
label = 'Button', variant = 'secondary', size = 'md', icon: ic, iconSvg, iconRight,
|
|
11
16
|
block = false, disabled = false, busy = false, type = 'button', href, iconOnly = false,
|
|
12
17
|
} = {}) {
|
|
13
18
|
const cls = cx(
|
|
@@ -18,7 +23,8 @@ export function button({
|
|
|
18
23
|
iconOnly && 'ui-btn--icon',
|
|
19
24
|
);
|
|
20
25
|
const bars = busy ? '<span class="ui-btn__bars"><i></i><i></i></span>' : '';
|
|
21
|
-
const
|
|
26
|
+
const lead = iconSvg || (ic ? icon(ic) : '');
|
|
27
|
+
const inner = `${lead}${iconOnly ? '' : `<span>${esc(label)}</span>`}${iconRight ? icon(iconRight) : ''}${bars}`;
|
|
22
28
|
// busy ⇒ disabled (not clickable while it works)
|
|
23
29
|
const attrs = `class="${cls}"${disabled || busy ? ' disabled aria-disabled="true"' : ''}${busy ? ' aria-busy="true"' : ''}${iconOnly ? ` aria-label="${esc(label)}"` : ''}`;
|
|
24
30
|
return href
|
|
@@ -48,28 +54,6 @@ export function card({ title, sub, body = '', variant, pad, icon: ic } = {}) {
|
|
|
48
54
|
return `<div class="${cls}">${head}${body}</div>`;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
|
-
// ---- Aurora — ambient warm backdrop --------------------------------------
|
|
52
|
-
// Layered blurred glow "blobs" + optional paper grain. Colours default to the
|
|
53
|
-
// accent glow tokens, so the field re-themes with the sub-theme. Drop it as the
|
|
54
|
-
// first child of a positioned wrapper (or use fixed: true for full-bleed) and
|
|
55
|
-
// give the real content a normal stacking context — it sits on top.
|
|
56
|
-
const AURORA_PRESET = [
|
|
57
|
-
{ tone: 'accent', x: '22%', y: '12%', size: '64%', delay: '0s' },
|
|
58
|
-
{ tone: 'teal', x: '84%', y: '24%', size: '56%', delay: '-9s' },
|
|
59
|
-
{ tone: 'warm', x: '54%', y: '92%', size: '70%', delay: '-17s' },
|
|
60
|
-
];
|
|
61
|
-
export function aurora({ blobs = AURORA_PRESET, grain = false, fixed = false, animated = true, className = '' } = {}) {
|
|
62
|
-
const items = blobs.map((b) => {
|
|
63
|
-
const tone = b.tone || 'accent';
|
|
64
|
-
const style = `--au-x:${b.x ?? '50%'};--au-y:${b.y ?? '50%'};--au-size:${b.size ?? '60%'}`
|
|
65
|
-
+ (b.delay != null ? `;--au-delay:${b.delay}` : '');
|
|
66
|
-
return `<span class="ui-aurora__blob ui-aurora__blob--${tone}" style="${style}"></span>`;
|
|
67
|
-
}).join('');
|
|
68
|
-
const grainEl = grain ? '<span class="ui-aurora__grain"></span>' : '';
|
|
69
|
-
const cls = cx('ui-aurora', animated && 'ui-aurora--animated', fixed && 'ui-aurora--fixed', className);
|
|
70
|
-
return `<div class="${cls}" aria-hidden="true">${items}${grainEl}</div>`;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
57
|
// ---- Segmented control ---------------------------------------------------
|
|
74
58
|
export function segmented({ options = [], active = 0, size, block, name = 'seg' } = {}) {
|
|
75
59
|
const cls = cx('ui-seg', size && `ui-seg--${size}`, block && 'ui-seg--block');
|
|
@@ -160,13 +144,48 @@ export function switchToggle({ checked = false, disabled = false, name, label =
|
|
|
160
144
|
export function callout({ variant, icon: ic = 'info', body } = {}) {
|
|
161
145
|
return `<div class="${cx('ui-callout', variant && `ui-callout--${variant}`)}"><span class="ui-callout__icon">${icon(ic)}</span><div>${body}</div></div>`;
|
|
162
146
|
}
|
|
163
|
-
|
|
164
|
-
|
|
147
|
+
// Default icon per status — overridable with `icon`.
|
|
148
|
+
const TOAST_ICON = { success: 'check', danger: 'x', warn: 'alert', info: 'info', neutral: 'bolt' };
|
|
149
|
+
// A toast carries a status (colour) and a style (surface). Everything visual is
|
|
150
|
+
// token-driven: the status modifier sets --toast-accent/-glow/-on, the style
|
|
151
|
+
// modifier consumes them. `action` adds a trailing button ("Undo"/"Retry"),
|
|
152
|
+
// `timer` adds an auto-dismiss progress bar (true → default 5s, or a number of
|
|
153
|
+
// seconds), `compact` drops the body for a single-line toast.
|
|
154
|
+
export function toast({
|
|
155
|
+
variant = 'success', style = 'soft', title, body, icon: ic,
|
|
156
|
+
action, timer = false, compact = false, dismissible = true,
|
|
157
|
+
} = {}) {
|
|
158
|
+
const cls = cx('ui-toast', `ui-toast--${variant}`, `ui-toast--${style}`, compact && 'ui-toast--compact');
|
|
159
|
+
const actLabel = typeof action === 'string' ? action : action && action.label;
|
|
160
|
+
const actBtn = actLabel ? `<button class="ui-toast__action" type="button">${esc(actLabel)}</button>` : '';
|
|
161
|
+
const closeBtn = dismissible ? `<button class="ui-toast__close" aria-label="Dismiss">${icon('x')}</button>` : '';
|
|
162
|
+
const bodyHtml = compact || !body ? '' : `<div class="ui-toast__text">${esc(body)}</div>`;
|
|
163
|
+
const dur = typeof timer === 'number' ? ` style="--toast-dur:${timer}s"` : '';
|
|
164
|
+
const timerBar = timer ? '<span class="ui-toast__timer" data-toast-timer></span>' : '';
|
|
165
|
+
return `<div class="${cls}" role="status" aria-live="polite"${dur}>`
|
|
166
|
+
+ `<span class="ui-toast__icon">${icon(ic || TOAST_ICON[variant] || 'info')}</span>`
|
|
167
|
+
+ `<div class="ui-toast__body">${title ? `<div class="ui-toast__title">${esc(title)}</div>` : ''}${bodyHtml}</div>`
|
|
168
|
+
+ `${actBtn}${closeBtn}${timerBar}</div>`;
|
|
165
169
|
}
|
|
166
170
|
export function successPanel({ title = 'Done', sub = '' } = {}) {
|
|
167
171
|
return `<div class="ui-success"><div class="ui-success__check">${icon('check')}</div><div class="ui-success__title">${esc(title)}</div>${sub ? `<div class="ui-success__sub">${esc(sub)}</div>` : ''}</div>`;
|
|
168
172
|
}
|
|
169
173
|
|
|
174
|
+
// ---- Empty state ---------------------------------------------------------
|
|
175
|
+
// The placeholder for an empty list/table/page. Pass `art` as an illustration
|
|
176
|
+
// name (see illustrations.js) or raw <svg> markup; a legacy line `icon` name is
|
|
177
|
+
// also accepted. `actions` is trusted button markup (e.g. button({…})).
|
|
178
|
+
export function emptyState({ art, icon: ic, title, sub, actions } = {}) {
|
|
179
|
+
const artHtml = art
|
|
180
|
+
? `<div class="ui-empty__art">${/<svg/.test(art) ? art : illo(art)}</div>`
|
|
181
|
+
: ic ? `<div class="ui-empty__icon">${icon(ic)}</div>` : '';
|
|
182
|
+
return `<div class="ui-empty">${artHtml}`
|
|
183
|
+
+ `${title ? `<div class="ui-empty__title">${esc(title)}</div>` : ''}`
|
|
184
|
+
+ `${sub ? `<div class="ui-empty__sub">${esc(sub)}</div>` : ''}`
|
|
185
|
+
+ `${actions ? `<div class="ui-empty__actions">${actions}</div>` : ''}`
|
|
186
|
+
+ `</div>`;
|
|
187
|
+
}
|
|
188
|
+
|
|
170
189
|
// ---- Snippet -------------------------------------------------------------
|
|
171
190
|
export function snippet({ label = 'shell', code = '', reveal = false, copy = true } = {}) {
|
|
172
191
|
return `<div class="${cx('ui-snippet', reveal && 'ui-snippet--reveal')}"><div class="ui-snippet__bar"><span>${esc(label)}</span>${copy ? `<button class="ui-snippet__copy">${icon('copy')}Copy</button>` : ''}</div><pre>${code}</pre></div>`;
|
|
@@ -187,3 +206,4 @@ export const hlShell = (raw) =>
|
|
|
187
206
|
);
|
|
188
207
|
|
|
189
208
|
export { icon };
|
|
209
|
+
export { illo, illoNames } from '../assets/illustrations.js';
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Navigation — the kit's primary wayfinding primitives. One umbrella factory,
|
|
2
|
+
// `nav({ variant })`, dispatches to three shapes that share tokens + classes but
|
|
3
|
+
// almost no markup, so each is also exported directly for ergonomic use:
|
|
4
|
+
//
|
|
5
|
+
// variant: 'sidebar' → sidebarNav() vertical rail: sections, icon+label
|
|
6
|
+
// items, active state, collapsible groups, an
|
|
7
|
+
// icon-only collapsed mode, optional per-item badge.
|
|
8
|
+
// variant: 'tabs' → navTabs() horizontal page tabs with an active
|
|
9
|
+
// underline (default) or pill.
|
|
10
|
+
// variant: 'breadcrumbs' → breadcrumbs() the `Finance / Payouts` trail.
|
|
11
|
+
//
|
|
12
|
+
// Why an umbrella AND named exports (unlike dropdown, which is one factory): the
|
|
13
|
+
// three variants don't share a body the way select/menu share a panel, so a
|
|
14
|
+
// single options bag would mean wildly different fields per variant. The named
|
|
15
|
+
// exports keep each call site honest; `nav()` stays the discoverable entry the
|
|
16
|
+
// issue asks for and the one thing to import when the variant is data-driven.
|
|
17
|
+
//
|
|
18
|
+
// These are NAVIGATION controls (links between locations), not tab panels — so
|
|
19
|
+
// tabs render as <nav> + <a aria-current="page">, not role="tablist" (that's
|
|
20
|
+
// what segmented() is for). Only the collapsible sidebar groups need JS; wire
|
|
21
|
+
// them once after mount with wireNav() (preview.js does this for Storybook).
|
|
22
|
+
import { esc, icon } from './index.js';
|
|
23
|
+
|
|
24
|
+
const cx = (...a) => a.filter(Boolean).join(' ');
|
|
25
|
+
|
|
26
|
+
// Unique-per-render ids so a section heading can label its own list. Module
|
|
27
|
+
// counter (never Date/random) — matches field()'s nextId() in index.js.
|
|
28
|
+
let _uid = 0;
|
|
29
|
+
const nextId = (p = 'nav') => `${p}-${++_uid}`;
|
|
30
|
+
|
|
31
|
+
// A trailing counter/badge on a nav item. `badge` is a number, a string, or
|
|
32
|
+
// { text, tone }. Tones map to the badge token set (accent | live | neutral…).
|
|
33
|
+
function navBadge(badge) {
|
|
34
|
+
if (badge == null || badge === '') return '';
|
|
35
|
+
const text = typeof badge === 'object' ? badge.text : badge;
|
|
36
|
+
const tone = typeof badge === 'object' && badge.tone ? badge.tone : 'neutral';
|
|
37
|
+
return `<span class="${cx('ui-nav__badge', `is-${tone}`)}">${esc(text)}</span>`;
|
|
38
|
+
}
|
|
39
|
+
|
|
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.
|
|
42
|
+
function sideLeaf(it, active, { collapsed, sub } = {}) {
|
|
43
|
+
const on = it.id != null && it.id === active;
|
|
44
|
+
const disabled = !!it.disabled;
|
|
45
|
+
const label = it.label || '';
|
|
46
|
+
const lead = it.icon ? `<span class="ui-nav__ic">${icon(it.icon)}</span>` : '';
|
|
47
|
+
const text = `<span class="ui-nav__label">${esc(label)}</span>`;
|
|
48
|
+
const badge = navBadge(it.badge);
|
|
49
|
+
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)}"` : '';
|
|
51
|
+
if (disabled) {
|
|
52
|
+
return `<li><span class="${cls}" aria-disabled="true"${name}>${lead}${text}${badge}</span></li>`;
|
|
53
|
+
}
|
|
54
|
+
const attrs = [
|
|
55
|
+
`class="${cls}"`,
|
|
56
|
+
`href="${esc(it.href || '#' + (it.id ?? ''))}"`,
|
|
57
|
+
it.target ? `target="${esc(it.target)}"` : '',
|
|
58
|
+
on ? 'aria-current="page"' : '',
|
|
59
|
+
name.trim(),
|
|
60
|
+
].filter(Boolean).join(' ');
|
|
61
|
+
return `<li><a ${attrs}>${lead}${text}${badge}</a></li>`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A collapsible group: a toggle button (aria-expanded/-controls) over a nested
|
|
65
|
+
// list. In collapsed (icon-only) mode groups don't expand, so we render the
|
|
66
|
+
// group head as a plain, non-collapsing icon row.
|
|
67
|
+
function sideGroup(it, active, { collapsed } = {}) {
|
|
68
|
+
const listId = nextId('nav-grp');
|
|
69
|
+
const childActive = (it.items || []).some((c) => c.id != null && c.id === active);
|
|
70
|
+
const open = collapsed ? false : (it.open != null ? !!it.open : childActive);
|
|
71
|
+
const lead = it.icon ? `<span class="ui-nav__ic">${icon(it.icon)}</span>` : '';
|
|
72
|
+
const label = it.label || '';
|
|
73
|
+
const text = `<span class="ui-nav__label">${esc(label)}</span>`;
|
|
74
|
+
const kids = (it.items || []).map((c) => sideLeaf(c, active, { collapsed, sub: true })).join('');
|
|
75
|
+
const name = collapsed ? ` aria-label="${esc(label)}" title="${esc(label)}"` : '';
|
|
76
|
+
const btn =
|
|
77
|
+
`<button type="button" class="${cx('ui-nav__item', 'ui-nav__toggle', childActive && 'is-current')}"` +
|
|
78
|
+
` data-nav-toggle aria-expanded="${open ? 'true' : 'false'}" aria-controls="${listId}"${name}>` +
|
|
79
|
+
`${lead}${text}<span class="ui-nav__caret" aria-hidden="true"></span></button>`;
|
|
80
|
+
return `<li class="${cx('ui-nav__group', open && 'is-open')}">${btn}` +
|
|
81
|
+
`<ul class="ui-nav__sub" id="${listId}"${open ? '' : ' hidden'}>${kids}</ul></li>`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sideItem(it, active, opts) {
|
|
85
|
+
return (it.items && it.items.length) ? sideGroup(it, active, opts) : sideLeaf(it, active, opts);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Sidebar nav. Pass `sections` ([{ label, items }]) or a flat `items` list.
|
|
89
|
+
// items/section items: { id, label, icon?, href?, target?, badge?, danger?,
|
|
90
|
+
// disabled?, items?, open? } (items ⇒ collapsible group)
|
|
91
|
+
// active id of the current item (gets aria-current="page")
|
|
92
|
+
// collapsed icon-only rail; labels fold into aria-label + title
|
|
93
|
+
// footer trusted HTML pinned below a divider (e.g. a sign-out link)
|
|
94
|
+
// ariaLabel accessible name for the <nav> landmark
|
|
95
|
+
export function sidebarNav({
|
|
96
|
+
sections, items, active, collapsed = false, footer = '', ariaLabel = 'Sidebar', id,
|
|
97
|
+
} = {}) {
|
|
98
|
+
const blocks = (sections && sections.length)
|
|
99
|
+
? sections
|
|
100
|
+
: [{ items: items || [] }];
|
|
101
|
+
const body = blocks.map((sec) => {
|
|
102
|
+
const capId = sec.label ? nextId('nav-cap') : '';
|
|
103
|
+
const cap = sec.label
|
|
104
|
+
? `<div class="ui-nav__cap" id="${capId}"${collapsed ? ' aria-hidden="true"' : ''}>${esc(sec.label)}</div>`
|
|
105
|
+
: '';
|
|
106
|
+
const list = `<ul class="ui-nav__list"${capId ? ` aria-labelledby="${capId}"` : ''}>` +
|
|
107
|
+
(sec.items || []).map((it) => sideItem(it, active, { collapsed })).join('') + `</ul>`;
|
|
108
|
+
return `<div class="ui-nav__section">${cap}${list}</div>`;
|
|
109
|
+
}).join('');
|
|
110
|
+
const foot = footer ? `<div class="ui-nav__foot">${footer}</div>` : '';
|
|
111
|
+
return `<nav class="${cx('ui-nav', 'ui-nav--side', collapsed && 'is-collapsed')}"` +
|
|
112
|
+
` aria-label="${esc(ariaLabel)}"${id ? ` id="${esc(id)}"` : ''}>${body}${foot}</nav>`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Horizontal page tabs — links, not a tablist. `variant` picks the active
|
|
116
|
+
// affordance: 'underline' (default) or 'pill'.
|
|
117
|
+
// items { id, label, href?, target?, badge?, disabled? }
|
|
118
|
+
// active id of the current tab (aria-current="page")
|
|
119
|
+
export function navTabs({
|
|
120
|
+
items = [], active, variant = 'underline', ariaLabel = 'Tabs', id,
|
|
121
|
+
} = {}) {
|
|
122
|
+
const tabs = items.map((it) => {
|
|
123
|
+
const on = it.id != null && it.id === active;
|
|
124
|
+
const badge = navBadge(it.badge);
|
|
125
|
+
const inner = `<span class="ui-nav__tab-label">${esc(it.label)}</span>${badge}`;
|
|
126
|
+
if (it.disabled) {
|
|
127
|
+
return `<span class="ui-nav__tab is-disabled" aria-disabled="true">${inner}</span>`;
|
|
128
|
+
}
|
|
129
|
+
const attrs = [
|
|
130
|
+
`class="${cx('ui-nav__tab', on && 'is-active')}"`,
|
|
131
|
+
`href="${esc(it.href || '#' + (it.id ?? ''))}"`,
|
|
132
|
+
it.target ? `target="${esc(it.target)}"` : '',
|
|
133
|
+
on ? 'aria-current="page"' : '',
|
|
134
|
+
].filter(Boolean).join(' ');
|
|
135
|
+
return `<a ${attrs}>${inner}</a>`;
|
|
136
|
+
}).join('');
|
|
137
|
+
return `<nav class="${cx('ui-nav', 'ui-nav--tabs', `is-${variant}`)}"` +
|
|
138
|
+
` aria-label="${esc(ariaLabel)}"${id ? ` id="${esc(id)}"` : ''}>${tabs}</nav>`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Breadcrumb trail. The last crumb is the current page (aria-current="page",
|
|
142
|
+
// rendered as text). Earlier crumbs link when they carry an `href`.
|
|
143
|
+
// items { label, href?, target?, icon? }
|
|
144
|
+
export function breadcrumbs({ items = [], ariaLabel = 'Breadcrumb', id } = {}) {
|
|
145
|
+
const last = items.length - 1;
|
|
146
|
+
const crumbs = items.map((it, i) => {
|
|
147
|
+
const lead = it.icon ? `<span class="ui-nav__ic">${icon(it.icon)}</span>` : '';
|
|
148
|
+
const label = `<span class="ui-nav__crumb-label">${esc(it.label)}</span>`;
|
|
149
|
+
const inner = `${lead}${label}`;
|
|
150
|
+
const isLast = i === last;
|
|
151
|
+
const cell = (!isLast && it.href)
|
|
152
|
+
? `<a class="ui-nav__crumb" href="${esc(it.href)}"${it.target ? ` target="${esc(it.target)}"` : ''}>${inner}</a>`
|
|
153
|
+
: `<span class="${cx('ui-nav__crumb', isLast && 'is-current')}"${isLast ? ' aria-current="page"' : ''}>${inner}</span>`;
|
|
154
|
+
return `<li class="ui-nav__crumb-item">${cell}</li>`;
|
|
155
|
+
}).join('');
|
|
156
|
+
return `<nav class="ui-nav ui-nav--crumbs" aria-label="${esc(ariaLabel)}"${id ? ` id="${esc(id)}"` : ''}>` +
|
|
157
|
+
`<ol class="ui-nav__crumbs">${crumbs}</ol></nav>`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Umbrella dispatcher — the one thing to import when the variant is data-driven.
|
|
161
|
+
export function nav({ variant = 'sidebar', ...opts } = {}) {
|
|
162
|
+
if (variant === 'tabs') return navTabs(opts);
|
|
163
|
+
if (variant === 'breadcrumbs') return breadcrumbs(opts);
|
|
164
|
+
return sidebarNav(opts);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---- Behaviour -----------------------------------------------------------
|
|
168
|
+
// Only the collapsible sidebar groups need JS: toggle a group's `.is-open` +
|
|
169
|
+
// its button's aria-expanded, and show/hide the nested list. Idempotent and
|
|
170
|
+
// event-delegated, so it's safe to call repeatedly (Storybook re-renders).
|
|
171
|
+
let _navGlobalWired = false;
|
|
172
|
+
|
|
173
|
+
function toggleGroup(btn) {
|
|
174
|
+
const li = btn.closest('.ui-nav__group');
|
|
175
|
+
const list = document.getElementById(btn.getAttribute('aria-controls'));
|
|
176
|
+
const open = btn.getAttribute('aria-expanded') === 'true';
|
|
177
|
+
btn.setAttribute('aria-expanded', open ? 'false' : 'true');
|
|
178
|
+
if (li) li.classList.toggle('is-open', !open);
|
|
179
|
+
if (list) { if (open) list.setAttribute('hidden', ''); else list.removeAttribute('hidden'); }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function wireNav(root = document) {
|
|
183
|
+
// Per-collapsed-rail behaviour would go here; groups use one delegated handler.
|
|
184
|
+
if (_navGlobalWired) return;
|
|
185
|
+
_navGlobalWired = true;
|
|
186
|
+
document.addEventListener('click', (e) => {
|
|
187
|
+
const btn = e.target.closest && e.target.closest('[data-nav-toggle]');
|
|
188
|
+
if (btn) { e.preventDefault(); toggleGroup(btn); }
|
|
189
|
+
});
|
|
190
|
+
}
|