@xoong/ui 0.0.0-stage → 0.0.35
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/LICENSE +21 -0
- package/README.md +30 -2
- package/package.json +51 -4
- package/src/confirm.js +53 -0
- package/src/dialog.js +71 -0
- package/src/form.js +148 -0
- package/src/index.d.ts +33 -0
- package/src/index.js +10 -0
- package/src/mist.css +1774 -0
- package/src/router.js +361 -0
- package/src/table.js +79 -0
- package/src/toast.js +81 -0
- package/src/xoong.css +149 -0
package/src/router.js
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-router>: a History-API router declared in markup (see docs/decisions/0008-spa-router.md and docs/admin.md).
|
|
4
|
+
*
|
|
5
|
+
* <xo-router base="/admin"> (add `base-links`: a root-relative link such as href="/users" inside the router means /admin/users,
|
|
6
|
+
* so the same screens can be mounted under any base; rel="external" leaves the app)
|
|
7
|
+
* <template xo-layout="shell"><nav>...<a href="/users">Users</a></nav><main xo-outlet></main></template>
|
|
8
|
+
* <template xo-route="/users/:id" layout="shell" title="User" description="The user page"><xo-view get="getUser" p-id=":id">...</xo-view></template>
|
|
9
|
+
* <template xo-route="*">Not found</template>
|
|
10
|
+
* </xo-router>
|
|
11
|
+
*
|
|
12
|
+
* The `router` object (exported by @xoong/ui):
|
|
13
|
+
* router.navigate('/users/3');
|
|
14
|
+
* router.guard(async (to, from) => (loggedIn() ? true : '/login')); // true | false | '/redirect'
|
|
15
|
+
* router.beforeNavigate((to, from) => !form.dirty || confirm('Leave without saving?')); // veto leaving
|
|
16
|
+
*/
|
|
17
|
+
import { XoElement, define, text } from '@xoong/core';
|
|
18
|
+
import { setParamSource, setUrlSync, ask } from '@xoong/data/view';
|
|
19
|
+
|
|
20
|
+
/** @typedef {{ path: string, params: Record<string,string>, query: URLSearchParams, url: string, route: Route | null }} Location */
|
|
21
|
+
/** @typedef {{ pattern: string, regex: RegExp, names: string[], layout: string | null, title: string, description: string | undefined, template: HTMLTemplateElement, score: number }} Route */
|
|
22
|
+
|
|
23
|
+
const trim = (s) => (s.length > 1 ? s.replace(/\/+$/, '') : s) || '/';
|
|
24
|
+
/** Decode a URL part; a malformed escape ("%zz") is kept as it is instead of throwing, like Vue Router. */
|
|
25
|
+
const dec = (s, fn = decodeURIComponent) => { try { return fn(s); } catch { return s; } };
|
|
26
|
+
|
|
27
|
+
/** Compile "/users/:id" or "*" into a matcher; static segments rank above parameters, parameters above the catch-all. */
|
|
28
|
+
function compile(pattern) {
|
|
29
|
+
if (pattern === '*') return { regex: /^.*$/, names: [], score: 0 };
|
|
30
|
+
const names = [];
|
|
31
|
+
let score = 1000;
|
|
32
|
+
const source = trim(pattern).split('/').map((seg) => {
|
|
33
|
+
if (seg.startsWith(':')) { names.push(seg.slice(1)); score -= 10; return '([^/]+)'; }
|
|
34
|
+
score += 1;
|
|
35
|
+
return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
36
|
+
}).join('/');
|
|
37
|
+
return { regex: new RegExp(`^${source || '/'}/?$`), names, score };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The router that is currently running (there is one per page). */
|
|
41
|
+
/** @type {XoRouter | null} */
|
|
42
|
+
let active = null;
|
|
43
|
+
|
|
44
|
+
class XoRouter extends XoElement {
|
|
45
|
+
static props = { base: String, baseLinks: Boolean };
|
|
46
|
+
|
|
47
|
+
/** @type {Route[]} */ #routes = [];
|
|
48
|
+
/** @type {Map<string, { template: HTMLTemplateElement, layout: string | null }>} */ #layouts = new Map();
|
|
49
|
+
/** @type {{ name: string, el: Element, outlet: Element }[]} */ #mounted = [];
|
|
50
|
+
/** @type {Location | null} */ #current = null;
|
|
51
|
+
/** @type {((to: Location, from: Location | null) => any)[]} */ #guards = [];
|
|
52
|
+
/** @type {((to: Location, from: Location | null, type: string) => any)[]} */ #vetoes = [];
|
|
53
|
+
#root = /** @type {HTMLElement | null} */ (null);
|
|
54
|
+
#token = 0;
|
|
55
|
+
#started = false;
|
|
56
|
+
/** Index of the current history entry (kept in history.state, like Vue Router): lets Back/Forward be cancelled with history.go. */
|
|
57
|
+
#pos = 0;
|
|
58
|
+
#reverting = false;
|
|
59
|
+
/** @type {Map<number, { x: number, y: number }>} scroll position per history entry, restored on Back/Forward */
|
|
60
|
+
#scrolls = new Map();
|
|
61
|
+
/** Forms with unsaved changes (they announce it with `xo-dirty`); leaving the page asks first, unless the form says `xo-guard="off"`. */
|
|
62
|
+
#dirtyForms = new Set();
|
|
63
|
+
#onDirty = (/** @type {Event} */ e) => {
|
|
64
|
+
const form = /** @type {Element} */ (e.target);
|
|
65
|
+
if (/** @type {any} */ (e).detail?.dirty && form.getAttribute('xo-guard') !== 'off') this.#dirtyForms.add(form); else this.#dirtyForms.delete(form);
|
|
66
|
+
};
|
|
67
|
+
#unsaved = () => { for (const f of this.#dirtyForms) if (!f.isConnected) this.#dirtyForms.delete(f); return [...this.#dirtyForms]; };
|
|
68
|
+
#onUnload = (/** @type {BeforeUnloadEvent} */ e) => { if (this.#unsaved().length) { e.preventDefault(); e.returnValue = ''; } };
|
|
69
|
+
#onClick = (/** @type {MouseEvent} */ e) => this.#click(e);
|
|
70
|
+
#onPop = () => {
|
|
71
|
+
if (this.#reverting) { this.#reverting = false; return; } // our own history.go(-delta) after a vetoed Back/Forward
|
|
72
|
+
const target = window.history.state?.xoPos;
|
|
73
|
+
const delta = typeof target === 'number' ? target - this.#pos : 0;
|
|
74
|
+
this.#go(this.#read(), 'replace', 'popstate', 0, delta, target).catch((e) => this.#report(e));
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
connected() {
|
|
78
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => this.#start(), { once: true });
|
|
79
|
+
else queueMicrotask(() => this.#start());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
disconnected() {
|
|
83
|
+
document.removeEventListener('click', this.#onClick);
|
|
84
|
+
document.removeEventListener('xo-dirty', this.#onDirty);
|
|
85
|
+
window.removeEventListener('popstate', this.#onPop);
|
|
86
|
+
window.removeEventListener('beforeunload', this.#onUnload);
|
|
87
|
+
setUrlSync(null);
|
|
88
|
+
this.#started = false;
|
|
89
|
+
if (active === this) active = null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---- public API (also available as the `router` object) --------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/** The current location, or null before the first navigation finished. */
|
|
95
|
+
get current() { return this.#current; }
|
|
96
|
+
|
|
97
|
+
/** Go to a path (relative to `base`). Resolves with true when the navigation happened. */
|
|
98
|
+
navigate(path, { replace = false } = {}) {
|
|
99
|
+
return this.#go(this.#parse(path), replace ? 'replace' : 'push', 'api');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Run before every navigation. Return true to continue, false to cancel, or a path to redirect. */
|
|
103
|
+
guard(fn) { this.#guards.push(fn); return () => { this.#guards = this.#guards.filter((g) => g !== fn); }; }
|
|
104
|
+
|
|
105
|
+
/** Run before leaving the current location (links, back/forward, `navigate`). Return false to stay. */
|
|
106
|
+
beforeNavigate(fn) { this.#vetoes.push(fn); return () => { this.#vetoes = this.#vetoes.filter((g) => g !== fn); }; }
|
|
107
|
+
|
|
108
|
+
// ---- internals -----------------------------------------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
#start() {
|
|
111
|
+
if (this.#started || !this.isConnected) return;
|
|
112
|
+
this.#started = true;
|
|
113
|
+
active = this;
|
|
114
|
+
for (const t of /** @type {HTMLTemplateElement[]} */ ([...this.querySelectorAll(':scope > template')])) {
|
|
115
|
+
if (t.hasAttribute('xo-layout')) this.#layouts.set(t.getAttribute('xo-layout') ?? '', { template: t, layout: t.getAttribute('layout') });
|
|
116
|
+
else if (t.hasAttribute('xo-route')) {
|
|
117
|
+
const pattern = t.getAttribute('xo-route') ?? '';
|
|
118
|
+
this.#routes.push({ pattern, ...compile(pattern), layout: t.getAttribute('layout'), title: t.getAttribute('title') ?? '', description: t.getAttribute('description') ?? undefined, template: t });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
this.#routes.sort((a, b) => b.score - a.score);
|
|
122
|
+
for (const old of this.querySelectorAll(':scope > .xo-router-root')) old.remove(); // content prerendered by @xoong/ssr: the router renders it again
|
|
123
|
+
this.#root = document.createElement('div');
|
|
124
|
+
this.#root.className = 'xo-router-root';
|
|
125
|
+
this.append(this.#root);
|
|
126
|
+
setParamSource((name) => this.#current?.params[name]); // p-id=":id" on a view
|
|
127
|
+
try { window.history.scrollRestoration = 'manual'; } catch { /* a DOM without it */ } // we restore scroll ourselves, per entry
|
|
128
|
+
this.#pos = window.history.state?.xoPos ?? 0;
|
|
129
|
+
document.addEventListener('click', this.#onClick);
|
|
130
|
+
document.addEventListener('xo-dirty', this.#onDirty);
|
|
131
|
+
window.addEventListener('popstate', this.#onPop);
|
|
132
|
+
window.addEventListener('beforeunload', this.#onUnload);
|
|
133
|
+
this.#vetoes.push(async () => { // a dirty form asks before the page is left (links, Back, navigate)
|
|
134
|
+
const dirty = this.#unsaved();
|
|
135
|
+
if (!dirty.length) return true;
|
|
136
|
+
const own = dirty.map((f) => f.getAttribute('xo-guard')).find((m) => m && m !== 'off');
|
|
137
|
+
return ask(own ?? text('xoong.guard.leave', 'You have unsaved changes. Leave this page?'), { kind: 'leave', forms: dirty });
|
|
138
|
+
});
|
|
139
|
+
// url-params of a view: write the query string through the router, without rendering the route again
|
|
140
|
+
setUrlSync({ read: () => new URLSearchParams(window.location.search), write: (params, mode) => this.#writeQuery(params, mode), subscribe: () => () => {} });
|
|
141
|
+
this.#go(this.#read(), 'replace', 'start').catch((e) => this.#report(e));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Navigation started by the page itself (first load, back/forward, a link click) cannot reject to a caller: report it. */
|
|
145
|
+
#report(e) {
|
|
146
|
+
console.error(e);
|
|
147
|
+
this.emit('xo-error', e);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
get #base() { return trim(this.base || '/'); }
|
|
151
|
+
|
|
152
|
+
/** With `base-links`, a root-relative link that does not already start with the base is relative to the base. */
|
|
153
|
+
#own(/** @type {string} */ href) {
|
|
154
|
+
const base = this.#base;
|
|
155
|
+
if (!this.baseLinks || base === '/' || !href.startsWith('/') || href.startsWith('//')) return href;
|
|
156
|
+
return href === base || href.startsWith(base + '/') || href.startsWith(base + '?') || href.startsWith(base + '#') ? href : base + href;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The browser location as a router location (path without the base). */
|
|
160
|
+
#read() {
|
|
161
|
+
return this.#parse(window.location.pathname + window.location.search + window.location.hash, true);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** @param {string} target a path (with optional query) relative to the base, or a full path when `absolute` */
|
|
165
|
+
#parse(target, absolute = false) {
|
|
166
|
+
const url = new URL(target, `${window.location.origin}${absolute ? '' : this.#base === '/' ? '/' : this.#base + '/'}`);
|
|
167
|
+
let path = trim(dec(url.pathname, decodeURI));
|
|
168
|
+
const base = this.#base;
|
|
169
|
+
if (base !== '/' && (path === base || path.startsWith(base + '/'))) path = trim(path.slice(base.length) || '/');
|
|
170
|
+
return this.#locate(path, url);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @returns {Location} */
|
|
174
|
+
#locate(path, url) {
|
|
175
|
+
const base = this.#base === '/' ? '' : this.#base;
|
|
176
|
+
const full = (base + (path === '/' ? '' : path)) || '/'; // as in the address bar: base + path, no trailing slash
|
|
177
|
+
const out = { path, params: {}, query: url.searchParams, url: full + url.search + url.hash, route: null };
|
|
178
|
+
for (const route of this.#routes) {
|
|
179
|
+
const m = route.regex.exec(path);
|
|
180
|
+
if (!m) continue;
|
|
181
|
+
out.params = Object.fromEntries(route.names.map((n, i) => [n, dec(m[i + 1])]));
|
|
182
|
+
out.route = route;
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#click(/** @type {MouseEvent} */ e) {
|
|
189
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
190
|
+
const a = /** @type {Element} */ (e.target)?.closest?.('a[href]');
|
|
191
|
+
if (!a || (a.getAttribute('target') && a.getAttribute('target') !== '_self') || a.hasAttribute('download') || a.getAttribute('rel')?.includes('external')) return;
|
|
192
|
+
const url = new URL(this.#own(/** @type {string} */ (a.getAttribute('href'))), window.location.href);
|
|
193
|
+
if (url.origin !== window.location.origin) return;
|
|
194
|
+
const base = this.#base;
|
|
195
|
+
if (base !== '/' && !(url.pathname === base || url.pathname.startsWith(base + '/'))) return; // not ours: let the browser navigate
|
|
196
|
+
if (url.pathname === window.location.pathname && url.search === window.location.search && url.hash) return; // in-page anchor
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
this.#go(this.#parse(url.pathname + url.search + url.hash, true), 'push', 'link').catch((err) => this.#report(err));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Run the hooks, then show the location. `type`: 'start' | 'link' | 'api' | 'popstate'.
|
|
203
|
+
* @param {Location} to @param {'push'|'replace'} mode @param {string} type
|
|
204
|
+
*/
|
|
205
|
+
async #go(to, mode, type, depth = 0, delta = 0, popPos = undefined) {
|
|
206
|
+
const token = ++this.#token;
|
|
207
|
+
const from = this.#current;
|
|
208
|
+
// Same place as now: nothing to do (Vue Router calls this a duplicated navigation). Only the hash differs: no re-render, just
|
|
209
|
+
// scroll to the anchor (React Router / Vue Router behave the same way for in-page anchors).
|
|
210
|
+
if (from && type !== 'start' && type !== 'redirect' && to.path === from.path && to.query.toString() === from.query.toString()) {
|
|
211
|
+
if (type !== 'popstate' && to.url === from.url) return false;
|
|
212
|
+
if (type !== 'popstate') this.#writeUrl(to, mode);
|
|
213
|
+
else if (typeof popPos === 'number') this.#pos = popPos;
|
|
214
|
+
this.#current = { ...to, route: from.route };
|
|
215
|
+
this.#scrollTo(to, type, this.#pos);
|
|
216
|
+
this.emit('xo-navigate', { to, from });
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
if (type !== 'start') {
|
|
220
|
+
for (const veto of [...this.#vetoes]) {
|
|
221
|
+
if ((await veto(to, from, type)) === false) { if (token === this.#token) this.#restore(type, delta); return false; }
|
|
222
|
+
if (token !== this.#token) return false; // a newer navigation took over
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
for (const guard of [...this.#guards]) {
|
|
226
|
+
const result = await guard(to, from);
|
|
227
|
+
if (token !== this.#token) return false;
|
|
228
|
+
if (result === false) { this.#restore(type, delta); return false; }
|
|
229
|
+
if (typeof result === 'string') {
|
|
230
|
+
if (depth >= 10) throw new Error('xoong router: too many redirects (a guard keeps redirecting)');
|
|
231
|
+
return this.#go(this.#parse(result), 'replace', 'redirect', depth + 1);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (from && type !== 'start') this.#scrolls.set(this.#pos, { x: window.scrollX, y: window.scrollY }); // where we were, for Back
|
|
235
|
+
if (type === 'popstate') { if (typeof popPos === 'number') this.#pos = popPos; }
|
|
236
|
+
else if (type === 'start') this.#writeUrl(to, 'replace');
|
|
237
|
+
else this.#writeUrl(to, mode);
|
|
238
|
+
this.#current = to;
|
|
239
|
+
this.#render(to);
|
|
240
|
+
this.#afterNavigate(to, from, type);
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A vetoed or cancelled Back/Forward already moved the address bar. Put it back with history.go(-delta), as Vue Router does:
|
|
246
|
+
* the history stays as it was, forward entries included. Where the position is unknown (or the DOM has no history.go), push the
|
|
247
|
+
* previous URL again as a fallback, which drops forward entries.
|
|
248
|
+
*/
|
|
249
|
+
#restore(type, delta) {
|
|
250
|
+
if (type !== 'popstate' || !this.#current) return;
|
|
251
|
+
if (delta && typeof window.history.go === 'function') { this.#reverting = true; window.history.go(-delta); return; }
|
|
252
|
+
window.history.pushState({ xoPos: this.#pos }, '', this.#current.url);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A view changed its url-params: update the query string of the current location (a new entry for a click, replace for typing). */
|
|
256
|
+
#writeQuery(params, mode) {
|
|
257
|
+
const query = params.toString();
|
|
258
|
+
const url = window.location.pathname + (query ? `?${query}` : '') + window.location.hash;
|
|
259
|
+
if (url === window.location.pathname + window.location.search + window.location.hash) return;
|
|
260
|
+
if (mode === 'push') { this.#scrolls.set(this.#pos, { x: window.scrollX, y: window.scrollY }); this.#pos += 1; window.history.pushState({ xoPos: this.#pos }, '', url); }
|
|
261
|
+
else window.history.replaceState({ xoPos: this.#pos }, '', url);
|
|
262
|
+
if (this.#current) this.#current = { ...this.#current, query: new URLSearchParams(query), url: url.slice(0) };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
#writeUrl(to, mode) {
|
|
266
|
+
if (mode === 'replace') window.history.replaceState({ xoPos: this.#pos }, '', to.url);
|
|
267
|
+
else if (to.url !== window.location.pathname + window.location.search + window.location.hash) {
|
|
268
|
+
this.#pos += 1;
|
|
269
|
+
window.history.pushState({ xoPos: this.#pos }, '', to.url);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** After a navigation: Back/Forward restores the scroll position of that entry, an anchor scrolls to its element, otherwise to the top. */
|
|
274
|
+
#scrollTo(to, type, pos) {
|
|
275
|
+
const hash = to.url.includes('#') ? to.url.slice(to.url.indexOf('#') + 1) : '';
|
|
276
|
+
const target = hash ? document.getElementById(dec(hash)) : null;
|
|
277
|
+
if (type === 'popstate' && !hash) { const s = this.#scrolls.get(pos); window.scrollTo?.(s?.x ?? 0, s?.y ?? 0); }
|
|
278
|
+
else if (target) target.scrollIntoView?.();
|
|
279
|
+
else window.scrollTo?.(0, 0);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The layout chain of a route, outermost first, e.g. ['root', 'shell']. */
|
|
283
|
+
#chain(route) {
|
|
284
|
+
const names = [];
|
|
285
|
+
for (let name = route?.layout; name; ) {
|
|
286
|
+
if (names.includes(name)) throw new Error(`xoong router: layout "${name}" includes itself`);
|
|
287
|
+
const layout = this.#layouts.get(name);
|
|
288
|
+
if (!layout) throw new Error(`xoong router: layout "${name}" is not declared (<template xo-layout="${name}">)`);
|
|
289
|
+
names.unshift(name);
|
|
290
|
+
name = layout.layout ?? null;
|
|
291
|
+
}
|
|
292
|
+
return names;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Show the matched route: keep the layouts it shares with the previous route, replace the rest. */
|
|
296
|
+
#render(to) {
|
|
297
|
+
const root = /** @type {HTMLElement} */ (this.#root);
|
|
298
|
+
const route = to.route;
|
|
299
|
+
const chain = this.#chain(route);
|
|
300
|
+
let keep = 0;
|
|
301
|
+
while (keep < chain.length && keep < this.#mounted.length && this.#mounted[keep].name === chain[keep]) keep++;
|
|
302
|
+
for (const gone of this.#mounted.splice(keep)) gone.el.remove();
|
|
303
|
+
let parent = keep ? this.#mounted[keep - 1].outlet : root;
|
|
304
|
+
for (const name of chain.slice(keep)) {
|
|
305
|
+
const fragment = /** @type {HTMLTemplateElement} */ (this.#layouts.get(name)?.template).content.cloneNode(true);
|
|
306
|
+
const holder = document.createElement('div');
|
|
307
|
+
holder.className = 'xo-layout';
|
|
308
|
+
holder.append(fragment);
|
|
309
|
+
const outlet = holder.querySelector('[xo-outlet]');
|
|
310
|
+
if (!outlet) throw new Error(`xoong router: layout "${name}" has no element with xo-outlet`);
|
|
311
|
+
parent.replaceChildren(holder);
|
|
312
|
+
this.#mounted.push({ name, el: holder, outlet });
|
|
313
|
+
parent = outlet;
|
|
314
|
+
}
|
|
315
|
+
if (!route) { parent.replaceChildren(); return; }
|
|
316
|
+
parent.replaceChildren(route.template.content.cloneNode(true)); // views inside start (and fetch) now
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#afterNavigate(to, from, type) {
|
|
320
|
+
if (to.route?.title) document.title = to.route.title;
|
|
321
|
+
if (to.route?.description !== undefined) { // per-route description for search engines (kept by prerendering)
|
|
322
|
+
let meta = document.head.querySelector('meta[name="description"]');
|
|
323
|
+
if (!meta) { meta = document.createElement('meta'); meta.setAttribute('name', 'description'); document.head.append(meta); }
|
|
324
|
+
meta.setAttribute('content', to.route.description);
|
|
325
|
+
}
|
|
326
|
+
if (this.baseLinks) for (const a of this.querySelectorAll('a[href]')) { // the address shown on hover and opened in a new tab is the real one
|
|
327
|
+
const href = /** @type {string} */ (a.getAttribute('href'));
|
|
328
|
+
if (!a.getAttribute('rel')?.includes('external') && !a.getAttribute('target') && this.#own(href) !== href) a.setAttribute('href', this.#own(href));
|
|
329
|
+
}
|
|
330
|
+
for (const a of document.querySelectorAll('a[href]')) {
|
|
331
|
+
let path;
|
|
332
|
+
try { path = trim(new URL(a.getAttribute('href') ?? '', window.location.href).pathname); } catch { continue; }
|
|
333
|
+
const base = this.#base;
|
|
334
|
+
if (base !== '/' && path.startsWith(base)) path = trim(path.slice(base.length) || '/');
|
|
335
|
+
if (path === to.path) a.setAttribute('aria-current', 'page');
|
|
336
|
+
else if (path !== '/' && to.path.startsWith(path + '/')) a.setAttribute('aria-current', 'true'); // a parent section
|
|
337
|
+
else if (a.hasAttribute('aria-current')) a.toggleAttribute('aria-current', false);
|
|
338
|
+
}
|
|
339
|
+
const outlet = this.#mounted.at(-1)?.outlet ?? this.#root;
|
|
340
|
+
if (from) { // not on the first render: move focus to the new content, and scroll like a page load (or restore, on Back/Forward)
|
|
341
|
+
outlet?.setAttribute('tabindex', '-1');
|
|
342
|
+
/** @type {HTMLElement} */ (outlet)?.focus?.({ preventScroll: true });
|
|
343
|
+
this.#scrollTo(to, type, this.#pos);
|
|
344
|
+
}
|
|
345
|
+
this.emit('xo-navigate', { to, from });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
define('xo-router', XoRouter);
|
|
349
|
+
|
|
350
|
+
/** The running router, as an object: `router.navigate('/users')`. Throws when there is no <xo-router> on the page. */
|
|
351
|
+
export const router = {
|
|
352
|
+
navigate: (path, options) => need().navigate(path, options),
|
|
353
|
+
guard: (fn) => need().guard(fn),
|
|
354
|
+
beforeNavigate: (fn) => need().beforeNavigate(fn),
|
|
355
|
+
get current() { return need().current; },
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
function need() {
|
|
359
|
+
if (!active) throw new Error('xoong router: there is no <xo-router> on this page yet (it starts after the page is parsed)');
|
|
360
|
+
return active;
|
|
361
|
+
}
|
package/src/table.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-table of="items" columns="name,email:E-mail,status" sortable page-size="25">
|
|
4
|
+
* <button xo-click="DELETE /users/{id}">Delete</button> <- children become the last cell of each row
|
|
5
|
+
* </xo-table>
|
|
6
|
+
*
|
|
7
|
+
* Expands into a plain <table> inside the enclosing <xo-view>, so your CSS and tests see ordinary markup.
|
|
8
|
+
* - columns: comma-separated fields, optional "field:Label". Labels default to the field name.
|
|
9
|
+
* - sortable: header buttons set the `sort` query parameter ("name", then "-name").
|
|
10
|
+
* - filters="name,status": a search box above the table per field; typing sets that query parameter (empty removes it).
|
|
11
|
+
* - A child with slot="field" replaces the default cell of that column with the child itself:
|
|
12
|
+
* <xo-table columns="name,status"><span slot="status" class="badge" data-status="{status}">{status}</span></xo-table>
|
|
13
|
+
* - selectable: a checkbox per row and a select-all checkbox. Children with slot="bulk" appear above the table while rows are
|
|
14
|
+
* ticked; give them xo-selected to run their operation once per ticked row:
|
|
15
|
+
* <button slot="bulk" xo-click="DELETE /users/{id}" xo-selected>Delete ticked</button>
|
|
16
|
+
* - filters="q:Search,status": a search box per field, with an optional label (the label is the placeholder and the accessible name).
|
|
17
|
+
* - columns="publishedAt:Published:datetime": a third part formats the cell (date, datetime, time, relative, number, localized).
|
|
18
|
+
* - cards: in a narrow pane every row is drawn as a card (a theme does it: the table gets the class `xo-cards`); every cell has `data-label` with its column's label,
|
|
19
|
+
* the first column (or `title="field"`) is the card title (`data-title`), `status="field"` marks the status cell (`data-status`).
|
|
20
|
+
* - empty="No results" (or a child `<template slot="empty">`): shown when the list is empty and not loading.
|
|
21
|
+
* - page-sizes="10,25,50": a rows-per-page choice next to the "1–3 of 5" range text.
|
|
22
|
+
* - page-size: sets `limit` and shows Previous/Next, which change `offset`. Needs a `total` field in the response.
|
|
23
|
+
* The operation must declare those query parameters; `xoong check` reports it when it does not (XO250).
|
|
24
|
+
*/
|
|
25
|
+
import { defineTemplate } from '@xoong/data/view';
|
|
26
|
+
|
|
27
|
+
const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
28
|
+
|
|
29
|
+
defineTemplate('xo-table', (el, { host }) => {
|
|
30
|
+
const of = el.getAttribute('of') || 'items';
|
|
31
|
+
const sortable = el.hasAttribute('sortable');
|
|
32
|
+
const pageSize = Number(el.getAttribute('page-size')) || 0;
|
|
33
|
+
const columns = (el.getAttribute('columns') || '').split(',').map((c) => c.trim()).filter(Boolean).map((c) => {
|
|
34
|
+
const [field, label, fmt] = c.split(':'); // field, optional label, optional display format (date, datetime, time, relative, number, localized)
|
|
35
|
+
return { field, label: label || field, fmt };
|
|
36
|
+
});
|
|
37
|
+
const cellOf = new Map();
|
|
38
|
+
const rest = [];
|
|
39
|
+
const bulk = [];
|
|
40
|
+
let empty = el.getAttribute('empty') ? esc(el.getAttribute('empty') ?? '') : '';
|
|
41
|
+
const selectable = el.hasAttribute('selectable');
|
|
42
|
+
/** The child as markup, without its slot attribute. */
|
|
43
|
+
const unslotted = (child) => { const copy = /** @type {Element} */ (child.cloneNode(true)); copy.toggleAttribute('slot', false); return copy.outerHTML; };
|
|
44
|
+
for (const child of [...el.childNodes]) {
|
|
45
|
+
const slot = child.nodeType === 1 ? /** @type {Element} */ (child).getAttribute('slot') : null;
|
|
46
|
+
if (slot === 'bulk') bulk.push(unslotted(child));
|
|
47
|
+
else if (slot === 'empty') empty = /** @type {Element} */ (child).localName === 'template' ? /** @type {HTMLTemplateElement} */ (child).innerHTML : unslotted(child);
|
|
48
|
+
else if (slot && columns.some((c) => c.field === slot)) cellOf.set(slot, unslotted(child));
|
|
49
|
+
else rest.push(child);
|
|
50
|
+
}
|
|
51
|
+
const actions = rest.map((n) => (n.nodeType === 1 ? n.outerHTML : n.textContent)).join('').trim();
|
|
52
|
+
const filters = (el.getAttribute('filters') || '').split(',').map((f) => f.trim()).filter(Boolean).map((f) => { const [field, label] = f.split(':'); return { field, label: label || columns.find((c) => c.field === field)?.label || field }; });
|
|
53
|
+
const sizes = (el.getAttribute('page-sizes') || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
54
|
+
if (pageSize && host.params.limit == null) host.params = { ...host.params, limit: pageSize };
|
|
55
|
+
|
|
56
|
+
const tickAll = selectable ? `<th scope="col"><input type="checkbox" xo-select="all:${esc(of)}" aria-label="Select all rows" xo-t="aria-label:xoong.table.selectAll"></th>` : '';
|
|
57
|
+
const tickRow = selectable ? '<td><input type="checkbox" xo-select aria-label="Select row" xo-t="aria-label:xoong.table.selectRow"></td>' : '';
|
|
58
|
+
const head = columns.map((c) => `<th scope="col">${sortable ? `<button type="button" xo-param="sort~=${esc(c.field)}">${esc(c.label)}</button>` : esc(c.label)}</th>`).join('');
|
|
59
|
+
const cellText = (c) => (['date', 'datetime', 'time', 'relative'].includes(c.fmt) ? `<time datetime="{${esc(c.field)}}">{${esc(c.field)} | ${c.fmt}}</time>` : c.fmt ? `{${esc(c.field)} | ${esc(c.fmt)}}` : `{${esc(c.field)}}`);
|
|
60
|
+
const titleField = el.getAttribute('title') || columns[0]?.field; // the card title of the narrow layout
|
|
61
|
+
const statusField = el.getAttribute('status');
|
|
62
|
+
const cells = columns.map((c) => `<td data-label="${esc(c.label)}"${c.field === titleField ? ' data-title' : ''}${c.field === statusField ? ' data-status' : ''}>${cellOf.get(c.field) ?? cellText(c)}</td>`).join('');
|
|
63
|
+
const box = document.createElement('div');
|
|
64
|
+
box.className = el.hasAttribute('cards') ? 'xo-table xo-cards' : 'xo-table'; // `cards`: rows become cards in a narrow pane (the theme draws them)
|
|
65
|
+
const search = filters.map((f) => `<input type="search" xo-param="${esc(f.field)}" placeholder="${esc(f.label)}" aria-label="${esc(f.label)}">`).join('');
|
|
66
|
+
const bar = bulk.length ? `<div class="xo-bulk" xo-if="$selectedCount"><span xo-t="xoong.table.selected">{$selectedCount} selected</span>${bulk.join('')}</div>` : '';
|
|
67
|
+
box.innerHTML = `${bar}${search ? `<div class="xo-filters">${search}</div>` : ''}<table>
|
|
68
|
+
<thead><tr>${tickAll}${head}${actions ? '<th scope="col"><span class="xo-visually-hidden" xo-t="xoong.table.actions">Actions</span></th>' : ''}</tr></thead>
|
|
69
|
+
<tbody><tr xo-each="${esc(of)}">${tickRow}${cells}${actions ?`<td class="xo-actions">${actions}</td>` : ''}</tr></tbody>
|
|
70
|
+
</table>${empty ? `
|
|
71
|
+
<div class="xo-empty" xo-if="!${esc(of)}"><div xo-if="!$loading"><div xo-if="!$error">${empty}</div></div></div>` : ''}${pageSize ? `
|
|
72
|
+
<nav class="xo-pager" aria-label="Pagination" xo-t="aria-label:xoong.table.pagination">
|
|
73
|
+
<span class="xo-range" xo-if="total" xo-t="xoong.table.range">{$from}–{$to} of {total}</span>${sizes.length ? `
|
|
74
|
+
<select class="xo-page-size" xo-param="limit" aria-label="Rows per page" xo-t="aria-label:xoong.table.rowsPerPage">${sizes.map((s) => `<option value="${esc(s)}">${esc(s)}</option>`).join('')}</select>` : ''}
|
|
75
|
+
<button type="button" xo-if="$hasPrev" xo-param="offset-=${pageSize}" xo-t="xoong.table.previous">Previous</button>
|
|
76
|
+
<button type="button" xo-if="$hasNext" xo-param="offset+=${pageSize}" xo-t="xoong.table.next">Next</button>
|
|
77
|
+
</nav>` : ''}`;
|
|
78
|
+
return box;
|
|
79
|
+
});
|
package/src/toast.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-toast></xo-toast>: messages for the person, from anywhere on the page.
|
|
4
|
+
*
|
|
5
|
+
* - errors: the latest `xo-error` (a failed action), in a `role="alert"` region; hidden by the next `xo-done` or after `timeout` seconds;
|
|
6
|
+
* - notices: an `xo-notice` event ({ message, kind: 'success' | 'info', action?, timeout? }) in a `role="status"` region. `xo-notice="Saved"`
|
|
7
|
+
* on an `xo-click`/`xo-submit` element raises one after success; from JavaScript use `toast.show({ message, action })`.
|
|
8
|
+
* - an action is a button inside the toast, for example Undo: `action: { label: 'Undo', run: () => ... }`.
|
|
9
|
+
*
|
|
10
|
+
* <xo-toast timeout="8"></xo-toast>
|
|
11
|
+
*
|
|
12
|
+
* Put one element in the layout; style `.xo-toast` (and `.xo-toast-status`) with your CSS. `timeout` 0 keeps a toast until dismissed.
|
|
13
|
+
*/
|
|
14
|
+
import { XoElement, define } from '@xoong/core';
|
|
15
|
+
|
|
16
|
+
class XoToast extends XoElement {
|
|
17
|
+
static props = { timeout: Number };
|
|
18
|
+
/** @type {HTMLElement | null} */ #error = null;
|
|
19
|
+
/** @type {HTMLElement | null} */ #status = null;
|
|
20
|
+
#timers = /** @type {Record<string, any>} */ ({});
|
|
21
|
+
#onError = (/** @type {Event} */ e) => this.#show('error', { message: this.#text(/** @type {any} */ (e).detail) });
|
|
22
|
+
#onNotice = (/** @type {Event} */ e) => this.#show('status', /** @type {any} */ (e).detail ?? {});
|
|
23
|
+
#onDone = () => this.#hide('error');
|
|
24
|
+
|
|
25
|
+
connected() {
|
|
26
|
+
this.#error = this.#region('xo-toast', 'alert');
|
|
27
|
+
this.#status = this.#region('xo-toast xo-toast-status', 'status');
|
|
28
|
+
this.replaceChildren(this.#error, this.#status);
|
|
29
|
+
document.addEventListener('xo-error', this.#onError);
|
|
30
|
+
document.addEventListener('xo-notice', this.#onNotice);
|
|
31
|
+
document.addEventListener('xo-done', this.#onDone);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
disconnected() {
|
|
35
|
+
document.removeEventListener('xo-error', this.#onError);
|
|
36
|
+
document.removeEventListener('xo-notice', this.#onNotice);
|
|
37
|
+
document.removeEventListener('xo-done', this.#onDone);
|
|
38
|
+
for (const t of Object.values(this.#timers)) clearTimeout(t);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#region(className, role) {
|
|
42
|
+
const p = document.createElement('p');
|
|
43
|
+
p.className = className;
|
|
44
|
+
p.setAttribute('role', role); // present from the start, so assistive technology announces what is put in it later
|
|
45
|
+
p.hidden = true;
|
|
46
|
+
return p;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#text(error) { return error?.detail ?? error?.message ?? String(error ?? 'Something went wrong'); }
|
|
50
|
+
|
|
51
|
+
/** @param {'error' | 'status'} which @param {{ message?: string, action?: { label: string, run: () => any }, timeout?: number }} notice */
|
|
52
|
+
#show(which, notice) {
|
|
53
|
+
const box = /** @type {HTMLElement} */ (which === 'error' ? this.#error : this.#status);
|
|
54
|
+
box.replaceChildren(document.createTextNode(notice.message ?? ''));
|
|
55
|
+
if (notice.action) {
|
|
56
|
+
const button = document.createElement('button');
|
|
57
|
+
button.type = 'button';
|
|
58
|
+
button.className = 'xo-toast-action';
|
|
59
|
+
button.textContent = notice.action.label;
|
|
60
|
+
button.addEventListener('click', async () => { this.#hide(which); await notice.action?.run(); });
|
|
61
|
+
box.append(' ', button);
|
|
62
|
+
}
|
|
63
|
+
box.hidden = false;
|
|
64
|
+
clearTimeout(this.#timers[which]);
|
|
65
|
+
const seconds = notice.timeout ?? this.timeout ?? 6;
|
|
66
|
+
if (seconds > 0) this.#timers[which] = setTimeout(() => this.#hide(which), seconds * 1000);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#hide(which) {
|
|
70
|
+
const box = which === 'error' ? this.#error : this.#status;
|
|
71
|
+
if (box) box.hidden = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
define('xo-toast', XoToast);
|
|
75
|
+
|
|
76
|
+
/** Show a notice from JavaScript: `toast.show({ message: 'Published 3', action: { label: 'Undo', run: undo } })`. */
|
|
77
|
+
export const toast = {
|
|
78
|
+
show(/** @type {{ message: string, kind?: string, action?: { label: string, run: () => any }, timeout?: number }} */ notice) {
|
|
79
|
+
document.dispatchEvent(new CustomEvent('xo-notice', { detail: { kind: 'info', ...notice }, bubbles: true }));
|
|
80
|
+
},
|
|
81
|
+
};
|