@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.
- package/package.json +10 -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 +115 -10
- 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 +17 -15
- package/src/index.css +9 -0
- package/src/index.js +5 -0
- package/src/inline.js +21 -3
- package/src/motion.js +68 -0
- package/src/styles/aurora.css +93 -0
- 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/styles/topbar.css +9 -3
- package/src/tokens/brand.generated.css +121 -0
- package/src/tokens/tokens.css +18 -5
|
@@ -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
|
+
}
|
package/src/components/topbar.js
CHANGED
|
@@ -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-
|
|
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 & 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
|
-
|
|
38
|
-
|
|
39
|
-
`<
|
|
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
|
-
|
|
113
|
-
root
|
|
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 =
|
|
15
|
-
// every `:root{ --… }`
|
|
16
|
-
|
|
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');
|