@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,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
+ }
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 inner = `${ic ? icon(ic) : ''}${iconOnly ? '' : `<span>${esc(label)}</span>`}${iconRight ? icon(iconRight) : ''}${bars}`;
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
- export function field({ label, hint, error, control } = {}) {
77
- return `<div class="ui-field">${label ? `<label class="ui-field__label">${esc(label)}</label>` : ''}${control || ''}${error ? `<div class="ui-field__error">${icon('alert')}${esc(error)}</div>` : hint ? `<div class="ui-field__hint">${esc(hint)}</div>` : ''}</div>`;
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
- export function input({ type = 'text', placeholder = '', value = '', icon: ic, invalid, disabled, name } = {}) {
80
- const el = `<input class="${cx('ui-input', invalid && 'is-invalid')}" type="${type}" placeholder="${esc(placeholder)}" value="${esc(value)}"${name ? ` name="${name}"` : ''}${disabled ? ' disabled' : ''}>`;
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
- export function toast({ variant = 'success', title, body, icon: ic = 'check' } = {}) {
101
- return `<div class="${cx('ui-toast', `ui-toast--${variant}`)}"><span class="ui-toast__icon">${icon(ic)}</span><div class="ui-toast__body">${title ? `<div class="ui-toast__title">${esc(title)}</div>` : ''}${body ? `<div>${esc(body)}</div>` : ''}</div><button class="ui-toast__close" aria-label="Dismiss">${icon('x')}</button></div>`;
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';