domma-cms 0.116.0 → 0.116.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugins/_lib/mail/imapPool.js +40 -12
- package/plugins/_lib/mail/send.js +2 -1
- package/plugins/free-tier.lock.json +16 -16
- package/plugins/support/CLAUDE.md +19 -0
- package/plugins/support/admin/css/index.css +4 -0
- package/plugins/support/admin/lib/kit.js +60 -11
- package/plugins/support/admin/views/desk.js +158 -74
- package/plugins/support/admin/views/inbox.js +146 -56
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/collections/support-tickets.json +1 -0
- package/plugins/support/lib/receiver.js +2 -1
- package/plugins/support/lib/sender.js +68 -4
- package/plugins/support/lib/shape.js +10 -1
- package/plugins/support/plugin.js +38 -6
- package/plugins/support/plugin.json +2 -2
- package/plugins/support/tests/api.test.js +94 -0
- package/plugins/support/tests/shape.test.js +68 -1
package/package.json
CHANGED
|
@@ -206,7 +206,7 @@ export function createPool(config) {
|
|
|
206
206
|
} catch {
|
|
207
207
|
// Already down - nothing to close.
|
|
208
208
|
}
|
|
209
|
-
return {ok: false, error: describeError(err)};
|
|
209
|
+
return {ok: false, error: describeError(err, connection)};
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
212
|
|
|
@@ -261,19 +261,52 @@ export function createPool(config) {
|
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
/**
|
|
264
|
-
* Turn an IMAP/network error into something worth showing a user.
|
|
264
|
+
* Turn an IMAP/SMTP/network error into something worth showing a user.
|
|
265
265
|
*
|
|
266
266
|
* ImapFlow's own messages are decent but its auth failures surface as raw
|
|
267
|
-
* server responses, which vary by server and read like a stack trace.
|
|
267
|
+
* server responses, which vary by server and read like a stack trace. TLS
|
|
268
|
+
* failures are worse: OpenSSL's text ("…:tls_validate_record_header:wrong
|
|
269
|
+
* version number:…") names nothing the user can change. nodemailer also wraps
|
|
270
|
+
* every socket error as `ESOCKET`, dropping the TLS code, so those are
|
|
271
|
+
* recognised by `reason`/message as well as by code.
|
|
268
272
|
*
|
|
269
|
-
* @param {Error & {code?: string, authenticationFailed?: boolean}} err
|
|
273
|
+
* @param {Error & {code?: string, reason?: string, authenticationFailed?: boolean}} err
|
|
274
|
+
* @param {{host?: string, port?: number, secure?: boolean}} [connection] - what was tried, so the message can name it
|
|
270
275
|
* @returns {string}
|
|
271
276
|
*/
|
|
272
|
-
export function describeError(err) {
|
|
277
|
+
export function describeError(err, connection = {}) {
|
|
273
278
|
if (!err) return 'Unknown error.';
|
|
274
|
-
if (err.authenticationFailed) return 'The mail server rejected that username and password.';
|
|
279
|
+
if (err.authenticationFailed || err.code === 'EAUTH') return 'The mail server rejected that username and password.';
|
|
275
280
|
|
|
276
|
-
|
|
281
|
+
const host = connection.host || 'The mail server';
|
|
282
|
+
const port = connection.port ? `Port ${connection.port}` : 'That port';
|
|
283
|
+
const text = `${err.reason || ''} ${err.message || ''}`;
|
|
284
|
+
|
|
285
|
+
// Implicit TLS against a port that starts in plain text (587, 143, 25):
|
|
286
|
+
// the "server hello" is a greeting line, which OpenSSL reads as a bad version.
|
|
287
|
+
if (err.code === 'ERR_SSL_WRONG_VERSION_NUMBER' || /wrong version number/i.test(text)) {
|
|
288
|
+
return connection.secure
|
|
289
|
+
? `${port} does not start with TLS, so Implicit TLS must be off for it - ports 587 and 143 switch to TLS with STARTTLS. Untick Implicit TLS, or use the implicit-TLS port (465 to send, 993 to read).`
|
|
290
|
+
: `${port} answered in a different TLS mode from the one chosen. Check the port and the Implicit TLS setting.`;
|
|
291
|
+
}
|
|
292
|
+
if (['DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN'].includes(err.code) || /self[- ]signed certificate/i.test(text)) {
|
|
293
|
+
return `${host === 'The mail server' ? 'The mail server’s' : `${host}’s`} TLS certificate is self-signed, so the server cannot prove who it is. `
|
|
294
|
+
+ `Install a trusted certificate (Let’s Encrypt, for example) that covers this hostname on the mail server. `
|
|
295
|
+
+ `For a server you control, the plugin’s allowInsecureTLS setting accepts it instead.`;
|
|
296
|
+
}
|
|
297
|
+
if (err.code === 'ERR_TLS_CERT_ALTNAME_INVALID' || /altnames|does not match certificate/i.test(text)) {
|
|
298
|
+
return `The mail server’s TLS certificate does not cover ${connection.host || 'that hostname'}. Use the hostname the certificate was issued for, or reissue it to include this one.`;
|
|
299
|
+
}
|
|
300
|
+
if (err.code === 'CERT_HAS_EXPIRED' || /certificate has expired/i.test(text)) {
|
|
301
|
+
return 'The mail server’s TLS certificate has expired. It needs renewing on the server.';
|
|
302
|
+
}
|
|
303
|
+
if (['UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY'].includes(err.code) || /unable to (verify the first certificate|get local issuer)/i.test(text)) {
|
|
304
|
+
return 'The mail server’s TLS certificate could not be verified - it is missing its intermediate certificate, or was issued by an authority this server does not trust.';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// nodemailer reports these as ESOCKET/EDNS; the system code survives only in the message.
|
|
308
|
+
const netCode = text.match(/\b(ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT|ECONNRESET)\b/)?.[1] ?? err.code;
|
|
309
|
+
switch (netCode) {
|
|
277
310
|
case 'ENOTFOUND':
|
|
278
311
|
case 'EAI_AGAIN':
|
|
279
312
|
return 'That mail server hostname could not be resolved.';
|
|
@@ -282,11 +315,6 @@ export function describeError(err) {
|
|
|
282
315
|
case 'ETIMEDOUT':
|
|
283
316
|
case 'ECONNRESET':
|
|
284
317
|
return 'The mail server did not respond in time.';
|
|
285
|
-
case 'CERT_HAS_EXPIRED':
|
|
286
|
-
case 'DEPTH_ZERO_SELF_SIGNED_CERT':
|
|
287
|
-
case 'SELF_SIGNED_CERT_IN_CHAIN':
|
|
288
|
-
case 'ERR_TLS_CERT_ALTNAME_INVALID':
|
|
289
|
-
return 'The mail server’s TLS certificate could not be verified.';
|
|
290
318
|
default:
|
|
291
319
|
return err.message || 'The mail server connection failed.';
|
|
292
320
|
}
|
|
@@ -13,6 +13,7 @@ import {createRequire} from 'node:module';
|
|
|
13
13
|
import nodemailer from 'nodemailer';
|
|
14
14
|
|
|
15
15
|
import {bareAddress, formatFrom} from '../admin/mail/identity.js';
|
|
16
|
+
import {describeError} from './imapPool.js';
|
|
16
17
|
import {normalisePriority, priorityHeaders} from './priority.js';
|
|
17
18
|
|
|
18
19
|
// MailComposer is not on nodemailer's public export, but it is the piece that
|
|
@@ -389,7 +390,7 @@ export async function verifySmtp(transportConfig, allowInsecureTLS = false) {
|
|
|
389
390
|
await transport.verify();
|
|
390
391
|
return {ok: true};
|
|
391
392
|
} catch (err) {
|
|
392
|
-
return {ok: false, error: err
|
|
393
|
+
return {ok: false, error: describeError(err, transportConfig)};
|
|
393
394
|
} finally {
|
|
394
395
|
transport.close();
|
|
395
396
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
|
|
3
|
-
"source": "dcms-marketplace@
|
|
3
|
+
"source": "dcms-marketplace@149aff8",
|
|
4
4
|
"plugins": {
|
|
5
5
|
"blog": {
|
|
6
6
|
"version": "1.9.4",
|
|
@@ -224,25 +224,25 @@
|
|
|
224
224
|
}
|
|
225
225
|
},
|
|
226
226
|
"support": {
|
|
227
|
-
"version": "1.0
|
|
227
|
+
"version": "1.1.0",
|
|
228
228
|
"files": {
|
|
229
|
-
"CLAUDE.md": "
|
|
230
|
-
"admin/css/index.css": "
|
|
231
|
-
"admin/lib/kit.js": "
|
|
229
|
+
"CLAUDE.md": "ff42515f210dfdbeb1b14992012177ad8513323a340a0d4fe69a6ec13773db5a",
|
|
230
|
+
"admin/css/index.css": "bad02a487ebb131b64ee55e2a4533c8b79d6ada80fffd34b0952bf3b68204a36",
|
|
231
|
+
"admin/lib/kit.js": "22f02768625bcad9d9c8e64bf0a8fe95f7c5ff6dcf8abb189df8203362a7a4e1",
|
|
232
232
|
"admin/templates/support.html": "2bb69d0443a367a402e6160fe68f4af3e8983b578cfff9575b4df9a534fcb480",
|
|
233
|
-
"admin/views/desk.js": "
|
|
234
|
-
"admin/views/inbox.js": "
|
|
235
|
-
"admin/views/support.js": "
|
|
236
|
-
"collections/support-tickets.json": "
|
|
233
|
+
"admin/views/desk.js": "ac043630ad08c8f2053f3f908be18586d9a4798976d20427cf5215ee042c807c",
|
|
234
|
+
"admin/views/inbox.js": "f570595d7b2c831d151e0aaf85ff903e6e2b7066124b3f9d23c2c24f5b1734cf",
|
|
235
|
+
"admin/views/support.js": "360a50ff54b81f93fcd6fb6949710e0cff5d342bb8573408415ef3faf3fd29ff",
|
|
236
|
+
"collections/support-tickets.json": "55a0856bacd66c2e8f1e3c85bdf3638e5ff826560943cda854a8f2d336a88905",
|
|
237
237
|
"config.js": "eb7ccc1169fa8b2be52a213536825492fedaec5fa73fd749cba9f498dd54b503",
|
|
238
238
|
"lib/mail.js": "d14aae3a59aad6af61451863cdaaf94fc4a87810b16e1dcb9c655dda6c803b3b",
|
|
239
|
-
"lib/receiver.js": "
|
|
240
|
-
"lib/sender.js": "
|
|
241
|
-
"lib/shape.js": "
|
|
242
|
-
"plugin.js": "
|
|
243
|
-
"plugin.json": "
|
|
244
|
-
"tests/api.test.js": "
|
|
245
|
-
"tests/shape.test.js": "
|
|
239
|
+
"lib/receiver.js": "b95c569932c261a48849f0a6b7005f649d9c2a50d319b098a47d02da9363050c",
|
|
240
|
+
"lib/sender.js": "b20aaee34ade89814a897f68467f9902693843fa6b5c8eb0090c4059193c8227",
|
|
241
|
+
"lib/shape.js": "04d840b589d20700ccb2cfa4c72072f8d9f8e03f0573e2ea1177a7a83a383bea",
|
|
242
|
+
"plugin.js": "e3c50a05fa99cb24375782bbb3ca8c58cf5ddc3036937e6115f4d963715c120e",
|
|
243
|
+
"plugin.json": "e6efaf06f7f2c99b196909e56f0a0b6d65b438ffd6d616aa4207769861f9bd66",
|
|
244
|
+
"tests/api.test.js": "1fa87f6ae12c48fc34a5fb2b5bbeed6cfaa36329610674d4fae05d64734dbb15",
|
|
245
|
+
"tests/shape.test.js": "364c6b7680699f59fcd449cd448184486488838300f155135daea4c05387cf74"
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
}
|
|
@@ -39,6 +39,25 @@ registration by where it runs.
|
|
|
39
39
|
- **The CMS version comes from the manager's registry** (`sites.json` `cmsVersion`), not the payload: a
|
|
40
40
|
child site's package.json is the manager's, so its own answer is wrong.
|
|
41
41
|
|
|
42
|
+
## Archive, remove and the row menus (1.1.0)
|
|
43
|
+
|
|
44
|
+
Both screens are domma-reactive throughout (lists AND the request panel: the thread is a `data-each` over
|
|
45
|
+
`threadRows()`, status/urgent/archived are computeds over the list's copy, so a move from a row menu shows in
|
|
46
|
+
an open panel at once). Every row has ONE menu definition (`menuItems`) reached by right-click, the ⋮ kebab
|
|
47
|
+
and Shift+F10 (`rowKeys()` in kit.js); click/Enter opens. The panel's thread has its own menu (copy; on the
|
|
48
|
+
manager also "Quote in reply").
|
|
49
|
+
|
|
50
|
+
- **Manager archive** = `data.archived` (PUT `/tickets/:id {archived}`), Domma's own filing: the site never
|
|
51
|
+
sees it, `/count` ignores archived requests, and `addTurn()` takes a request OUT of the archive when the
|
|
52
|
+
customer writes again (a close does not).
|
|
53
|
+
- **Site shelf** = the outbox's `shelf` (`{ref: {state: archived|removed, dommaAt}}`), this site's own tidying
|
|
54
|
+
- Domma keeps every request. `applyShelf()` (pure) marks archived and drops removed, and either comes back
|
|
55
|
+
the moment `lastDommaAt` moves past the recorded `dommaAt`: a reply is never hidden. `remember()` prunes
|
|
56
|
+
entries Domma deleted or has written to since. Shelving marks the reply read. Removing a request that
|
|
57
|
+
never reached Domma (`discard()`) cancels it - it and its follow-ups leave the outbox. Writing to an
|
|
58
|
+
archived request unshelves it.
|
|
59
|
+
- Manager **Delete** stays a real delete (with `deleted.json`); the confirm suggests archiving instead.
|
|
60
|
+
|
|
42
61
|
## Files
|
|
43
62
|
|
|
44
63
|
- `plugin.js` - both route sets (see its header); `options.mode` / `mail` / `fetch` / `env` /
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
.sp-seg button { border: 1px solid var(--dm-border); background: none; color: var(--dm-text-secondary); border-radius: var(--dm-radius-full);
|
|
37
37
|
padding: .2rem .7rem; font-size: var(--dm-font-size-sm); cursor: pointer; }
|
|
38
38
|
.sp-seg button b { font-weight: 600; margin-left: .2rem; }
|
|
39
|
+
.sp-seg button:focus-visible { outline: 2px solid var(--dm-primary); outline-offset: 1px; }
|
|
39
40
|
.sp-seg button.is-on { background: var(--dm-primary-light); color: var(--dm-text); border-color: var(--dm-primary); }
|
|
40
41
|
.sp-search { flex: 1; min-width: 12rem; max-width: 20rem; }
|
|
41
42
|
|
|
@@ -48,6 +49,8 @@
|
|
|
48
49
|
.sp-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
49
50
|
.sp-main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
50
51
|
.sp-main small { color: var(--dm-text-secondary); font-size: var(--dm-font-size-xs); }
|
|
52
|
+
.sp-kebab { border: 0; background: none; color: var(--dm-text-secondary); cursor: pointer; font-size: 1.1rem; line-height: 1; padding: .2rem .4rem; border-radius: var(--dm-radius-md); flex: none; }
|
|
53
|
+
.sp-kebab:hover, .sp-kebab:focus-visible { background: var(--dm-hover-bg); color: var(--dm-text); outline: none; }
|
|
51
54
|
.sp-dot { width: .55rem; height: .55rem; border-radius: 50%; background: var(--dm-primary); flex: none; }
|
|
52
55
|
.sp-urgent { font-size: var(--dm-font-size-xs); font-weight: 700; color: var(--dm-danger); text-transform: uppercase; letter-spacing: .03em; }
|
|
53
56
|
.sp-chip { font-size: var(--dm-font-size-xs); font-weight: 600; padding: .12rem .55rem; border-radius: var(--dm-radius-full); white-space: nowrap;
|
|
@@ -55,6 +58,7 @@
|
|
|
55
58
|
.sp-chip.is-info { background: color-mix(in srgb, var(--dm-info) 18%, transparent); }
|
|
56
59
|
.sp-chip.is-warning { background: color-mix(in srgb, var(--dm-warning) 22%, transparent); }
|
|
57
60
|
.sp-chip.is-success { background: color-mix(in srgb, var(--dm-success) 20%, transparent); }
|
|
61
|
+
.sp-chip.is-muted { color: var(--dm-text-secondary); }
|
|
58
62
|
.sp-chip.is-bad { background: color-mix(in srgb, var(--dm-danger) 20%, transparent); }
|
|
59
63
|
|
|
60
64
|
/* Forms */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Support admin - what both screens share: the admin's helpers (loaded at run
|
|
3
3
|
* time; a plugin never imports admin/ statically - the updater replaces it),
|
|
4
|
-
* the API, escaping, dates, slideovers and the conversation thread.
|
|
4
|
+
* the API, escaping, dates, slideovers, the row menus and the conversation thread.
|
|
5
5
|
*
|
|
6
6
|
* @module support/admin/lib/kit
|
|
7
7
|
*/
|
|
@@ -21,6 +21,10 @@ export function loadKit() {
|
|
|
21
21
|
import('/admin/js/lib/help-popover.js').catch(() => ({}))
|
|
22
22
|
]).then(([tk, help]) => ({
|
|
23
23
|
onceClosed: tk.onceClosed || ((so, fn) => { const c = so.close.bind(so); so.close = (...a) => { const r = c(...a); fn?.(); return r; }; }),
|
|
24
|
+
openMenuAt: tk.openMenuAt || ((el) => {
|
|
25
|
+
const r = el.getBoundingClientRect();
|
|
26
|
+
el.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true, clientX: r.left, clientY: r.bottom, view: window}));
|
|
27
|
+
}),
|
|
24
28
|
viewLifetime: tk.viewLifetime || (() => ({add() {}, reset() {}})),
|
|
25
29
|
helpPopovers: help.helpPopovers || (() => () => {})
|
|
26
30
|
}));
|
|
@@ -38,6 +42,8 @@ export const api = {
|
|
|
38
42
|
write: (ref, body) => H.post(`${BASE}/tickets/${seg(ref)}/messages`, {body}),
|
|
39
43
|
close: (ref) => H.post(`${BASE}/tickets/${seg(ref)}/close`, {}),
|
|
40
44
|
seen: (ref, at) => H.put(`${BASE}/tickets/${seg(ref)}/seen`, {at}),
|
|
45
|
+
/** `at`: Domma's latest reply as the screen showed it (omitted for an offline copy, which has none). */
|
|
46
|
+
shelf: (ref, state, at) => H.put(`${BASE}/tickets/${seg(ref)}/shelf`, {state, ...(at !== undefined && {at})}),
|
|
41
47
|
// manager
|
|
42
48
|
ticket: (id) => H.get(`${BASE}/tickets/${seg(id)}`),
|
|
43
49
|
reply: (id, d) => H.post(`${BASE}/tickets/${seg(id)}/reply`, d),
|
|
@@ -88,17 +94,60 @@ export const telHref = (phone) => `tel:${String(phone || '').replace(/[^\d+]/g,
|
|
|
88
94
|
export const TONE = {open: 'warning', answered: 'info', closed: 'success', waiting: 'warning', refused: 'bad'};
|
|
89
95
|
|
|
90
96
|
/**
|
|
91
|
-
* The conversation, oldest first, as
|
|
92
|
-
*
|
|
97
|
+
* The conversation, oldest first, as rows for THREAD. `mine` says which side is
|
|
98
|
+
* "us" (customer on a site, domma on the manager).
|
|
93
99
|
*/
|
|
94
|
-
export function
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
export function threadRows(messages = [], mine) {
|
|
101
|
+
return messages.map((m, i) => ({
|
|
102
|
+
key: m.id || `m${i}`,
|
|
103
|
+
cls: `${m.from === mine ? 'is-mine' : 'is-theirs'}${m.from === 'domma' ? ' is-domma' : ''}`,
|
|
104
|
+
who: m.from === 'domma' ? `${m.name || 'Domma'} · Domma Support` : (m.name || 'Customer'),
|
|
105
|
+
when: `${stamp(m.at)}${m.pending ? ' · waiting to send' : ''}${m.refused ? ' · not accepted' : ''}`,
|
|
106
|
+
body: m.body || ''
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The thread, bound to a `thread` observable of threadRows(). Text bindings only: nothing is parsed as HTML. */
|
|
111
|
+
export const THREAD = `
|
|
112
|
+
<p class="sp-note" data-bind-hidden="thread.value.length">Nothing here yet.</p>
|
|
113
|
+
<ol class="sp-thread" data-each="thread.value key=key"><li class="sp-msg" data-bind-class="cls" data-key="{{key}}" tabindex="-1">
|
|
114
|
+
<div class="sp-msg-head"><strong data-bind-text="who"></strong><small data-bind-text="when"></small></div>
|
|
115
|
+
<div class="sp-msg-body" data-bind-text="body"></div>
|
|
116
|
+
</li></ol>`;
|
|
117
|
+
|
|
118
|
+
/** Copy text, with a toast either way. */
|
|
119
|
+
export async function copyText(text, what = 'Copied') {
|
|
120
|
+
try {
|
|
121
|
+
await navigator.clipboard.writeText(String(text ?? ''));
|
|
122
|
+
E.toast(`${what}.`, {type: 'success'});
|
|
123
|
+
} catch {
|
|
124
|
+
E.toast('Your browser would not let us copy that.', {type: 'warning'});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The one way into a list's row menu besides right-click: the row's ⋮ button
|
|
130
|
+
* and Shift+F10. Click (or Enter) on the row itself does `primary`.
|
|
131
|
+
*
|
|
132
|
+
* @returns {Function} removes the listeners
|
|
133
|
+
*/
|
|
134
|
+
export function rowKeys(listEl, {rowSel, kebabSel, openMenuAt, primary}) {
|
|
135
|
+
const onClick = (e) => {
|
|
136
|
+
const row = e.target.closest(rowSel);
|
|
137
|
+
if (!row) return;
|
|
138
|
+
const kebab = e.target.closest(kebabSel);
|
|
139
|
+
if (kebab) { e.stopPropagation(); openMenuAt(kebab); return; }
|
|
140
|
+
primary(row.dataset.id);
|
|
141
|
+
};
|
|
142
|
+
const onKey = (e) => {
|
|
143
|
+
const row = e.target.closest(rowSel);
|
|
144
|
+
if (!row) return;
|
|
145
|
+
if ((e.key === 'F10' && e.shiftKey) || e.key === 'ContextMenu') { e.preventDefault(); openMenuAt(row); }
|
|
146
|
+
else if ((e.key === 'Enter' || e.key === ' ') && e.target === row) { e.preventDefault(); primary(row.dataset.id); }
|
|
147
|
+
};
|
|
148
|
+
listEl.addEventListener('click', onClick);
|
|
149
|
+
listEl.addEventListener('keydown', onKey);
|
|
150
|
+
return () => { listEl.removeEventListener('click', onClick); listEl.removeEventListener('keydown', onKey); };
|
|
102
151
|
}
|
|
103
152
|
|
|
104
153
|
/**
|