@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,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,6 +54,28 @@ export function card({ title, sub, body = '', variant, pad, icon: ic } = {}) {
|
|
|
48
54
|
return `<div class="${cls}">${head}${body}</div>`;
|
|
49
55
|
}
|
|
50
56
|
|
|
57
|
+
// ---- Aurora — ambient warm backdrop --------------------------------------
|
|
58
|
+
// Layered blurred glow "blobs" + optional paper grain. Colours default to the
|
|
59
|
+
// accent glow tokens, so the field re-themes with the sub-theme. Drop it as the
|
|
60
|
+
// first child of a positioned wrapper (or use fixed: true for full-bleed) and
|
|
61
|
+
// give the real content a normal stacking context — it sits on top.
|
|
62
|
+
const AURORA_PRESET = [
|
|
63
|
+
{ tone: 'accent', x: '22%', y: '12%', size: '64%', delay: '0s' },
|
|
64
|
+
{ tone: 'teal', x: '84%', y: '24%', size: '56%', delay: '-9s' },
|
|
65
|
+
{ tone: 'warm', x: '54%', y: '92%', size: '70%', delay: '-17s' },
|
|
66
|
+
];
|
|
67
|
+
export function aurora({ blobs = AURORA_PRESET, grain = false, fixed = false, animated = true, className = '' } = {}) {
|
|
68
|
+
const items = blobs.map((b) => {
|
|
69
|
+
const tone = b.tone || 'accent';
|
|
70
|
+
const style = `--au-x:${b.x ?? '50%'};--au-y:${b.y ?? '50%'};--au-size:${b.size ?? '60%'}`
|
|
71
|
+
+ (b.delay != null ? `;--au-delay:${b.delay}` : '');
|
|
72
|
+
return `<span class="ui-aurora__blob ui-aurora__blob--${tone}" style="${style}"></span>`;
|
|
73
|
+
}).join('');
|
|
74
|
+
const grainEl = grain ? '<span class="ui-aurora__grain"></span>' : '';
|
|
75
|
+
const cls = cx('ui-aurora', animated && 'ui-aurora--animated', fixed && 'ui-aurora--fixed', className);
|
|
76
|
+
return `<div class="${cls}" aria-hidden="true">${items}${grainEl}</div>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
51
79
|
// ---- Segmented control ---------------------------------------------------
|
|
52
80
|
export function segmented({ options = [], active = 0, size, block, name = 'seg' } = {}) {
|
|
53
81
|
const cls = cx('ui-seg', size && `ui-seg--${size}`, block && 'ui-seg--block');
|
|
@@ -73,16 +101,57 @@ export function accentPicker({ active = 'default', options = ['default', 'phoeni
|
|
|
73
101
|
}
|
|
74
102
|
|
|
75
103
|
// ---- Field / input -------------------------------------------------------
|
|
76
|
-
|
|
77
|
-
|
|
104
|
+
// Auto-generated ids let field() tie its <label for> to the control it wraps.
|
|
105
|
+
// A module counter (never Date/random) keeps ids unique within a rendered page.
|
|
106
|
+
let _uid = 0;
|
|
107
|
+
const nextId = (p = 'ui') => `${p}-${++_uid}`;
|
|
108
|
+
// Give the first form control in `html` an id (or reuse one it already carries)
|
|
109
|
+
// so a <label> can point at it. Returns { html, id }; id is null if there's no
|
|
110
|
+
// form element to name.
|
|
111
|
+
function withControlId(html) {
|
|
112
|
+
const existing = html.match(/<(?:input|textarea|select)\b[^>]*\bid="([^"]+)"/);
|
|
113
|
+
if (existing) return { html, id: existing[1] };
|
|
114
|
+
let id = null;
|
|
115
|
+
const out = html.replace(/<(input|textarea|select)\b/, (m) => { id = nextId('field'); return `${m} id="${id}"`; });
|
|
116
|
+
return { html: out, id };
|
|
78
117
|
}
|
|
79
|
-
|
|
80
|
-
|
|
118
|
+
|
|
119
|
+
export function field({ label, hint, error, control = '', id } = {}) {
|
|
120
|
+
let ctl = control;
|
|
121
|
+
let forId = id;
|
|
122
|
+
if (label && control) {
|
|
123
|
+
const r = withControlId(control);
|
|
124
|
+
ctl = r.html;
|
|
125
|
+
forId = id || r.id;
|
|
126
|
+
}
|
|
127
|
+
const lab = label
|
|
128
|
+
? `<label class="ui-field__label"${forId ? ` for="${forId}"` : ''}>${esc(label)}</label>`
|
|
129
|
+
: '';
|
|
130
|
+
const foot = error
|
|
131
|
+
? `<div class="ui-field__error">${icon('alert')}${esc(error)}</div>`
|
|
132
|
+
: hint ? `<div class="ui-field__hint">${esc(hint)}</div>` : '';
|
|
133
|
+
return `<div class="ui-field">${lab}${ctl}${foot}</div>`;
|
|
134
|
+
}
|
|
135
|
+
// `ariaLabel` gives a standalone control (no wrapping field) an accessible name.
|
|
136
|
+
export function input({ type = 'text', placeholder = '', value = '', icon: ic, invalid, disabled, name, id, ariaLabel } = {}) {
|
|
137
|
+
const attrs = `${id ? ` id="${esc(id)}"` : ''}${name ? ` name="${name}"` : ''}${ariaLabel ? ` aria-label="${esc(ariaLabel)}"` : ''}${disabled ? ' disabled' : ''}`;
|
|
138
|
+
const el = `<input class="${cx('ui-input', invalid && 'is-invalid')}" type="${type}" placeholder="${esc(placeholder)}" value="${esc(value)}"${attrs}>`;
|
|
81
139
|
if (!ic) return el;
|
|
82
140
|
return `<div class="ui-input-group"><span class="ui-input-group__icon">${icon(ic)}</span>${el}</div>`;
|
|
83
141
|
}
|
|
84
|
-
export function textarea({ placeholder = '', value = '', rows = 4 } = {}) {
|
|
85
|
-
return `<textarea class="ui-textarea" rows="${rows}" placeholder="${esc(placeholder)}">${esc(value)}</textarea>`;
|
|
142
|
+
export function textarea({ placeholder = '', value = '', rows = 4, name, id, ariaLabel } = {}) {
|
|
143
|
+
return `<textarea class="ui-textarea" rows="${rows}" placeholder="${esc(placeholder)}"${id ? ` id="${esc(id)}"` : ''}${name ? ` name="${name}"` : ''}${ariaLabel ? ` aria-label="${esc(ariaLabel)}"` : ''}>${esc(value)}</textarea>`;
|
|
144
|
+
}
|
|
145
|
+
// Native <select>. Pass a `label` via field() or an `ariaLabel` for a bare one —
|
|
146
|
+
// a select with neither has no accessible name.
|
|
147
|
+
export function select({ options = [], value, name, id, ariaLabel, disabled } = {}) {
|
|
148
|
+
const opts = options.map((o) => {
|
|
149
|
+
const label = typeof o === 'string' ? o : o.label;
|
|
150
|
+
const val = typeof o === 'string' ? o : (o.value ?? o.label);
|
|
151
|
+
const sel = value != null && String(val) === String(value);
|
|
152
|
+
return `<option value="${esc(val)}"${sel ? ' selected' : ''}>${esc(label)}</option>`;
|
|
153
|
+
}).join('');
|
|
154
|
+
return `<select class="ui-select"${id ? ` id="${esc(id)}"` : ''}${name ? ` name="${name}"` : ''}${ariaLabel ? ` aria-label="${esc(ariaLabel)}"` : ''}${disabled ? ' disabled' : ''}>${opts}</select>`;
|
|
86
155
|
}
|
|
87
156
|
export function checkbox({ label, checked, type = 'checkbox', name } = {}) {
|
|
88
157
|
return `<label class="ui-check"><input type="${type}"${name ? ` name="${name}"` : ''}${checked ? ' checked' : ''}><span>${label}</span></label>`;
|
|
@@ -97,13 +166,48 @@ export function switchToggle({ checked = false, disabled = false, name, label =
|
|
|
97
166
|
export function callout({ variant, icon: ic = 'info', body } = {}) {
|
|
98
167
|
return `<div class="${cx('ui-callout', variant && `ui-callout--${variant}`)}"><span class="ui-callout__icon">${icon(ic)}</span><div>${body}</div></div>`;
|
|
99
168
|
}
|
|
100
|
-
|
|
101
|
-
|
|
169
|
+
// Default icon per status — overridable with `icon`.
|
|
170
|
+
const TOAST_ICON = { success: 'check', danger: 'x', warn: 'alert', info: 'info', neutral: 'bolt' };
|
|
171
|
+
// A toast carries a status (colour) and a style (surface). Everything visual is
|
|
172
|
+
// token-driven: the status modifier sets --toast-accent/-glow/-on, the style
|
|
173
|
+
// modifier consumes them. `action` adds a trailing button ("Undo"/"Retry"),
|
|
174
|
+
// `timer` adds an auto-dismiss progress bar (true → default 5s, or a number of
|
|
175
|
+
// seconds), `compact` drops the body for a single-line toast.
|
|
176
|
+
export function toast({
|
|
177
|
+
variant = 'success', style = 'soft', title, body, icon: ic,
|
|
178
|
+
action, timer = false, compact = false, dismissible = true,
|
|
179
|
+
} = {}) {
|
|
180
|
+
const cls = cx('ui-toast', `ui-toast--${variant}`, `ui-toast--${style}`, compact && 'ui-toast--compact');
|
|
181
|
+
const actLabel = typeof action === 'string' ? action : action && action.label;
|
|
182
|
+
const actBtn = actLabel ? `<button class="ui-toast__action" type="button">${esc(actLabel)}</button>` : '';
|
|
183
|
+
const closeBtn = dismissible ? `<button class="ui-toast__close" aria-label="Dismiss">${icon('x')}</button>` : '';
|
|
184
|
+
const bodyHtml = compact || !body ? '' : `<div class="ui-toast__text">${esc(body)}</div>`;
|
|
185
|
+
const dur = typeof timer === 'number' ? ` style="--toast-dur:${timer}s"` : '';
|
|
186
|
+
const timerBar = timer ? '<span class="ui-toast__timer" data-toast-timer></span>' : '';
|
|
187
|
+
return `<div class="${cls}" role="status" aria-live="polite"${dur}>`
|
|
188
|
+
+ `<span class="ui-toast__icon">${icon(ic || TOAST_ICON[variant] || 'info')}</span>`
|
|
189
|
+
+ `<div class="ui-toast__body">${title ? `<div class="ui-toast__title">${esc(title)}</div>` : ''}${bodyHtml}</div>`
|
|
190
|
+
+ `${actBtn}${closeBtn}${timerBar}</div>`;
|
|
102
191
|
}
|
|
103
192
|
export function successPanel({ title = 'Done', sub = '' } = {}) {
|
|
104
193
|
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>`;
|
|
105
194
|
}
|
|
106
195
|
|
|
196
|
+
// ---- Empty state ---------------------------------------------------------
|
|
197
|
+
// The placeholder for an empty list/table/page. Pass `art` as an illustration
|
|
198
|
+
// name (see illustrations.js) or raw <svg> markup; a legacy line `icon` name is
|
|
199
|
+
// also accepted. `actions` is trusted button markup (e.g. button({…})).
|
|
200
|
+
export function emptyState({ art, icon: ic, title, sub, actions } = {}) {
|
|
201
|
+
const artHtml = art
|
|
202
|
+
? `<div class="ui-empty__art">${/<svg/.test(art) ? art : illo(art)}</div>`
|
|
203
|
+
: ic ? `<div class="ui-empty__icon">${icon(ic)}</div>` : '';
|
|
204
|
+
return `<div class="ui-empty">${artHtml}`
|
|
205
|
+
+ `${title ? `<div class="ui-empty__title">${esc(title)}</div>` : ''}`
|
|
206
|
+
+ `${sub ? `<div class="ui-empty__sub">${esc(sub)}</div>` : ''}`
|
|
207
|
+
+ `${actions ? `<div class="ui-empty__actions">${actions}</div>` : ''}`
|
|
208
|
+
+ `</div>`;
|
|
209
|
+
}
|
|
210
|
+
|
|
107
211
|
// ---- Snippet -------------------------------------------------------------
|
|
108
212
|
export function snippet({ label = 'shell', code = '', reveal = false, copy = true } = {}) {
|
|
109
213
|
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>`;
|
|
@@ -124,3 +228,4 @@ export const hlShell = (raw) =>
|
|
|
124
228
|
);
|
|
125
229
|
|
|
126
230
|
export { icon };
|
|
231
|
+
export { illo, illoNames } from '../assets/illustrations.js';
|