@apliteni/apliteni-ui 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -0,0 +1,126 @@
1
+ // Success / confirmation surface — the emotional high point of a flow, done
2
+ // with craft. One factory, three layouts and three backdrops, an SVG check
3
+ // that draws itself in, optional confetti and an optional auto-redirect
4
+ // countdown. Every motion path is reduced-motion safe (static check, no burst,
5
+ // no confetti, no sweep). Accent-aware via the kit tokens; the success mark
6
+ // stays on the --green family. Styles ship in styles/success.css.
7
+ //
8
+ // container.innerHTML = success({
9
+ // title: 'Feedback sent',
10
+ // body: 'It goes straight to the strategy owner.',
11
+ // actions: [
12
+ // { label: 'Back to strategy', variant: 'primary', icon: 'compass' },
13
+ // { label: 'Send another', variant: 'ghost' },
14
+ // ],
15
+ // });
16
+ //
17
+ // The check animation is pure CSS, so string-rendered markup animates on its
18
+ // own once mounted. A live countdown (ticking numbers + redirect) is opt-in via
19
+ // wireSuccess(); the markup alone shows the ring sweep + a static number.
20
+ import { esc, button } from './index.js';
21
+
22
+ // Self-drawing check: a faint track disc, a filled accent disc that springs in,
23
+ // the tick that strokes on via dash-offset, and a burst ring that expands and
24
+ // fades. viewBox 0 0 52 52; the tick path length is ~34 (dasharray tuned to it).
25
+ export function successCheck() {
26
+ // width/height attributes keep the base svg:not([width]) fallback from
27
+ // sizing us; the real size is set per-layout in success.css.
28
+ return `<svg class="ui-sx__check" width="52" height="52" viewBox="0 0 52 52" aria-hidden="true" focusable="false">
29
+ <circle class="ui-sx__ring" cx="26" cy="26" r="24"/>
30
+ <circle class="ui-sx__disc" cx="26" cy="26" r="24"/>
31
+ <path class="ui-sx__tick" d="M15 27l7.5 7.5L37 18"/>
32
+ </svg>`;
33
+ }
34
+
35
+ // A deterministic confetti field (opt-in). Decorative + aria-hidden; each piece
36
+ // carries its own drift/rotation/colour via custom props so the CSS can scatter
37
+ // them without inline keyframes. Deterministic so string renders stay stable
38
+ // (and the a11y snapshot is reproducible).
39
+ const CONFETTI = [
40
+ { x: 8, d: 0.00, r: -24, t: 'a', s: 1.0 }, { x: 20, d: 0.14, r: 40, t: 'g', s: 0.8 },
41
+ { x: 31, d: 0.06, r: 12, t: 'c', s: 1.1 }, { x: 42, d: 0.20, r: -52, t: 'p', s: 0.9 },
42
+ { x: 50, d: 0.02, r: 28, t: 'a', s: 1.0 }, { x: 58, d: 0.18, r: -16, t: 'k', s: 0.75 },
43
+ { x: 67, d: 0.09, r: 60, t: 'g', s: 1.05 },{ x: 76, d: 0.24, r: -36, t: 'c', s: 0.85 },
44
+ { x: 85, d: 0.05, r: 20, t: 'a', s: 1.0 }, { x: 92, d: 0.16, r: -48, t: 'p', s: 0.9 },
45
+ { x: 14, d: 0.30, r: 44, t: 'k', s: 0.8 }, { x: 37, d: 0.34, r: -28, t: 'g', s: 1.0 },
46
+ { x: 62, d: 0.28, r: 52, t: 'a', s: 0.9 }, { x: 80, d: 0.36, r: -20, t: 'c', s: 1.05 },
47
+ ];
48
+ function confettiField() {
49
+ const pieces = CONFETTI.map((p) =>
50
+ `<i class="ui-sx__piece ui-sx__piece--${p.t}" style="left:${p.x}%;--sx-d:${p.d}s;--sx-r:${p.r}deg;--sx-s:${p.s}"></i>`,
51
+ ).join('');
52
+ return `<div class="ui-sx__confetti" aria-hidden="true">${pieces}</div>`;
53
+ }
54
+
55
+ // Optional auto-redirect countdown. Markup-only here (a conic ring sweeps and a
56
+ // static number shows); wireSuccess() turns it into a live ticking counter.
57
+ function countdownEl({ seconds = 5, label = 'Redirecting' } = {}) {
58
+ return `<div class="ui-sx__count" data-sx-count style="--sx-secs:${seconds}s">
59
+ <span class="ui-sx__count-ring" aria-hidden="true"></span>
60
+ <span class="ui-sx__count-text">${esc(label)} in <b data-sx-num>${seconds}</b>s</span>
61
+ </div>`;
62
+ }
63
+
64
+ export function success({
65
+ layout = 'hero', // 'hero' | 'split' | 'compact'
66
+ backdrop = 'aurora', // 'aurora' | 'glow' | 'flat'
67
+ eyebrow = '',
68
+ title = 'All done',
69
+ body = '',
70
+ actions = [], // [{ label, variant, href, icon, iconRight, size }]
71
+ confetti = false, // opt-in particle burst (reduced-motion safe)
72
+ countdown = null, // { seconds, label } | null
73
+ className = '',
74
+ } = {}) {
75
+ const cls = ['ui-sx', `ui-sx--${layout}`, `ui-sx--bd-${backdrop}`, confetti && 'ui-sx--confetti', className]
76
+ .filter(Boolean).join(' ');
77
+
78
+ const bd = backdrop === 'aurora'
79
+ ? `<div class="ui-sx__aurora" aria-hidden="true">
80
+ <span class="ui-sx__glow ui-sx__glow--a"></span>
81
+ <span class="ui-sx__glow ui-sx__glow--b"></span>
82
+ </div>`
83
+ : backdrop === 'glow'
84
+ ? `<span class="ui-glow ui-glow--green ui-sx__bg-glow" aria-hidden="true"></span>`
85
+ : '';
86
+
87
+ const eyebrowEl = eyebrow ? `<div class="ui-sx__eyebrow">${esc(eyebrow)}</div>` : '';
88
+ const bodyEl = body ? `<p class="ui-sx__body">${esc(body)}</p>` : '';
89
+ const actionsEl = actions.length
90
+ ? `<div class="ui-sx__actions">${actions.map((a) => button({ size: 'md', ...a })).join('')}</div>`
91
+ : '';
92
+ const countEl = countdown ? countdownEl(countdown) : '';
93
+
94
+ return `<div class="${cls}" role="status" aria-live="polite">
95
+ ${bd}
96
+ ${confetti ? confettiField() : ''}
97
+ <div class="ui-sx__inner">
98
+ <div class="ui-sx__visual">${successCheck()}</div>
99
+ <div class="ui-sx__content">
100
+ ${eyebrowEl}
101
+ <h3 class="ui-sx__title">${esc(title)}</h3>
102
+ ${bodyEl}
103
+ ${actionsEl}
104
+ ${countEl}
105
+ </div>
106
+ </div>
107
+ </div>`;
108
+ }
109
+
110
+ // Live countdown wiring (opt-in). Ticks the number down once a second and calls
111
+ // onDone() at zero — e.g. to navigate. Respects reduced-motion only for the
112
+ // visual sweep (owned by CSS); the counter itself is content, so it still runs.
113
+ // Returns a stop() to cancel (unmount / user interaction).
114
+ // const stop = wireSuccess(el, { onDone: () => location.assign('/strategy') });
115
+ export function wireSuccess(root, { onDone } = {}) {
116
+ const box = root && root.querySelector('[data-sx-count]');
117
+ if (!box) return () => {};
118
+ const numEl = box.querySelector('[data-sx-num]');
119
+ let n = parseInt(box.style.getPropertyValue('--sx-secs'), 10) || parseInt(numEl?.textContent, 10) || 5;
120
+ const id = setInterval(() => {
121
+ n -= 1;
122
+ if (numEl) numEl.textContent = String(Math.max(0, n));
123
+ if (n <= 0) { clearInterval(id); if (typeof onDone === 'function') onDone(); }
124
+ }, 1000);
125
+ return () => clearInterval(id);
126
+ }
@@ -0,0 +1,94 @@
1
+ // Toast stack controller — entrance/exit, auto-dismiss timers, swipe-to-dismiss.
2
+ //
3
+ // The visual markup comes from toast() in index.js; this wires behaviour onto a
4
+ // container of them, or lets you push new ones onto a live stack:
5
+ //
6
+ // import { toast } from '@apliteni/apliteni-ui';
7
+ // import { wireToastStack, pushToast } from '@apliteni/apliteni-ui/toasts';
8
+ // stack.innerHTML = toast({ variant: 'info', title: 'Saved', timer: 5 });
9
+ // wireToastStack(stack);
10
+ // pushToast(stack, { variant: 'success', title: 'Done', timer: 5 });
11
+ //
12
+ // The kit owns the animation + interaction; your app decides when to push and
13
+ // what each toast says. Reduced-motion is respected (no slide, instant remove).
14
+ import { toast } from './index.js';
15
+
16
+ const reduceMotion = () =>
17
+ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
18
+
19
+ // Slide-and-fade a toast out, then remove it. Idempotent.
20
+ export function dismissToast(el) {
21
+ if (!el || el.dataset.leaving) return;
22
+ el.dataset.leaving = '1';
23
+ const remove = () => el.remove();
24
+ if (reduceMotion()) return remove();
25
+ el.classList.add('is-leaving');
26
+ el.addEventListener('animationend', remove, { once: true });
27
+ setTimeout(remove, 260); // fallback if animationend never fires
28
+ }
29
+
30
+ // Wire one toast: close button, auto-dismiss timer (pauses on hover),
31
+ // swipe-to-dismiss via pointer drag.
32
+ function wireToast(el) {
33
+ if (el.dataset.wired) return;
34
+ el.dataset.wired = '1';
35
+
36
+ el.querySelector('.ui-toast__close')?.addEventListener('click', () => dismissToast(el));
37
+
38
+ const bar = el.querySelector('[data-toast-timer]');
39
+ if (bar && !reduceMotion()) {
40
+ bar.classList.add('is-running');
41
+ const dur = parseFloat(getComputedStyle(el).getPropertyValue('--toast-dur')) || 5;
42
+ let timer = setTimeout(() => dismissToast(el), dur * 1000);
43
+ el.addEventListener('mouseenter', () => { clearTimeout(timer); bar.style.animationPlayState = 'paused'; });
44
+ el.addEventListener('mouseleave', () => {
45
+ bar.style.animationPlayState = 'running';
46
+ const left = (parseFloat(getComputedStyle(bar).transform.split(',')[0].slice(7)) || 1);
47
+ timer = setTimeout(() => dismissToast(el), dur * 1000 * left);
48
+ });
49
+ } else if (bar && reduceMotion()) {
50
+ // No animated bar under reduced motion, but still auto-dismiss on time.
51
+ const dur = parseFloat(getComputedStyle(el).getPropertyValue('--toast-dur')) || 5;
52
+ setTimeout(() => dismissToast(el), dur * 1000);
53
+ }
54
+
55
+ // Swipe-to-dismiss (ignore drags that start on a button).
56
+ let x0 = null;
57
+ el.addEventListener('pointerdown', (e) => {
58
+ if (e.target.closest('button')) return;
59
+ x0 = e.clientX; el.setPointerCapture(e.pointerId); el.style.transition = 'none';
60
+ });
61
+ el.addEventListener('pointermove', (e) => {
62
+ if (x0 == null) return;
63
+ const dx = e.clientX - x0;
64
+ el.style.transform = `translateX(${dx}px)`;
65
+ el.style.opacity = String(Math.max(0, 1 - Math.abs(dx) / 240));
66
+ });
67
+ const settle = (e) => {
68
+ if (x0 == null) return;
69
+ const dx = e.clientX - x0; x0 = null;
70
+ if (Math.abs(dx) > 90) return dismissToast(el);
71
+ el.style.transition = 'transform 0.18s ease, opacity 0.18s ease';
72
+ el.style.transform = ''; el.style.opacity = '';
73
+ };
74
+ el.addEventListener('pointerup', settle);
75
+ el.addEventListener('pointercancel', settle);
76
+ }
77
+
78
+ // Wire every toast currently inside a container (string selector or element).
79
+ export function wireToastStack(container) {
80
+ const root = typeof container === 'string' ? document.querySelector(container) : container;
81
+ if (!root) return null;
82
+ root.querySelectorAll('.ui-toast').forEach(wireToast);
83
+ return root;
84
+ }
85
+
86
+ // Build a toast from opts, prepend it to a live stack (newest on top), wire it.
87
+ export function pushToast(container, opts = {}) {
88
+ const root = typeof container === 'string' ? document.querySelector(container) : container;
89
+ if (!root) return null;
90
+ root.insertAdjacentHTML('afterbegin', toast(opts));
91
+ const el = root.firstElementChild;
92
+ wireToast(el);
93
+ return el;
94
+ }
@@ -2,6 +2,8 @@
2
2
  // (brand, Deck/Text, theme toggle, version switcher, account menu).
3
3
  import { brand } from '../assets/brand.js';
4
4
  import { icon, sun, moon } from '../assets/icons.js';
5
+ import { esc } from './index.js';
6
+ import { wireDropdown } from './dropdown.js';
5
7
 
6
8
  const THEME_KEY = 'apliteni-strategy-theme';
7
9
 
@@ -15,15 +17,18 @@ export function deckTextSwitch(active = 'deck') {
15
17
  `<a${active === 'text' ? ' class="cur" aria-current="page"' : ''} href="#text">Text</a></div>`;
16
18
  }
17
19
 
20
+ // Thin consumer of the shared dropdown wiring (wireDropdown): keeps its own
21
+ // bespoke .vsw/.vopt classes for a pixel-identical look, but emits the generic
22
+ // [data-dropdown] hooks so there's ONE open/close/keyboard implementation.
18
23
  export function versionSwitcher(versions = [], activeIdx = 0) {
19
24
  const cur = versions[activeIdx]?.label || '';
20
25
  const opts = versions.map((v, i) =>
21
- `<div class="vopt" role="option" data-active="${i === activeIdx ? '1' : '0'}">` +
26
+ `<div class="vopt" role="option" data-dd-item tabindex="-1" aria-selected="${i === activeIdx}" data-active="${i === activeIdx ? '1' : '0'}">` +
22
27
  `<span><div class="vname">${v.label}</div><div class="vmeta">${v.meta || ''}</div></span>` +
23
28
  `<span class="vbadge ${v.badge === 'live' ? 'live' : 'arch'}">${v.badge || 'archive'}</span></div>`).join('');
24
- return `<div class="vsw" data-vsw><button class="vsw__btn" aria-haspopup="listbox" aria-expanded="false">` +
29
+ return `<div class="vsw" data-dropdown><button type="button" class="vsw__btn" data-dropdown-trigger aria-haspopup="listbox" aria-expanded="false" aria-label="Version — ${esc(cur)}">` +
25
30
  `<span class="lbl">version:</span><span class="cur">${cur}</span><span class="car"></span></button>` +
26
- `<div class="vsw__menu" role="listbox">${opts}</div></div>`;
31
+ `<div class="vsw__menu" data-dropdown-panel role="listbox" aria-label="Version">${opts}</div></div>`;
27
32
  }
28
33
 
29
34
  // `nav` ([id, icon, label, href?, target?][]) mirrors the account sidebar so the
@@ -32,14 +37,15 @@ export function accountMenu({ name = 'Ada Lovelace', email = 'ada@apliteni.com',
32
37
  const ini = (email.split('@')[0].split(/[._-]+/).filter(Boolean).map((w) => w[0]).slice(0, 2).join('') || '?').toUpperCase();
33
38
  const items = nav && nav.length ? nav : [['prefs', 'gear', 'Preferences'], ['access', 'key', 'Access &amp; agents']];
34
39
  const it = ([id, ic, label, href, target]) =>
35
- `<a href="${href || '#' + id}"${target ? ` target="${target}"` : ''}${active === id ? ' class="cur"' : ''} role="menuitem">${icon(ic)}${label}</a>`;
40
+ `<a href="${href || '#' + id}"${target ? ` target="${target}"` : ''} data-dd-item tabindex="-1"${active === id ? ' class="cur"' : ''} role="menuitem">${icon(ic)}${label}</a>`;
36
41
  // `on` so the menu is visible in Storybook / standalone use (no /auth/me gate).
37
- return `<div class="acct on" data-acct>` +
38
- `<button class="avatar" data-acct-btn aria-haspopup="menu" aria-expanded="false" aria-label="Account">${ini}</button>` +
39
- `<div class="amenu" role="menu">` +
42
+ // Consumes the shared dropdown wiring via the generic [data-dropdown] hooks.
43
+ return `<div class="acct on" data-dropdown>` +
44
+ `<button class="avatar" data-dropdown-trigger aria-haspopup="menu" aria-expanded="false" aria-label="Account">${ini}</button>` +
45
+ `<div class="amenu" data-dropdown-panel role="menu">` +
40
46
  `<div class="ahead"><span class="avatar">${ini}</span><span class="aw"><span class="anm">${name}</span><span class="aem" title="${email}">${email}</span></span></div>` +
41
47
  items.map(it).join('') +
42
- `<div class="asep"></div><a class="aout" href="#logout" role="menuitem">${icon('logout')}Sign out</a>` +
48
+ `<div class="asep"></div><a class="aout" href="#logout" data-dd-item tabindex="-1" role="menuitem">${icon('logout')}Sign out</a>` +
43
49
  `</div></div>`;
44
50
  }
45
51
 
@@ -108,13 +114,9 @@ export function wireTopbar(root = document) {
108
114
  b.setAttribute('aria-selected', 'true');
109
115
  });
110
116
  });
111
- // Version switcher + account menu (open/close)
112
- const toggleOpen = (el, btn) => (e) => { e.stopPropagation(); const o = el.classList.toggle('open'); btn?.setAttribute('aria-expanded', o ? 'true' : 'false'); };
113
- root.querySelectorAll('[data-vsw]').forEach((vsw) => { const b = vsw.querySelector('.vsw__btn'); b?.addEventListener('click', toggleOpen(vsw, b)); });
114
- root.querySelectorAll('[data-acct]').forEach((ac) => { const b = ac.querySelector('[data-acct-btn]'); b?.addEventListener('click', toggleOpen(ac, b)); });
115
- document.addEventListener('click', () => {
116
- root.querySelectorAll('[data-vsw].open,[data-acct].open').forEach((el) => el.classList.remove('open'));
117
- });
117
+ // Version switcher + account menu — the shared dropdown wiring (open/close +
118
+ // click-outside + Esc + keyboard nav). One implementation for the whole kit.
119
+ wireDropdown(root);
118
120
  // Accent pickers
119
121
  root.querySelectorAll('[data-accent-pick]').forEach((chip) => {
120
122
  chip.addEventListener('click', () => {
package/src/index.css CHANGED
@@ -3,17 +3,26 @@
3
3
  * Import once (`import 'apliteni-ui/css'`) or cherry-pick from src/styles/*.
4
4
  * Requires the Poppins font (loaded by the host page or Storybook preview).
5
5
  * ========================================================================== */
6
+ @import "./tokens/brand.generated.css";
6
7
  @import "./tokens/tokens.css";
7
8
  @import "./tokens/accents.css";
8
9
  @import "./styles/base.css";
10
+ @import "./styles/motion.css";
11
+ @import "./styles/aurora.css";
9
12
  @import "./styles/button.css";
10
13
  @import "./styles/card.css";
11
14
  @import "./styles/badge.css";
12
15
  @import "./styles/segmented.css";
13
16
  @import "./styles/input.css";
17
+ @import "./styles/dropdown.css";
18
+ @import "./styles/nav.css";
19
+ @import "./styles/drawer.css";
14
20
  @import "./styles/table.css";
21
+ @import "./styles/empty.css";
15
22
  @import "./styles/callout.css";
16
23
  @import "./styles/code.css";
17
24
  @import "./styles/topbar.css";
25
+ @import "./styles/footer.css";
18
26
  @import "./styles/layout.css";
19
27
  @import "./styles/feedback.css";
28
+ @import "./styles/success.css";
package/src/index.js CHANGED
@@ -1,8 +1,13 @@
1
1
  // apliteni-ui — public entry.
2
2
  // Styles ship separately: `import 'apliteni-ui/css'`.
3
3
  export * from './components/index.js';
4
+ export * from './components/dropdown.js';
5
+ export * from './components/nav.js';
6
+ export * from './components/drawer.js';
4
7
  export * from './components/topbar.js';
5
8
  export * from './components/shell.js';
6
9
  export * from './components/feedback.js';
10
+ export * from './components/toasts.js';
7
11
  export * from './assets/icons.js';
8
12
  export * from './assets/brand.js';
13
+ export * from './motion.js';
package/src/inline.js CHANGED
@@ -11,9 +11,13 @@ import { readFileSync } from 'node:fs';
11
11
 
12
12
  const read = (rel) => readFileSync(new URL(`./${rel}`, import.meta.url), 'utf8');
13
13
 
14
- // Tokens = base scale + dark/light + accent sub-themes. The single source for
15
- // every `:root{ --… }` definition. Replaces hand-inlined token blocks.
16
- export const tokensCss = read('tokens/tokens.css') + '\n' + read('tokens/accents.css');
14
+ // Tokens = brand primitives (synced from design-system) + base scale +
15
+ // dark/light + accent sub-themes. The single source for every `:root{ --… }`
16
+ // definition. brand.generated.css goes first so the cascade is right.
17
+ export const tokensCss =
18
+ read('tokens/brand.generated.css') + '\n' +
19
+ read('tokens/tokens.css') + '\n' +
20
+ read('tokens/accents.css');
17
21
 
18
22
  // Base reset, ambient glow, focus ring, default icon sizing.
19
23
  export const baseCss = read('styles/base.css');
@@ -24,32 +28,46 @@ export const topbarCss = read('styles/topbar.css');
24
28
  // Individual component stylesheets, addressable by name.
25
29
  export const styles = {
26
30
  base: baseCss,
31
+ motion: read('styles/motion.css'),
32
+ aurora: read('styles/aurora.css'),
27
33
  button: read('styles/button.css'),
28
34
  card: read('styles/card.css'),
29
35
  badge: read('styles/badge.css'),
30
36
  segmented: read('styles/segmented.css'),
31
37
  input: read('styles/input.css'),
38
+ dropdown: read('styles/dropdown.css'),
39
+ nav: read('styles/nav.css'),
40
+ drawer: read('styles/drawer.css'),
32
41
  table: read('styles/table.css'),
33
42
  callout: read('styles/callout.css'),
34
43
  code: read('styles/code.css'),
35
44
  topbar: topbarCss,
45
+ footer: read('styles/footer.css'),
36
46
  layout: read('styles/layout.css'),
37
47
  feedback: read('styles/feedback.css'),
48
+ success: read('styles/success.css'),
38
49
  };
39
50
 
40
51
  // Everything, in the same order as index.css. `tokensCss` first so cascade is right.
41
52
  export const cssText = [
42
53
  tokensCss,
43
54
  styles.base,
55
+ styles.motion,
56
+ styles.aurora,
44
57
  styles.button,
45
58
  styles.card,
46
59
  styles.badge,
47
60
  styles.segmented,
48
61
  styles.input,
62
+ styles.dropdown,
63
+ styles.nav,
64
+ styles.drawer,
49
65
  styles.table,
50
66
  styles.callout,
51
67
  styles.code,
52
68
  styles.topbar,
69
+ styles.footer,
53
70
  styles.layout,
54
71
  styles.feedback,
72
+ styles.success,
55
73
  ].join('\n');