@harshankur/viewcounter 3.0.1 → 3.2.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/.env.example +50 -6
- package/README.md +444 -104
- package/admin/apple-touch-icon.png +0 -0
- package/admin/assets/world-map.json +1 -0
- package/admin/css/admin.css +2568 -0
- package/admin/favicon.ico +0 -0
- package/admin/favicon.svg +9 -0
- package/admin/icon-192.png +0 -0
- package/admin/icon-512.png +0 -0
- package/admin/index.html +95 -0
- package/admin/js/api.js +146 -0
- package/admin/js/appTabs.js +100 -0
- package/admin/js/charts.js +842 -0
- package/admin/js/clamp.js +41 -0
- package/admin/js/constants.js +239 -0
- package/admin/js/dataTable.js +478 -0
- package/admin/js/dom.js +83 -0
- package/admin/js/format.js +130 -0
- package/admin/js/i18n.js +80 -0
- package/admin/js/icons.js +168 -0
- package/admin/js/listbox.js +145 -0
- package/admin/js/logs.js +318 -0
- package/admin/js/main.js +399 -0
- package/admin/js/modal.js +171 -0
- package/admin/js/overview.js +905 -0
- package/admin/js/passwordPrompt.js +75 -0
- package/admin/js/table.js +94 -0
- package/admin/js/theme.js +72 -0
- package/admin/js/toast.js +47 -0
- package/admin/js/viewDialogs.js +224 -0
- package/admin/js/views.js +751 -0
- package/admin/locales/en.json +683 -0
- package/admin/site.webmanifest +20 -0
- package/config/index.js +122 -4
- package/constants.js +334 -3
- package/db/AdminRepository.js +488 -0
- package/db/DatabaseManager.js +148 -26
- package/db/LogRepository.js +354 -0
- package/db/adminSchema.js +329 -0
- package/db/adminSessionStore.js +104 -0
- package/db/analysis.js +479 -0
- package/db/rejectionCounter.js +117 -0
- package/db/retention.js +97 -0
- package/index.js +91 -24
- package/middleware/adminAuth.js +244 -0
- package/middleware/adminValidation.js +319 -0
- package/middleware/auth.js +2 -2
- package/middleware/security.js +26 -2
- package/middleware/validation.js +50 -2
- package/package.json +20 -10
- package/routes/admin.js +546 -0
- package/routes/analytics.js +207 -19
- package/tracker/tracker.js +191 -0
- package/utils/appIdUtils.js +1 -1
- package/utils/cookieUtils.js +47 -0
- package/utils/durationUtils.js +33 -0
- package/utils/errorUtils.js +39 -1
- package/utils/geoCity.js +87 -0
- package/utils/ipUtils.js +1 -1
- package/utils/privacyUtils.js +2 -2
- package/utils/referrerParser.js +23 -5
- package/utils/secretStore.js +1 -1
- package/utils/userAgentParser.js +52 -3
- package/utils/visitorContext.js +70 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale-aware formatting (FRONTEND.md §8: dates and numbers are localized
|
|
3
|
+
* too, not only strings).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { currentLocale, t } from './i18n.js';
|
|
7
|
+
|
|
8
|
+
/** Intl time styles: with seconds, and without. */
|
|
9
|
+
const TIME_STYLE = Object.freeze({ FULL: 'medium', SHORT: 'short' });
|
|
10
|
+
|
|
11
|
+
function formatParts(value, options) {
|
|
12
|
+
if (value === null || value === undefined || value === '') return t('common.none');
|
|
13
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
14
|
+
if (Number.isNaN(date.getTime())) return String(value);
|
|
15
|
+
return new Intl.DateTimeFormat(currentLocale(), options).format(date);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatWith(value, timeStyle) {
|
|
19
|
+
return formatParts(value, { dateStyle: 'medium', timeStyle });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Date and time to the second: logs and the details dialog. */
|
|
23
|
+
export function formatDateTime(value) {
|
|
24
|
+
return formatWith(value, TIME_STYLE.FULL);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The date alone: the first line of a table's time cell. */
|
|
28
|
+
export function formatDate(value) {
|
|
29
|
+
return formatParts(value, { dateStyle: 'medium' });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The time of day alone: the second line of a table's time cell. Logs keep the
|
|
34
|
+
* seconds, since they order events that can be moments apart.
|
|
35
|
+
*/
|
|
36
|
+
export function formatTime(value, { seconds = false } = {}) {
|
|
37
|
+
return formatParts(value, { timeStyle: seconds ? TIME_STYLE.FULL : TIME_STYLE.SHORT });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {number} value */
|
|
41
|
+
export function formatNumber(value) {
|
|
42
|
+
return new Intl.NumberFormat(currentLocale()).format(Number(value) || 0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 1,284 stays exact; 12,900 becomes 12.9K. */
|
|
46
|
+
export function formatCompact(value) {
|
|
47
|
+
return new Intl.NumberFormat(currentLocale(), { notation: 'compact', maximumFractionDigits: 1 }).format(Number(value) || 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A headline count: exact below ten thousand (1,029), compact above (12.9K). */
|
|
51
|
+
export function formatHeadline(value) {
|
|
52
|
+
return Math.abs(Number(value) || 0) < 10000 ? formatNumber(value) : formatCompact(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A ratio (0..1) as a percentage. */
|
|
56
|
+
export function formatPercent(ratio) {
|
|
57
|
+
return new Intl.NumberFormat(currentLocale(), { style: 'percent', maximumFractionDigits: 1 }).format(Number(ratio) || 0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A number with at most `digits` decimals: 1.75 pages per visit reads 1.8. */
|
|
61
|
+
export function formatDecimal(value, digits = 1) {
|
|
62
|
+
return new Intl.NumberFormat(currentLocale(), { maximumFractionDigits: digits }).format(Number(value) || 0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function unit(value, name) {
|
|
66
|
+
return new Intl.NumberFormat(currentLocale(), { style: 'unit', unit: name, unitDisplay: 'narrow' }).format(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A duration, to the second below an hour: 45s, 3m 20s, 1h 5m. Empty
|
|
71
|
+
* (never measured) shows the placeholder rather than a misleading 0s.
|
|
72
|
+
* @param {number|null} ms
|
|
73
|
+
*/
|
|
74
|
+
export function formatDuration(ms) {
|
|
75
|
+
if (ms === null || ms === undefined || Number.isNaN(Number(ms))) return t('common.none');
|
|
76
|
+
const seconds = Math.round(Number(ms) / 1000);
|
|
77
|
+
if (seconds < 60) return unit(seconds, 'second');
|
|
78
|
+
if (seconds < 3600) {
|
|
79
|
+
const rest = seconds % 60;
|
|
80
|
+
return rest ? `${unit(Math.floor(seconds / 60), 'minute')} ${unit(rest, 'second')}` : unit(seconds / 60, 'minute');
|
|
81
|
+
}
|
|
82
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
83
|
+
const hours = Math.floor(seconds / 3600);
|
|
84
|
+
return minutes ? `${unit(hours, 'hour')} ${unit(minutes, 'minute')}` : unit(hours, 'hour');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* When a trend bucket starts. Day, week, and month buckets are named by their
|
|
89
|
+
* date (YYYY-MM-DD) and read as calendar dates at UTC midnight; hour buckets
|
|
90
|
+
* ("YYYY-MM-DD HH:00") are instants in the server's clock, which is UTC.
|
|
91
|
+
* @param {string} period
|
|
92
|
+
* @param {'hour'|'day'|'week'|'month'} bucket
|
|
93
|
+
* @returns {Date}
|
|
94
|
+
*/
|
|
95
|
+
export function periodStart(period, bucket) {
|
|
96
|
+
return bucket === 'hour'
|
|
97
|
+
? new Date(`${String(period).replace(' ', 'T')}:00Z`)
|
|
98
|
+
: new Date(`${period}T00:00:00Z`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A trend bucket label. Calendar buckets are formatted in UTC so a day never
|
|
103
|
+
* shifts across time zones; an hour is an instant, shown in local time.
|
|
104
|
+
* @param {string} period
|
|
105
|
+
* @param {'hour'|'day'|'week'|'month'} bucket
|
|
106
|
+
* @param {{ long?: boolean }} [options] long adds the date to an hour, the year
|
|
107
|
+
* to a day, and "week of" to a week
|
|
108
|
+
*/
|
|
109
|
+
export function formatPeriod(period, bucket, { long = false } = {}) {
|
|
110
|
+
const date = periodStart(period, bucket);
|
|
111
|
+
if (Number.isNaN(date.getTime())) return period;
|
|
112
|
+
const locale = currentLocale();
|
|
113
|
+
if (bucket === 'hour') {
|
|
114
|
+
return new Intl.DateTimeFormat(locale, {
|
|
115
|
+
...(long ? { day: 'numeric', month: 'short' } : {}), hour: 'numeric', minute: '2-digit',
|
|
116
|
+
}).format(date);
|
|
117
|
+
}
|
|
118
|
+
if (bucket === 'month') {
|
|
119
|
+
return new Intl.DateTimeFormat(locale, { month: long ? 'long' : 'short', year: 'numeric', timeZone: 'UTC' }).format(date);
|
|
120
|
+
}
|
|
121
|
+
const day = new Intl.DateTimeFormat(locale, {
|
|
122
|
+
day: 'numeric', month: 'short', ...(long ? { year: 'numeric' } : {}), timeZone: 'UTC',
|
|
123
|
+
}).format(date);
|
|
124
|
+
return bucket === 'week' && long ? t('charts.weekOf', { date: day }) : day;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A value for display, with a translated placeholder for empty. */
|
|
128
|
+
export function orNone(value) {
|
|
129
|
+
return value === null || value === undefined || value === '' ? t('common.none') : String(value);
|
|
130
|
+
}
|
package/admin/js/i18n.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translation layer (FRONTEND.md §8).
|
|
3
|
+
*
|
|
4
|
+
* Every user-visible string is a key resolved here. One locale ships today;
|
|
5
|
+
* adding another is a new file in locales/ with the same key set, which
|
|
6
|
+
* tests/adminUiContract.test.js enforces.
|
|
7
|
+
*
|
|
8
|
+
* Keys are dotted paths into the locale JSON. `{name}` placeholders are
|
|
9
|
+
* interpolated. When params include `count` and the key names an object with
|
|
10
|
+
* plural categories (`one`, `other`, ...), the category is chosen with
|
|
11
|
+
* Intl.PluralRules for the active locale.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { DEFAULT_LOCALE } from './constants.js';
|
|
15
|
+
|
|
16
|
+
let messages = {};
|
|
17
|
+
let locale = DEFAULT_LOCALE;
|
|
18
|
+
let pluralRules = new Intl.PluralRules(DEFAULT_LOCALE);
|
|
19
|
+
|
|
20
|
+
/** Load a locale bundle. Called once at startup. */
|
|
21
|
+
export async function loadLocale(code = DEFAULT_LOCALE) {
|
|
22
|
+
const response = await fetch(`locales/${code}.json`, { credentials: 'same-origin' });
|
|
23
|
+
messages = await response.json();
|
|
24
|
+
locale = code;
|
|
25
|
+
pluralRules = new Intl.PluralRules(code);
|
|
26
|
+
document.documentElement.lang = code;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function currentLocale() {
|
|
30
|
+
return locale;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function lookup(key) {
|
|
34
|
+
return key.split('.').reduce((node, part) => (node && typeof node === 'object' ? node[part] : undefined), messages);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve a key.
|
|
39
|
+
* @param {string} key
|
|
40
|
+
* @param {Record<string, string|number>} [params]
|
|
41
|
+
* @returns {string} the translation, or the key itself if missing, so a gap
|
|
42
|
+
* is visible on screen rather than silently blank
|
|
43
|
+
*/
|
|
44
|
+
export function t(key, params = {}) {
|
|
45
|
+
let entry = lookup(key);
|
|
46
|
+
if (entry && typeof entry === 'object' && typeof params.count === 'number') {
|
|
47
|
+
entry = entry[pluralRules.select(params.count)] ?? entry.other;
|
|
48
|
+
}
|
|
49
|
+
if (typeof entry !== 'string') return key;
|
|
50
|
+
return entry.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve a key that may legitimately be absent, such as a device size or
|
|
55
|
+
* source type defined in the server's config rather than in this bundle, and
|
|
56
|
+
* fall back to the raw value instead of showing the key.
|
|
57
|
+
* @param {string} key
|
|
58
|
+
* @param {string} fallback
|
|
59
|
+
*/
|
|
60
|
+
export function tOr(key, fallback, params = {}) {
|
|
61
|
+
const result = t(key, params);
|
|
62
|
+
return result === key ? fallback : result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Translate static markup: `data-i18n` sets text, `data-i18n-attr` sets
|
|
67
|
+
* attributes as `attr:key;attr:key`.
|
|
68
|
+
* @param {ParentNode} root
|
|
69
|
+
*/
|
|
70
|
+
export function applyTranslations(root = document) {
|
|
71
|
+
for (const node of root.querySelectorAll('[data-i18n]')) {
|
|
72
|
+
node.textContent = t(node.dataset.i18n);
|
|
73
|
+
}
|
|
74
|
+
for (const node of root.querySelectorAll('[data-i18n-attr]')) {
|
|
75
|
+
for (const pair of node.dataset.i18nAttr.split(';')) {
|
|
76
|
+
const [attr, key] = pair.split(':').map((part) => part.trim());
|
|
77
|
+
if (attr && key) node.setAttribute(attr, t(key));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The UI's icons: small line drawings on a 24-unit grid, built as SVG
|
|
3
|
+
* elements (never parsed from markup), stroked in the text colour around
|
|
4
|
+
* them. An icon is always decoration: the control it sits in carries the
|
|
5
|
+
* accessible name.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Each icon is a list of [tag, attributes]. Paths are open line drawings;
|
|
12
|
+
* `fill: true` marks the few shapes that are solid.
|
|
13
|
+
*/
|
|
14
|
+
const ICONS = {
|
|
15
|
+
overview: [
|
|
16
|
+
['rect', { x: 3, y: 3, width: 7, height: 9, rx: 1.5 }],
|
|
17
|
+
['rect', { x: 14, y: 3, width: 7, height: 5, rx: 1.5 }],
|
|
18
|
+
['rect', { x: 14, y: 12, width: 7, height: 9, rx: 1.5 }],
|
|
19
|
+
['rect', { x: 3, y: 16, width: 7, height: 5, rx: 1.5 }],
|
|
20
|
+
],
|
|
21
|
+
views: [
|
|
22
|
+
['rect', { x: 3, y: 4, width: 18, height: 16, rx: 2 }],
|
|
23
|
+
['path', { d: 'M3 9.5h18M3 15h18M9 9.5V20' }],
|
|
24
|
+
],
|
|
25
|
+
trash: [
|
|
26
|
+
['path', { d: 'M4 7h16M10 11v6M14 11v6M9 7V4.5h6V7M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12' }],
|
|
27
|
+
],
|
|
28
|
+
trackingLog: [
|
|
29
|
+
['path', { d: 'M3 12h4l3-7 4 14 3-7h4' }],
|
|
30
|
+
],
|
|
31
|
+
adminLog: [
|
|
32
|
+
['path', { d: 'M12 3l7 3v5c0 4.6-3 8.4-7 10-4-1.6-7-5.4-7-10V6z' }],
|
|
33
|
+
['path', { d: 'M9 12l2 2 4-4' }],
|
|
34
|
+
],
|
|
35
|
+
sun: [
|
|
36
|
+
['circle', { cx: 12, cy: 12, r: 4 }],
|
|
37
|
+
['path', { d: 'M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.3 5.3l1.4 1.4M17.3 17.3l1.4 1.4M5.3 18.7l1.4-1.4M17.3 6.7l1.4-1.4' }],
|
|
38
|
+
],
|
|
39
|
+
moon: [
|
|
40
|
+
['path', { d: 'M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z' }],
|
|
41
|
+
],
|
|
42
|
+
auto: [
|
|
43
|
+
['circle', { cx: 12, cy: 12, r: 8.5 }],
|
|
44
|
+
['path', { d: 'M12 3.5a8.5 8.5 0 0 1 0 17z', fill: true }],
|
|
45
|
+
],
|
|
46
|
+
signOut: [
|
|
47
|
+
['path', { d: 'M9 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h3M15 16.5l4.5-4.5L15 7.5M19.5 12H9' }],
|
|
48
|
+
],
|
|
49
|
+
external: [
|
|
50
|
+
['path', { d: 'M14 4h6v6M20 4l-9 9M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4' }],
|
|
51
|
+
],
|
|
52
|
+
globe: [
|
|
53
|
+
['circle', { cx: 12, cy: 12, r: 9 }],
|
|
54
|
+
['path', { d: 'M3 12h18M12 3c2.5 2.6 3.8 5.6 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3z' }],
|
|
55
|
+
],
|
|
56
|
+
book: [
|
|
57
|
+
['path', { d: 'M5 5.5A2.5 2.5 0 0 1 7.5 3H19v15H7.5A2.5 2.5 0 0 0 5 20.5zM5 20.5A2.5 2.5 0 0 0 7.5 21H19v-3' }],
|
|
58
|
+
],
|
|
59
|
+
code: [
|
|
60
|
+
['path', { d: 'M8.5 7L3.5 12l5 5M15.5 7l5 5-5 5' }],
|
|
61
|
+
],
|
|
62
|
+
package: [
|
|
63
|
+
['path', { d: 'M12 3l8 4.5v9L12 21l-8-4.5v-9zM4 7.5l8 4.5 8-4.5M12 12v9' }],
|
|
64
|
+
],
|
|
65
|
+
info: [
|
|
66
|
+
['circle', { cx: 12, cy: 12, r: 9 }],
|
|
67
|
+
['path', { d: 'M12 11v5.5M12 7.6v.1' }],
|
|
68
|
+
],
|
|
69
|
+
edit: [
|
|
70
|
+
['path', { d: 'M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4' }],
|
|
71
|
+
],
|
|
72
|
+
note: [
|
|
73
|
+
['path', { d: 'M5 4h14v10.5L14.5 19H5zM14 19v-4.5h5M8.5 8.5h7M8.5 12h4' }],
|
|
74
|
+
],
|
|
75
|
+
restore: [
|
|
76
|
+
['path', { d: 'M3.5 12a8.5 8.5 0 1 0 2.6-6.1M3.5 4v4.5H8' }],
|
|
77
|
+
],
|
|
78
|
+
purge: [
|
|
79
|
+
['circle', { cx: 12, cy: 12, r: 9 }],
|
|
80
|
+
['path', { d: 'M9 9l6 6M15 9l-6 6' }],
|
|
81
|
+
],
|
|
82
|
+
chevronDown: [
|
|
83
|
+
['path', { d: 'M6 9l6 6 6-6' }],
|
|
84
|
+
],
|
|
85
|
+
chevronRight: [
|
|
86
|
+
['path', { d: 'M9 6l6 6-6 6' }],
|
|
87
|
+
],
|
|
88
|
+
columns: [
|
|
89
|
+
['rect', { x: 3, y: 4, width: 18, height: 16, rx: 2 }],
|
|
90
|
+
['path', { d: 'M9 4v16M15 4v16' }],
|
|
91
|
+
],
|
|
92
|
+
arrowUp: [
|
|
93
|
+
['path', { d: 'M12 19V5M6 11l6-6 6 6' }],
|
|
94
|
+
],
|
|
95
|
+
arrowDown: [
|
|
96
|
+
['path', { d: 'M12 5v14M6 13l6 6 6-6' }],
|
|
97
|
+
],
|
|
98
|
+
sort: [
|
|
99
|
+
['path', { d: 'M8 5v14M4.5 8.5L8 5l3.5 3.5M16 19V5M12.5 15.5L16 19l3.5-3.5' }],
|
|
100
|
+
],
|
|
101
|
+
table: [
|
|
102
|
+
['rect', { x: 3, y: 4, width: 18, height: 16, rx: 2 }],
|
|
103
|
+
['path', { d: 'M3 9.5h18M3 14.8h18M10 9.5V20' }],
|
|
104
|
+
],
|
|
105
|
+
chart: [
|
|
106
|
+
['path', { d: 'M4 4v16h16M7.5 15l4-4.5 3 3L20 7' }],
|
|
107
|
+
],
|
|
108
|
+
refresh: [
|
|
109
|
+
['path', { d: 'M20 12a8 8 0 1 1-2.4-5.7M20 4v5h-5' }],
|
|
110
|
+
],
|
|
111
|
+
close: [
|
|
112
|
+
['path', { d: 'M6 6l12 12M18 6L6 18' }],
|
|
113
|
+
],
|
|
114
|
+
filter: [
|
|
115
|
+
['path', { d: 'M4 5h16l-6.2 7.2V18l-3.6 2v-7.8z' }],
|
|
116
|
+
],
|
|
117
|
+
arrowRight: [
|
|
118
|
+
['path', { d: 'M5 12h14M13 6l6 6-6 6' }],
|
|
119
|
+
],
|
|
120
|
+
pause: [
|
|
121
|
+
['path', { d: 'M9 5.5v13M15 5.5v13' }],
|
|
122
|
+
],
|
|
123
|
+
play: [
|
|
124
|
+
['path', { d: 'M7.5 5.5l11 6.5-11 6.5z', fill: true }],
|
|
125
|
+
],
|
|
126
|
+
clock: [
|
|
127
|
+
['circle', { cx: 12, cy: 12, r: 9 }],
|
|
128
|
+
['path', { d: 'M12 7v5l3 2' }],
|
|
129
|
+
],
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Every icon name, for tests and for callers that pick one by key. */
|
|
133
|
+
export const ICON_NAMES = Object.freeze(Object.keys(ICONS));
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Build an icon.
|
|
137
|
+
* @param {string} name a key of ICONS
|
|
138
|
+
* @param {{ className?: string }} [options]
|
|
139
|
+
* @returns {SVGSVGElement}
|
|
140
|
+
*/
|
|
141
|
+
export function icon(name, { className = '' } = {}) {
|
|
142
|
+
const shapes = ICONS[name];
|
|
143
|
+
if (!shapes) throw new Error(`Unknown icon: ${name}`);
|
|
144
|
+
const root = document.createElementNS(SVG_NS, 'svg');
|
|
145
|
+
const attrs = {
|
|
146
|
+
class: `icon icon-${name} ${className}`.trim(),
|
|
147
|
+
viewBox: '0 0 24 24',
|
|
148
|
+
width: '16',
|
|
149
|
+
height: '16',
|
|
150
|
+
fill: 'none',
|
|
151
|
+
stroke: 'currentColor',
|
|
152
|
+
'stroke-width': '1.8',
|
|
153
|
+
'stroke-linecap': 'round',
|
|
154
|
+
'stroke-linejoin': 'round',
|
|
155
|
+
'aria-hidden': 'true',
|
|
156
|
+
focusable: 'false',
|
|
157
|
+
};
|
|
158
|
+
for (const [key, value] of Object.entries(attrs)) root.setAttribute(key, value);
|
|
159
|
+
for (const [tag, shapeAttrs] of shapes) {
|
|
160
|
+
const shape = document.createElementNS(SVG_NS, tag);
|
|
161
|
+
for (const [key, value] of Object.entries(shapeAttrs)) {
|
|
162
|
+
if (key === 'fill') shape.setAttribute('fill', 'currentColor');
|
|
163
|
+
else shape.setAttribute(key, String(value));
|
|
164
|
+
}
|
|
165
|
+
root.append(shape);
|
|
166
|
+
}
|
|
167
|
+
return root;
|
|
168
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A select replacement (FRONTEND.md §0, §7): a button that opens a listbox the
|
|
3
|
+
* page renders itself, so it can be styled and behaves identically on every
|
|
4
|
+
* machine.
|
|
5
|
+
*
|
|
6
|
+
* ARIA listbox pattern: the button has aria-haspopup/aria-expanded; the
|
|
7
|
+
* options carry role=option and aria-selected; the active option is announced
|
|
8
|
+
* through aria-activedescendant. Keyboard: ArrowUp/ArrowDown move, Home/End
|
|
9
|
+
* jump, Enter/Space commit, Escape and Tab close.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { el, clear, uniqueId } from './dom.js';
|
|
13
|
+
import { icon } from './icons.js';
|
|
14
|
+
import { KEY } from './constants.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {{ label: string, options: Array<{value: string, label: string}>, value?: string,
|
|
18
|
+
* onChange?: (value: string) => void, className?: string }} config
|
|
19
|
+
* @returns {{ element: HTMLElement, getValue: () => string, setValue: (value: string) => void,
|
|
20
|
+
* setOptions: (options: Array<{value: string, label: string}>, value?: string) => void }}
|
|
21
|
+
*/
|
|
22
|
+
export function createListbox({ label, options, value, onChange = () => {}, className = '' }) {
|
|
23
|
+
const listId = uniqueId('listbox');
|
|
24
|
+
let items = options;
|
|
25
|
+
let selected = value ?? options[0]?.value;
|
|
26
|
+
let activeIndex = -1;
|
|
27
|
+
|
|
28
|
+
const valueText = el('span', { className: 'listbox-value' });
|
|
29
|
+
const button = el('button', {
|
|
30
|
+
className: 'listbox-button',
|
|
31
|
+
attrs: { type: 'button', 'aria-haspopup': 'listbox', 'aria-expanded': 'false', 'aria-label': label },
|
|
32
|
+
}, [valueText, icon('chevronDown', { className: 'listbox-caret' })]);
|
|
33
|
+
|
|
34
|
+
const list = el('ul', {
|
|
35
|
+
className: 'listbox-popup',
|
|
36
|
+
attrs: { role: 'listbox', id: listId, tabindex: '-1', 'aria-label': label },
|
|
37
|
+
});
|
|
38
|
+
list.hidden = true;
|
|
39
|
+
button.setAttribute('aria-controls', listId);
|
|
40
|
+
|
|
41
|
+
const wrapper = el('div', { className: `listbox ${className}`.trim() }, [button, list]);
|
|
42
|
+
|
|
43
|
+
const labelOf = (v) => items.find((item) => item.value === v)?.label ?? '';
|
|
44
|
+
|
|
45
|
+
function render() {
|
|
46
|
+
valueText.textContent = labelOf(selected);
|
|
47
|
+
clear(list);
|
|
48
|
+
items.forEach((item, index) => {
|
|
49
|
+
const isSelected = item.value === selected;
|
|
50
|
+
list.append(el('li', {
|
|
51
|
+
className: `listbox-option${index === activeIndex ? ' active' : ''}`,
|
|
52
|
+
text: item.label,
|
|
53
|
+
attrs: { role: 'option', id: `${listId}-${index}`, 'aria-selected': String(isSelected) },
|
|
54
|
+
on: {
|
|
55
|
+
mousedown: (event) => event.preventDefault(),
|
|
56
|
+
click: () => commit(index),
|
|
57
|
+
},
|
|
58
|
+
}));
|
|
59
|
+
});
|
|
60
|
+
if (activeIndex >= 0) list.setAttribute('aria-activedescendant', `${listId}-${activeIndex}`);
|
|
61
|
+
else list.removeAttribute('aria-activedescendant');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isOpen() {
|
|
65
|
+
return !list.hidden;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function open() {
|
|
69
|
+
if (isOpen()) return;
|
|
70
|
+
activeIndex = Math.max(0, items.findIndex((item) => item.value === selected));
|
|
71
|
+
list.hidden = false;
|
|
72
|
+
button.setAttribute('aria-expanded', 'true');
|
|
73
|
+
render();
|
|
74
|
+
list.focus();
|
|
75
|
+
list.querySelector('.active')?.scrollIntoView({ block: 'nearest' });
|
|
76
|
+
document.addEventListener('mousedown', onOutside, true);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function close({ refocus = true } = {}) {
|
|
80
|
+
if (!isOpen()) return;
|
|
81
|
+
list.hidden = true;
|
|
82
|
+
button.setAttribute('aria-expanded', 'false');
|
|
83
|
+
activeIndex = -1;
|
|
84
|
+
document.removeEventListener('mousedown', onOutside, true);
|
|
85
|
+
if (refocus) button.focus();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function commit(index) {
|
|
89
|
+
const item = items[index];
|
|
90
|
+
if (!item) return;
|
|
91
|
+
const changed = item.value !== selected;
|
|
92
|
+
selected = item.value;
|
|
93
|
+
close();
|
|
94
|
+
render();
|
|
95
|
+
if (changed) onChange(selected);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function move(index) {
|
|
99
|
+
activeIndex = Math.min(Math.max(index, 0), items.length - 1);
|
|
100
|
+
render();
|
|
101
|
+
list.querySelector('.active')?.scrollIntoView({ block: 'nearest' });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function onOutside(event) {
|
|
105
|
+
if (!wrapper.contains(event.target)) close({ refocus: false });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
button.addEventListener('click', () => (isOpen() ? close() : open()));
|
|
109
|
+
button.addEventListener('keydown', (event) => {
|
|
110
|
+
if (event.key === KEY.ARROW_DOWN || event.key === KEY.ARROW_UP) {
|
|
111
|
+
event.preventDefault();
|
|
112
|
+
open();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
list.addEventListener('keydown', (event) => {
|
|
117
|
+
switch (event.key) {
|
|
118
|
+
case KEY.ARROW_DOWN: event.preventDefault(); move(activeIndex + 1); break;
|
|
119
|
+
case KEY.ARROW_UP: event.preventDefault(); move(activeIndex - 1); break;
|
|
120
|
+
case KEY.HOME: event.preventDefault(); move(0); break;
|
|
121
|
+
case KEY.END: event.preventDefault(); move(items.length - 1); break;
|
|
122
|
+
case KEY.ENTER:
|
|
123
|
+
case KEY.SPACE: event.preventDefault(); commit(activeIndex); break;
|
|
124
|
+
case KEY.ESCAPE: event.preventDefault(); event.stopPropagation(); close(); break;
|
|
125
|
+
case KEY.TAB: close({ refocus: false }); break;
|
|
126
|
+
default: break;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
render();
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
element: wrapper,
|
|
134
|
+
getValue: () => selected,
|
|
135
|
+
setValue(next) {
|
|
136
|
+
selected = next;
|
|
137
|
+
render();
|
|
138
|
+
},
|
|
139
|
+
setOptions(nextOptions, nextValue) {
|
|
140
|
+
items = nextOptions;
|
|
141
|
+
selected = nextValue ?? (items.some((item) => item.value === selected) ? selected : items[0]?.value);
|
|
142
|
+
render();
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|