@apliteni/apliteni-ui 0.26.0 → 0.30.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/README.md +2 -2
- package/package.json +1 -1
- package/react/README.md +111 -2
- package/react/dist/index.css +11 -13
- package/react/dist/index.d.ts +111 -4
- package/react/dist/index.js +811 -167
- package/src/components/back.js +57 -0
- package/src/components/command-palette.js +597 -0
- package/src/components/confirm.js +3 -2
- package/src/components/drawer.js +31 -2
- package/src/components/dropdown.js +192 -17
- package/src/components/feedback.js +2 -2
- package/src/components/index.js +5 -2
- package/src/components/loading.js +3 -2
- package/src/components/nav.js +15 -7
- package/src/components/overlay.js +67 -14
- package/src/components/pagination.js +344 -0
- package/src/components/shell.js +11 -2
- package/src/components/tabs.js +7 -1
- package/src/components/tooltip.js +237 -0
- package/src/components/topbar.js +9 -4
- package/src/index.css +4 -0
- package/src/index.js +4 -0
- package/src/inline.js +8 -0
- package/src/motion.js +43 -2
- package/src/styles/back.css +42 -0
- package/src/styles/badge.css +5 -7
- package/src/styles/base.css +8 -7
- package/src/styles/button.css +14 -0
- package/src/styles/card.css +12 -8
- package/src/styles/code.css +3 -4
- package/src/styles/command-palette.css +321 -0
- package/src/styles/confirm.css +11 -11
- package/src/styles/drawer.css +57 -15
- package/src/styles/dropdown.css +70 -11
- package/src/styles/feedback.css +2 -0
- package/src/styles/footer.css +3 -4
- package/src/styles/input.css +7 -1
- package/src/styles/layout.css +3 -2
- package/src/styles/loading.css +5 -0
- package/src/styles/nav.css +12 -8
- package/src/styles/pagination.css +124 -0
- package/src/styles/success.css +3 -2
- package/src/styles/table.css +4 -5
- package/src/styles/tabs.css +3 -0
- package/src/styles/tooltip.css +65 -0
- package/src/styles/topbar.css +3 -4
- package/src/tokens/tokens.css +14 -8
package/src/components/drawer.js
CHANGED
|
@@ -68,6 +68,33 @@ export function drawer({
|
|
|
68
68
|
+ `</aside></div>`;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* One group inside a drawer's body: an optional heading over label/value rows,
|
|
73
|
+
* then any trailing markup (an action that belongs to the group). Rows render as
|
|
74
|
+
* a <dl>, so a screen reader hears each label with its value.
|
|
75
|
+
*
|
|
76
|
+
* drawer({ title, body: drawerSection({ title: 'Source', rows: [['Statement', '#4102']] }) })
|
|
77
|
+
*
|
|
78
|
+
* why: docs/specification.md#the-drawer
|
|
79
|
+
*
|
|
80
|
+
* @param {object} [o]
|
|
81
|
+
* @param {string} [o.title] group heading (escaped)
|
|
82
|
+
* @param {Array<[string, string | { html: string }]>} [o.rows] [label, value] pairs. Both are
|
|
83
|
+
* escaped, since a value is usually data (a bank feed's description); pass the value as
|
|
84
|
+
* `{ html: '…' }` to write trusted markup as it is
|
|
85
|
+
* @param {string} [o.body] trailing markup after the rows (trusted)
|
|
86
|
+
* @returns {string} html
|
|
87
|
+
*/
|
|
88
|
+
export function drawerSection({ title, rows = [], body = '' } = {}) {
|
|
89
|
+
const head = title ? `<h3 class="ui-drawer__section-title">${esc(title)}</h3>` : '';
|
|
90
|
+
const value = (v) => (v !== null && typeof v === 'object' && 'html' in v ? String(v.html) : esc(v));
|
|
91
|
+
const list = rows.length
|
|
92
|
+
? `<dl class="ui-drawer__rows">${rows.map(([term, detail]) =>
|
|
93
|
+
`<div class="ui-drawer__row"><dt>${esc(term)}</dt><dd>${value(detail)}</dd></div>`).join('')}</dl>`
|
|
94
|
+
: '';
|
|
95
|
+
return `<section class="ui-drawer__section">${head}${list}${body}</section>`;
|
|
96
|
+
}
|
|
97
|
+
|
|
71
98
|
// ---- Shared behaviour ----------------------------------------------------
|
|
72
99
|
// One open/close/scrim/close-button implementation for every drawer in the kit;
|
|
73
100
|
// inertness, Escape and Tab belong to the overlay stack. Per-instance handlers
|
|
@@ -92,11 +119,13 @@ export function openDrawer(root, returnFocusTo) {
|
|
|
92
119
|
export function closeDrawer(root) {
|
|
93
120
|
if (!root || !root.classList.contains('is-open')) return;
|
|
94
121
|
root.classList.remove('is-open');
|
|
95
|
-
popOverlay(root);
|
|
96
122
|
const back = root.__drawerReturn;
|
|
97
123
|
root.__drawerReturn = null;
|
|
98
124
|
// The trigger can be gone by now — focus() on a detached node does nothing at
|
|
99
|
-
// all, which leaves the reader with no place on the page.
|
|
125
|
+
// all, which leaves the reader with no place on the page. popOverlay is told
|
|
126
|
+
// where focus is headed so it can catch the other way that happens: a trigger
|
|
127
|
+
// still on the page, inside what an overlay left open underneath has re-hidden.
|
|
128
|
+
popOverlay(root, back);
|
|
100
129
|
returnFocus(back, root.ownerDocument);
|
|
101
130
|
}
|
|
102
131
|
|
|
@@ -16,7 +16,8 @@ import { esc, icon } from './index.js';
|
|
|
16
16
|
|
|
17
17
|
const cx = (...a) => a.filter(Boolean).join(' ');
|
|
18
18
|
|
|
19
|
-
// A trailing status badge. `badge` is
|
|
19
|
+
// A trailing status badge. `badge` is the text shown, in the case it is written
|
|
20
|
+
// ("Live"), or { text, tone }; a string reading "live" in any case takes the live tone.
|
|
20
21
|
function ddBadge(badge) {
|
|
21
22
|
if (!badge) return '';
|
|
22
23
|
const text = typeof badge === 'string' ? badge : badge.text;
|
|
@@ -26,8 +27,8 @@ function ddBadge(badge) {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
// One item row. `listbox` picks role=option (selectable) vs role=menuitem (action).
|
|
29
|
-
function ddItem(it, listbox) {
|
|
30
|
-
if (it === '---' || it.separator) return
|
|
30
|
+
function ddItem(it, listbox, ext) {
|
|
31
|
+
if (it === '---' || it.separator) return `<div class="ui-dropdown__sep" role="separator"${ext?.filtering ? ' hidden' : ''}></div>`;
|
|
31
32
|
const disabled = !!it.disabled;
|
|
32
33
|
const selected = !!it.selected;
|
|
33
34
|
const role = listbox ? 'option' : 'menuitem';
|
|
@@ -48,19 +49,89 @@ function ddItem(it, listbox) {
|
|
|
48
49
|
disabled ? 'aria-disabled="true"' : '',
|
|
49
50
|
asLink ? `href="${esc(it.href)}"` : '',
|
|
50
51
|
asLink && it.target ? `target="${esc(it.target)}"` : '',
|
|
52
|
+
ext?.id ? `id="${esc(ext.id)}"` : '',
|
|
53
|
+
ext?.hidden ? 'hidden' : '',
|
|
51
54
|
].filter(Boolean).join(' ');
|
|
52
55
|
return `<${tag} ${attrs}>${lead}${main}${badge}${tick}</${tag}>`;
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
// Render a flat item list or grouped sections ([{ label, items }]).
|
|
56
|
-
|
|
58
|
+
// Render a flat item list or grouped sections ([{ label, items }]). `sx` is the
|
|
59
|
+
// search variant's context; the plain dropdown passes none and its markup is
|
|
60
|
+
// unchanged.
|
|
61
|
+
function ddBody({ items, sections }, listbox, sx) {
|
|
62
|
+
const one = (it) => ddItem(it, listbox, sx && ddRowExt(it, sx));
|
|
57
63
|
if (sections && sections.length) {
|
|
58
64
|
return sections.map((s) => {
|
|
59
65
|
const head = s.label ? `<div class="ui-dropdown__group" role="presentation">${esc(s.label)}</div>` : '';
|
|
60
|
-
|
|
66
|
+
const gone = sx && ddFiltering(sx.q) && !(s.items || []).some((it) => ddIsRow(it) && ddMatch(it.label, sx.q));
|
|
67
|
+
return `<div class="ui-dropdown__section" role="group"${s.label ? ` aria-label="${esc(s.label)}"` : ''}${gone ? ' hidden' : ''}>${head}${(s.items || []).map(one).join('')}</div>`;
|
|
61
68
|
}).join('');
|
|
62
69
|
}
|
|
63
|
-
return (items || []).map(
|
|
70
|
+
return (items || []).map(one).join('');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---- Search --------------------------------------------------------------
|
|
74
|
+
// The match is a substring of the label, anywhere in it, ignoring case and
|
|
75
|
+
// accents; rows keep their order. The factory and the wiring both ask
|
|
76
|
+
// ddMatch(), so a preset query and a typed one hide the same rows.
|
|
77
|
+
// why: docs/specification.md#a-dropdown-with-a-search-field
|
|
78
|
+
// NFD takes the mark off é or ö; ł, ø, đ and the rest are letters of their own
|
|
79
|
+
// with nothing to take off, so they are mapped by hand.
|
|
80
|
+
const FOLD = { ł: 'l', ø: 'o', đ: 'd', ð: 'd', ß: 'ss', æ: 'ae', œ: 'oe', ı: 'i', þ: 'th' };
|
|
81
|
+
const fold = (s) => String(s == null ? '' : s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
|
|
82
|
+
.replace(/[łøđðßæœıþ]/g, (c) => FOLD[c]);
|
|
83
|
+
const ddFiltering = (q) => fold(q).trim() !== '';
|
|
84
|
+
const ddMatch = (label, q) => fold(label).includes(fold(q).trim());
|
|
85
|
+
const ddIsRow = (it) => it && it !== '---' && !it.separator;
|
|
86
|
+
|
|
87
|
+
let _ddSeq = 0;
|
|
88
|
+
|
|
89
|
+
function ddSearchContext(search, id) {
|
|
90
|
+
const o = search === true ? {} : search;
|
|
91
|
+
return {
|
|
92
|
+
base: id || `ui-dd-${++_ddSeq}`,
|
|
93
|
+
n: 0,
|
|
94
|
+
q: o.query || '',
|
|
95
|
+
label: o.label,
|
|
96
|
+
placeholder: o.placeholder || 'Search',
|
|
97
|
+
empty: o.empty || 'No match for “{q}”',
|
|
98
|
+
hint: o.hint || 'Check the spelling, or try fewer letters.',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function ddRowExt(it, sx) {
|
|
103
|
+
if (!ddIsRow(it)) return { filtering: ddFiltering(sx.q) };
|
|
104
|
+
return { id: `${sx.base}-opt-${sx.n++}`, hidden: !ddMatch(it.label, sx.q) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The no-match state. A function replacer, so a `$&` typed into the field is
|
|
108
|
+
// text rather than a replacement pattern.
|
|
109
|
+
function ddNone(empty, hint, q) {
|
|
110
|
+
return `<span class="ui-dropdown__none-title">${esc(empty.replace('{q}', () => q.trim()))}</span>`
|
|
111
|
+
+ `<span class="ui-dropdown__none-hint">${esc(hint)}</span>`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The field is a combobox that owns the list; the rows stay options, and the
|
|
115
|
+
// one Enter would pick is named by aria-activedescendant, so focus never
|
|
116
|
+
// leaves the field while the reader types.
|
|
117
|
+
function ddSearchBody({ items, sections }, sx, name, scroll) {
|
|
118
|
+
const listId = `${sx.base}-list`;
|
|
119
|
+
const rows = ddBody({ items, sections }, true, sx);
|
|
120
|
+
const flat = (sections ? sections.flatMap((s) => s.items || []) : (items || [])).filter(ddIsRow);
|
|
121
|
+
const shown = flat.some((it) => ddMatch(it.label, sx.q));
|
|
122
|
+
const cap = scroll && scroll !== true ? ` style="max-height:${typeof scroll === 'number' ? scroll + 'px' : esc(scroll)}"` : '';
|
|
123
|
+
const input = [
|
|
124
|
+
'class="ui-dropdown__search-input"', 'type="text"', 'role="combobox"',
|
|
125
|
+
'aria-autocomplete="list"', 'aria-expanded="true"', `aria-controls="${esc(listId)}"`,
|
|
126
|
+
`aria-label="${esc(sx.label || `Search ${name}`)}"`, `placeholder="${esc(sx.placeholder)}"`,
|
|
127
|
+
'autocomplete="off"', 'spellcheck="false"', 'data-dd-search',
|
|
128
|
+
sx.q ? `value="${esc(sx.q)}"` : '',
|
|
129
|
+
].filter(Boolean).join(' ');
|
|
130
|
+
return `<div class="ui-dropdown__search">`
|
|
131
|
+
+ `<span class="ui-dropdown__search-ic" aria-hidden="true">${icon('search')}</span><input ${input}></div>`
|
|
132
|
+
+ `<div class="ui-dropdown__list" role="listbox" id="${esc(listId)}" aria-label="${esc(name)}"${cap}>${rows}</div>`
|
|
133
|
+
+ `<div class="ui-dropdown__none" role="status" data-dd-none data-dd-empty="${esc(sx.empty)}" data-dd-hint="${esc(sx.hint)}">`
|
|
134
|
+
+ `${shown || !ddFiltering(sx.q) ? '' : ddNone(sx.empty, sx.hint, sx.q)}</div>`;
|
|
64
135
|
}
|
|
65
136
|
|
|
66
137
|
/**
|
|
@@ -81,18 +152,22 @@ function ddBody({ items, sections }, listbox) {
|
|
|
81
152
|
* @param {boolean|number} [o.scroll] true, or a maxHeight in px, to cap and scroll
|
|
82
153
|
* @param {boolean} [o.open] render already-open (handy for screenshots)
|
|
83
154
|
* @param {string} [o.ariaLabel] accessible name for the panel and trigger
|
|
155
|
+
* @param {boolean|object} [o.search] true, or { placeholder, label, empty, hint, query } —
|
|
156
|
+
* a field above the rows that filters them; `empty` may carry {q}
|
|
84
157
|
* @returns {string} html
|
|
85
158
|
*/
|
|
86
159
|
export function dropdown({
|
|
87
160
|
label, value, placeholder = 'Select…', variant, items, sections,
|
|
88
161
|
header = '', footer = '', triggerContent, triggerClass = '', chevron = true,
|
|
89
162
|
align = 'start', direction = 'down', portal = false,
|
|
90
|
-
scroll = false, open = false, ariaLabel, id, panelClass = '',
|
|
163
|
+
scroll = false, open = false, ariaLabel, id, panelClass = '', search = false,
|
|
91
164
|
} = {}) {
|
|
92
165
|
const flat = sections ? sections.flatMap((s) => s.items || []) : (items || []);
|
|
93
166
|
const isSelect = variant === 'select' || (variant == null && flat.some((it) => it && (it.selected || it.value != null)));
|
|
94
167
|
const listRole = isSelect ? 'listbox' : 'menu';
|
|
95
168
|
const cur = value != null ? value : (isSelect ? (flat.find((it) => it && it.selected)?.label) : null);
|
|
169
|
+
const sx = search ? ddSearchContext(search, id) : null;
|
|
170
|
+
const name = ariaLabel || (label ? String(label).replace(/:\s*$/, '') : '') || 'Options';
|
|
96
171
|
|
|
97
172
|
const trig = triggerContent != null
|
|
98
173
|
? triggerContent
|
|
@@ -102,7 +177,7 @@ export function dropdown({
|
|
|
102
177
|
`class="${cx('ui-dropdown__trigger', triggerClass)}"`,
|
|
103
178
|
'type="button"',
|
|
104
179
|
'data-dropdown-trigger',
|
|
105
|
-
`aria-haspopup="${listRole}"`,
|
|
180
|
+
`aria-haspopup="${sx ? 'dialog' : listRole}"`,
|
|
106
181
|
`aria-expanded="${open ? 'true' : 'false'}"`,
|
|
107
182
|
ariaLabel && triggerContent != null ? `aria-label="${esc(ariaLabel)}"` : '',
|
|
108
183
|
].filter(Boolean).join(' ');
|
|
@@ -114,15 +189,17 @@ export function dropdown({
|
|
|
114
189
|
'ui-dropdown__panel',
|
|
115
190
|
align === 'end' && 'is-end',
|
|
116
191
|
direction === 'up' && 'is-up',
|
|
117
|
-
scroll && 'is-scroll',
|
|
192
|
+
scroll && !sx && 'is-scroll',
|
|
193
|
+
sx && 'ui-dropdown__panel--search',
|
|
118
194
|
portal && 'ui-dropdown__panel--portal',
|
|
119
195
|
portal && open && 'is-open',
|
|
120
196
|
panelClass,
|
|
121
197
|
)}"`,
|
|
122
198
|
'data-dropdown-panel',
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
199
|
+
// With search the panel holds a field and a list, which a listbox may not.
|
|
200
|
+
`role="${sx ? 'dialog' : listRole}"`,
|
|
201
|
+
sx ? `aria-label="${esc(name)}"` : (ariaLabel ? `aria-label="${esc(ariaLabel)}"` : ''),
|
|
202
|
+
scroll && scroll !== true && !sx ? `style="max-height:${typeof scroll === 'number' ? scroll + 'px' : esc(scroll)}"` : '',
|
|
126
203
|
].filter(Boolean).join(' ');
|
|
127
204
|
|
|
128
205
|
const ddAttrs = 'data-dropdown'
|
|
@@ -132,7 +209,7 @@ export function dropdown({
|
|
|
132
209
|
|
|
133
210
|
return `<div class="${cx('ui-dropdown', open && 'open')}" ${ddAttrs}${id ? ` id="${esc(id)}"` : ''}>` +
|
|
134
211
|
`<button ${triggerAttrs}>${trig}${chevron ? '<span class="ui-dropdown__chevron" aria-hidden="true"></span>' : ''}</button>` +
|
|
135
|
-
`<div ${panelAttrs}>${header}${ddBody({ items, sections }, isSelect)}${footer}</div>` +
|
|
212
|
+
`<div ${panelAttrs}>${header}${sx ? ddSearchBody({ items, sections }, sx, name, scroll) : ddBody({ items, sections }, isSelect)}${footer}</div>` +
|
|
136
213
|
`</div>`;
|
|
137
214
|
}
|
|
138
215
|
|
|
@@ -165,7 +242,59 @@ function ddItemsOf(dd) {
|
|
|
165
242
|
const panel = ddPanelOf(dd);
|
|
166
243
|
if (!panel) return [];
|
|
167
244
|
return Array.from(panel.querySelectorAll('[data-dd-item]'))
|
|
168
|
-
.filter((el) => el.getAttribute('aria-disabled') !== 'true');
|
|
245
|
+
.filter((el) => el.getAttribute('aria-disabled') !== 'true' && !el.hidden);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---- Search wiring ---------------------------------------------------------
|
|
249
|
+
// why: docs/specification.md#a-dropdown-with-a-search-field
|
|
250
|
+
const ddSearchOf = (dd) => ddPanelOf(dd)?.querySelector('[data-dd-search]') || null;
|
|
251
|
+
const ddActiveOf = (dd) => ddPanelOf(dd)?.querySelector('[data-dd-item].is-active') || null;
|
|
252
|
+
const ddComposing = (e) => e.isComposing || e.keyCode === 229;
|
|
253
|
+
|
|
254
|
+
// Mark the row Enter would pick and keep it inside the list's scroll box,
|
|
255
|
+
// without scrolling the page the way scrollIntoView() would.
|
|
256
|
+
function ddSetActive(dd, row) {
|
|
257
|
+
const search = ddSearchOf(dd);
|
|
258
|
+
if (!search) return;
|
|
259
|
+
ddActiveOf(dd)?.classList.remove('is-active');
|
|
260
|
+
if (!row) { search.removeAttribute('aria-activedescendant'); return; }
|
|
261
|
+
row.classList.add('is-active');
|
|
262
|
+
search.setAttribute('aria-activedescendant', row.id);
|
|
263
|
+
const list = row.closest('.ui-dropdown__list');
|
|
264
|
+
if (!list || !list.clientHeight) return;
|
|
265
|
+
const top = row.offsetTop - list.offsetTop;
|
|
266
|
+
if (top < list.scrollTop) list.scrollTop = top;
|
|
267
|
+
else if (top + row.offsetHeight > list.scrollTop + list.clientHeight) list.scrollTop = top + row.offsetHeight - list.clientHeight;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function ddFilter(dd) {
|
|
271
|
+
const panel = ddPanelOf(dd);
|
|
272
|
+
const search = ddSearchOf(dd);
|
|
273
|
+
if (!panel || !search) return;
|
|
274
|
+
const q = search.value;
|
|
275
|
+
let shown = 0;
|
|
276
|
+
panel.querySelectorAll('[data-dd-item]').forEach((row) => {
|
|
277
|
+
row.hidden = !ddMatch((row.querySelector('.ui-dropdown__label') || row).textContent, q);
|
|
278
|
+
if (!row.hidden) shown += 1;
|
|
279
|
+
});
|
|
280
|
+
panel.querySelectorAll('.ui-dropdown__sep').forEach((el) => { el.hidden = ddFiltering(q); });
|
|
281
|
+
panel.querySelectorAll('.ui-dropdown__section').forEach((el) => { el.hidden = !el.querySelector('[data-dd-item]:not([hidden])'); });
|
|
282
|
+
const none = panel.querySelector('[data-dd-none]');
|
|
283
|
+
if (none) {
|
|
284
|
+
none.innerHTML = shown || !ddFiltering(q) ? ''
|
|
285
|
+
: ddNone(none.getAttribute('data-dd-empty') || '', none.getAttribute('data-dd-hint') || '', q);
|
|
286
|
+
}
|
|
287
|
+
ddSetActive(dd, ddItemsOf(dd)[0] || null);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Every open starts from the whole list. Reset here rather than on close, so
|
|
291
|
+
// a panel fading out after a pick does not flash back to every row.
|
|
292
|
+
function ddResetSearch(dd, panel, search) {
|
|
293
|
+
search.value = '';
|
|
294
|
+
ddFilter(dd);
|
|
295
|
+
panel.style.minWidth = '';
|
|
296
|
+
// Hold the width the whole list needs, so the panel does not narrow as rows go.
|
|
297
|
+
if (panel.offsetWidth) panel.style.minWidth = `${panel.offsetWidth}px`;
|
|
169
298
|
}
|
|
170
299
|
|
|
171
300
|
// `auto` is the only direction the wiring decides; `up` and the default are the
|
|
@@ -225,12 +354,22 @@ function closeAllDropdowns(except) {
|
|
|
225
354
|
function openDropdown(dd, focusIdx) {
|
|
226
355
|
closeAllDropdowns(dd);
|
|
227
356
|
const panel = ddPanelOf(dd);
|
|
357
|
+
const search = ddSearchOf(dd);
|
|
358
|
+
if (panel && search) ddResetSearch(dd, panel, search);
|
|
228
359
|
if (panel) {
|
|
229
360
|
ddResolveDirection(dd, panel);
|
|
230
361
|
if (dd.__ddPanel) { positionPortalPanel(dd, panel); panel.classList.add('is-open'); }
|
|
231
362
|
}
|
|
232
363
|
dd.classList.add('open');
|
|
233
364
|
dd.querySelector('[data-dropdown-trigger]')?.setAttribute('aria-expanded', 'true');
|
|
365
|
+
// With search, focus goes to the field however the panel was opened, and
|
|
366
|
+
// the selected row (or the first) is the one Enter would pick.
|
|
367
|
+
if (search) {
|
|
368
|
+
const items = ddItemsOf(dd);
|
|
369
|
+
ddSetActive(dd, items.find((el) => el.getAttribute('aria-selected') === 'true') || items[0] || null);
|
|
370
|
+
search.focus();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
234
373
|
if (focusIdx != null) {
|
|
235
374
|
const items = ddItemsOf(dd);
|
|
236
375
|
const sel = items.findIndex((el) => el.getAttribute('aria-selected') === 'true');
|
|
@@ -241,7 +380,9 @@ function openDropdown(dd, focusIdx) {
|
|
|
241
380
|
// Single-select: reflect the picked option into aria-selected + the trigger value.
|
|
242
381
|
function selectOption(dd, item) {
|
|
243
382
|
if (!dd.hasAttribute('data-dropdown-select')) return;
|
|
244
|
-
|
|
383
|
+
// Every enabled row, the ones a search query has hidden included.
|
|
384
|
+
ddPanelOf(dd)?.querySelectorAll('[data-dd-item]:not([aria-disabled="true"])')
|
|
385
|
+
.forEach((el) => el.setAttribute('aria-selected', el === item ? 'true' : 'false'));
|
|
245
386
|
ddPanelOf(dd)?.querySelectorAll('[data-dd-item].is-selected').forEach((el) => el.classList.remove('is-selected'));
|
|
246
387
|
item.classList.add('is-selected');
|
|
247
388
|
const valueEl = dd.querySelector('[data-dropdown-trigger] .ui-dropdown__value');
|
|
@@ -292,11 +433,45 @@ export function wireDropdown(root = document) {
|
|
|
292
433
|
closeDropdown(dd);
|
|
293
434
|
trigger.focus();
|
|
294
435
|
});
|
|
436
|
+
const field = panel.querySelector('[data-dd-search]');
|
|
437
|
+
if (field) {
|
|
438
|
+
field.addEventListener('input', () => ddFilter(dd));
|
|
439
|
+
// The pointer moves the pick too, so Enter takes the row under it. A
|
|
440
|
+
// move to the same point is the browser's own after a scroll, not the
|
|
441
|
+
// reader's, and would snatch the pick from the arrows.
|
|
442
|
+
let at = '';
|
|
443
|
+
panel.addEventListener('mousemove', (e) => {
|
|
444
|
+
const here = `${e.clientX},${e.clientY}`;
|
|
445
|
+
if (here === at) return;
|
|
446
|
+
at = here;
|
|
447
|
+
const row = e.target.closest('[data-dd-item]');
|
|
448
|
+
if (row && row.getAttribute('aria-disabled') !== 'true' && !row.classList.contains('is-active')) ddSetActive(dd, row);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
295
451
|
}
|
|
296
452
|
|
|
297
453
|
const onKeydown = (e) => {
|
|
298
454
|
const open = dd.classList.contains('open');
|
|
299
455
|
const onTrigger = e.target === trigger;
|
|
456
|
+
const search = ddSearchOf(dd);
|
|
457
|
+
// While an IME is composing, its keys commit or steer the text, not the
|
|
458
|
+
// list. Safari's committing Enter carries keyCode 229, not isComposing.
|
|
459
|
+
if (e.target === search && ddComposing(e)) return;
|
|
460
|
+
if (search && open) {
|
|
461
|
+
// Arrows walk the rows still showing; focus stays in the field.
|
|
462
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
463
|
+
e.preventDefault();
|
|
464
|
+
const items = ddItemsOf(dd);
|
|
465
|
+
if (!items.length) return;
|
|
466
|
+
const i = items.indexOf(ddActiveOf(dd));
|
|
467
|
+
const next = e.key === 'ArrowDown' ? (i + 1) % items.length : (i <= 0 ? items.length - 1 : i - 1);
|
|
468
|
+
ddSetActive(dd, items[next]);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (e.target === search && e.key === 'Enter') { e.preventDefault(); ddActiveOf(dd)?.click(); return; }
|
|
472
|
+
// Home and End move the caret in a text field; they are not the list's.
|
|
473
|
+
if (e.target === search && (e.key === 'Home' || e.key === 'End')) return;
|
|
474
|
+
}
|
|
300
475
|
if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && (onTrigger || open)) {
|
|
301
476
|
e.preventDefault();
|
|
302
477
|
if (!open) return openDropdown(dd, e.key === 'ArrowDown' ? 0 : 'selected');
|
|
@@ -337,7 +512,7 @@ export function wireDropdown(root = document) {
|
|
|
337
512
|
_ddGlobalWired = true;
|
|
338
513
|
document.addEventListener('click', () => closeAllDropdowns());
|
|
339
514
|
document.addEventListener('keydown', (e) => {
|
|
340
|
-
if (e.key !== 'Escape') return;
|
|
515
|
+
if (e.key !== 'Escape' || ddComposing(e)) return;
|
|
341
516
|
const open = document.querySelector('[data-dropdown].open');
|
|
342
517
|
if (open) { closeDropdown(open); open.querySelector('[data-dropdown-trigger]')?.focus(); }
|
|
343
518
|
});
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// (onSend does the POST / issue / whatever) and any deep-link shaping. Styles
|
|
10
10
|
// ship in styles/feedback.css (part of the kit stylesheet). Accent-aware.
|
|
11
11
|
import { esc } from './index.js';
|
|
12
|
+
import { playEntrance } from '../motion.js';
|
|
12
13
|
|
|
13
14
|
// Decorative, like every glyph in the kit — the pill and the buttons carry their
|
|
14
15
|
// own text or aria-label, so the SVGs stay out of the accessibility tree.
|
|
@@ -16,7 +17,6 @@ const IC_MSG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strok
|
|
|
16
17
|
const IC_LINES = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false"><path d="M4 5h16M4 12h10M4 19h7"/></svg>';
|
|
17
18
|
const IC_X = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M6 6l12 12M18 6L6 18"/></svg>';
|
|
18
19
|
const CHECK = '<svg class="ui-fbck" viewBox="0 0 150 150" aria-hidden="true"><path class="ui-fbck-t" d="M40 78l24 24 46-50"/><path class="ui-fbck-m" d="M40 78l24 24 46-50"/><path class="ui-fbck-s" d="M40 78l24 24 46-50"/></svg>';
|
|
19
|
-
|
|
20
20
|
// The widget markup — append once to the page (e.g. document.body). Copy is
|
|
21
21
|
// static here; behaviour + the dynamic chip/quote come from wireFeedback().
|
|
22
22
|
export function feedbackWidget({
|
|
@@ -164,7 +164,7 @@ export function wireFeedback(opts = {}) {
|
|
|
164
164
|
|
|
165
165
|
function fail(msg) {
|
|
166
166
|
errEl.textContent = msg || 'Could not send just now — try again in a moment.';
|
|
167
|
-
errEl.classList.add('show'); sendLb.textContent = 'Send feedback'; sendBtn.disabled = false;
|
|
167
|
+
errEl.classList.add('show'); playEntrance(errEl); sendLb.textContent = 'Send feedback'; sendBtn.disabled = false;
|
|
168
168
|
}
|
|
169
169
|
sendBtn.addEventListener('click', async () => {
|
|
170
170
|
if (sendBtn.disabled || !pending) return;
|
package/src/components/index.js
CHANGED
|
@@ -56,11 +56,14 @@ export function statusDot(live = false) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
// ---- Card ----------------------------------------------------------------
|
|
59
|
-
|
|
59
|
+
// The title is a heading one level under the page's h1 unless `level` says
|
|
60
|
+
// otherwise. why: docs/specification.md#labels-and-titles
|
|
61
|
+
export function card({ title, sub, body = '', variant, pad, icon: ic, level = 2 } = {}) {
|
|
60
62
|
const cls = cx('ui-card', variant && `ui-card--${variant}`, pad && `ui-card--pad-${pad}`);
|
|
63
|
+
const h = [2, 3, 4, 5, 6].includes(Number(level)) ? `h${Number(level)}` : 'h2';
|
|
61
64
|
// title/sub are trusted markup (may carry a badge/icon) — not escaped.
|
|
62
65
|
const head = title
|
|
63
|
-
?
|
|
66
|
+
? `<${h} class="ui-card__title">${ic ? `<span class="ui-card__icon">${icon(ic)}</span>` : ''}${title}</${h}>${sub ? `<div class="ui-card__sub">${sub}</div>` : ''}`
|
|
64
67
|
: '';
|
|
65
68
|
return `<div class="${cls}">${head}${body}</div>`;
|
|
66
69
|
}
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
// why: docs/specification.md#pending-and-denied-states
|
|
9
9
|
import { icon } from '../assets/icons.js';
|
|
10
10
|
import { button, esc } from './index.js';
|
|
11
|
+
import { playEntrance } from '../motion.js';
|
|
11
12
|
|
|
12
13
|
const cx = (...a) => a.filter(Boolean).join(' ');
|
|
13
|
-
|
|
14
14
|
// ---- Skeleton ------------------------------------------------------------
|
|
15
15
|
// The placeholder shape. `lines` is a count, or an array of widths when the
|
|
16
16
|
// varied ragged edge of real prose matters (['100%','92%','60%']). `height`
|
|
@@ -80,7 +80,8 @@ export function setBusy(root, { busy = false, message, body } = {}) {
|
|
|
80
80
|
region.setAttribute('aria-busy', busy ? 'true' : 'false');
|
|
81
81
|
if (body != null) {
|
|
82
82
|
const slot = region.querySelector('[data-busy-body]');
|
|
83
|
-
|
|
83
|
+
// The new body fades in. Only here — the region the page loads with is still.
|
|
84
|
+
if (slot) { slot.innerHTML = body; playEntrance(slot); }
|
|
84
85
|
}
|
|
85
86
|
const msg = region.querySelector('[data-busy-msg]');
|
|
86
87
|
if (msg) {
|
package/src/components/nav.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// <nav> + <a aria-current="page">, not role="tablist" (that is what segmented()
|
|
8
8
|
// is for). Only the collapsible sidebar groups need JS; wire with wireNav().
|
|
9
9
|
import { esc, icon } from './index.js';
|
|
10
|
+
import { playEntrance } from '../motion.js';
|
|
10
11
|
|
|
11
12
|
const cx = (...a) => a.filter(Boolean).join(' ');
|
|
12
13
|
|
|
@@ -44,7 +45,7 @@ const leafName = (label, collapsed, badge) => {
|
|
|
44
45
|
|
|
45
46
|
// One sidebar leaf: a link (or a plain, aria-disabled span). `collapsed` also
|
|
46
47
|
// hides the label visually, leaving the icon hoverable.
|
|
47
|
-
function sideLeaf(it, active, { collapsed, sub } = {}) {
|
|
48
|
+
function sideLeaf(it, active, { collapsed, sub, current = 'page' } = {}) {
|
|
48
49
|
const on = it.id != null && it.id === active;
|
|
49
50
|
const disabled = !!it.disabled;
|
|
50
51
|
const label = it.label || '';
|
|
@@ -60,7 +61,7 @@ function sideLeaf(it, active, { collapsed, sub } = {}) {
|
|
|
60
61
|
`class="${cls}"`,
|
|
61
62
|
`href="${esc(it.href || '#' + (it.id ?? ''))}"`,
|
|
62
63
|
it.target ? `target="${esc(it.target)}"` : '',
|
|
63
|
-
on ?
|
|
64
|
+
on ? `aria-current="${current}"` : '',
|
|
64
65
|
name.trim(),
|
|
65
66
|
].filter(Boolean).join(' ');
|
|
66
67
|
return `<li><a ${attrs}>${lead}${text}${badge}</a></li>`;
|
|
@@ -69,14 +70,14 @@ function sideLeaf(it, active, { collapsed, sub } = {}) {
|
|
|
69
70
|
// A collapsible group: a toggle button (aria-expanded/-controls) over a nested
|
|
70
71
|
// list. In collapsed (icon-only) mode groups don't expand, so we render the
|
|
71
72
|
// group head as a plain, non-collapsing icon row.
|
|
72
|
-
function sideGroup(it, active, { collapsed } = {}) {
|
|
73
|
+
function sideGroup(it, active, { collapsed, current } = {}) {
|
|
73
74
|
const listId = nextId('nav-grp');
|
|
74
75
|
const childActive = (it.items || []).some((c) => c.id != null && c.id === active);
|
|
75
76
|
const open = collapsed ? false : (it.open != null ? !!it.open : childActive);
|
|
76
77
|
const lead = it.icon ? `<span class="ui-nav__ic">${icon(it.icon)}</span>` : '';
|
|
77
78
|
const label = it.label || '';
|
|
78
79
|
const text = `<span class="ui-nav__label">${esc(label)}</span>`;
|
|
79
|
-
const kids = (it.items || []).map((c) => sideLeaf(c, active, { collapsed, sub: true })).join('');
|
|
80
|
+
const kids = (it.items || []).map((c) => sideLeaf(c, active, { collapsed, sub: true, current })).join('');
|
|
80
81
|
const name = leafName(label, collapsed);
|
|
81
82
|
const btn =
|
|
82
83
|
`<button type="button" class="${cx('ui-nav__item', 'ui-nav__toggle', childActive && 'is-current')}"` +
|
|
@@ -94,13 +95,17 @@ function sideItem(it, active, opts) {
|
|
|
94
95
|
// items/section items: { id, label, icon?, href?, target?, badge?, danger?,
|
|
95
96
|
// disabled?, items?, open? } (items ⇒ collapsible group)
|
|
96
97
|
// active id of the current item (gets aria-current="page")
|
|
98
|
+
// activeIs 'page', or 'section' when the page on screen sits below the active
|
|
99
|
+
// row: the row then carries aria-current="true", because "page" would
|
|
100
|
+
// announce the list as the page the reader is on
|
|
97
101
|
// collapsed icon-only rail; every item is aria-labelled at every width, and
|
|
98
102
|
// collapsed adds the hover tooltip on top
|
|
99
103
|
// footer trusted HTML pinned below a divider (e.g. a sign-out link)
|
|
100
104
|
// ariaLabel accessible name for the <nav> landmark
|
|
101
105
|
export function sidebarNav({
|
|
102
|
-
sections, items, active, collapsed = false, footer = '', ariaLabel = 'Sidebar', id,
|
|
106
|
+
sections, items, active, activeIs = 'page', collapsed = false, footer = '', ariaLabel = 'Sidebar', id,
|
|
103
107
|
} = {}) {
|
|
108
|
+
const current = activeIs === 'section' ? 'true' : 'page';
|
|
104
109
|
const blocks = (sections && sections.length)
|
|
105
110
|
? sections
|
|
106
111
|
: [{ items: items || [] }];
|
|
@@ -110,7 +115,7 @@ export function sidebarNav({
|
|
|
110
115
|
? `<div class="ui-nav__cap" id="${capId}"${collapsed ? ' aria-hidden="true"' : ''}>${esc(sec.label)}</div>`
|
|
111
116
|
: '';
|
|
112
117
|
const list = `<ul class="ui-nav__list"${capId ? ` aria-labelledby="${capId}"` : ''}>` +
|
|
113
|
-
(sec.items || []).map((it) => sideItem(it, active, { collapsed })).join('') + `</ul>`;
|
|
118
|
+
(sec.items || []).map((it) => sideItem(it, active, { collapsed, current })).join('') + `</ul>`;
|
|
114
119
|
return `<div class="ui-nav__section">${cap}${list}</div>`;
|
|
115
120
|
}).join('');
|
|
116
121
|
const foot = footer ? `<div class="ui-nav__foot">${footer}</div>` : '';
|
|
@@ -182,7 +187,10 @@ function toggleGroup(btn) {
|
|
|
182
187
|
const open = btn.getAttribute('aria-expanded') === 'true';
|
|
183
188
|
btn.setAttribute('aria-expanded', open ? 'false' : 'true');
|
|
184
189
|
if (li) li.classList.toggle('is-open', !open);
|
|
185
|
-
if (list) {
|
|
190
|
+
if (list) {
|
|
191
|
+
if (open) list.setAttribute('hidden', '');
|
|
192
|
+
else { list.removeAttribute('hidden'); playEntrance(list); }
|
|
193
|
+
}
|
|
186
194
|
}
|
|
187
195
|
|
|
188
196
|
export function wireNav(root = document) {
|