@yz-social/civildefense.io 4.5.9 → 4.5.16

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/server/app.js CHANGED
@@ -18,12 +18,12 @@ const argv = yargs(hideBin(process.argv))
18
18
  .option('nPortals', {
19
19
  alias: 'p',
20
20
  type: 'number',
21
- default: Math.min(logicalCores, 5),
21
+ default: Math.max(2, logicalCores - 3),
22
22
  description: "The number of steady nodes that handle initial connections."
23
23
  })
24
24
  .option('baseURL', {
25
25
  type: 'string',
26
- default: 'http://localhost:3000/kdht',
26
+ default: 'http://localhost:3000',
27
27
  description: "The base URL of the portal server through which to bootstrap."
28
28
  })
29
29
  .option('externalBaseURL', {
@@ -36,15 +36,10 @@ const argv = yargs(hideBin(process.argv))
36
36
  default: false,
37
37
  description: "Announce our availability on the DHT, so that other nodes can enter through us if their original portal goes down."
38
38
  })
39
- .option('fixedSpacing', {
39
+ .option('spacing', {
40
40
  type: 'number',
41
- default: 2,
42
- description: "Minimum seconds to add between each portal."
43
- })
44
- .options('variableSpacing', {
45
- type: 'number',
46
- default: 5,
47
- description: "Additional variable seconds (+/- variableSpacing/2) to add to fixedSpacing between each portal."
41
+ default: 3,
42
+ description: "Minimum seconds to add between each portal on startup and shutdown."
48
43
  })
49
44
  .option('info', {
50
45
  alias: 'i',
@@ -60,6 +55,10 @@ const argv = yargs(hideBin(process.argv))
60
55
  })
61
56
  .parse();
62
57
 
58
+ const log = argv.info && ((...rest) => console.log(new Date(), ...rest));
59
+ const debug = argv.verbose && ((...rest) => console.log(new Date(), ...rest));
60
+ function delay(ms = argv.spacing * 1e3) { return new Promise(resolve => setTimeout(resolve, ms)); }
61
+
63
62
  if (cluster.isPrimary) { // Parent process with portal webserver through which clienta can bootstrap
64
63
  const port = parseInt((new URL(argv.baseURL)).port || '80');
65
64
  process.title = 'yz.social';
@@ -100,31 +99,43 @@ if (cluster.isPrimary) { // Parent process with portal webserver through which c
100
99
  }));
101
100
 
102
101
  app.listen(port);
103
- console.log(new Date(), `Listening on ${port} and starting ${argv.nPortals} nodes on ${logicalCores} ${cpus()[0].model} logical cores.`);
102
+ log(`Listening on ${port} and starting ${argv.nPortals} nodes on ${logicalCores} ${cpus()[0].model} logical cores.`);
104
103
  for (let i = 0; i < argv.nPortals; i++) {
105
104
  cluster.fork();
106
- await new Promise(resolve => setTimeout(resolve, 2e3)); // Number chosen to give an unspikey rise in packets/s.
105
+ await delay(); // Number chosen to give an unspikey rise in packets/s.
107
106
  }
107
+ log(process.title, 'RUNNING.');
108
+ process.on('SIGINT', async () => { // Leave the network politely.
109
+ const seconds = (1 + argv.nPortals) * argv.spacing;
110
+ log(process.title, 'shutting down', argv.nPortals, 'nodes.');
111
+ await delay(seconds * 1e3);
112
+ log(process.title, 'done');
113
+ process.exit(0);
114
+ });
108
115
  } else {
109
116
  process.title = 'axona-starting';
110
117
  const { P2PWebNetwork, location } = await import('../index.js');
111
118
  const network = await P2PWebNetwork.create({
112
119
  region: location,
113
- infoLogger: (...rest) => argv.info && console.log(new Date(), ...rest),
114
- debugLogger: (...rest) => argv.verbose && console.log(new Date(), ...rest)
120
+ infoLogger: log,
121
+ debugLogger: debug
115
122
  });
116
123
  process.title = 'axona-' + network.nodeIdentity.id;
117
- let update = setInterval(() => {
118
- const roles = network.peer.health().axonRoles;
119
- network.debug(roles.length, 'axons',
120
- roles.reduce((total, role) => total + (role.isRoot ? 1 : 0), 0), 'roots');
121
- }, 10e3);
124
+ let update = argv.verbose && setInterval(() => {
125
+ network.debug(network.peer.health());
126
+ }, 30e3);
122
127
  process.on('SIGINT', async () => { // Leave the network politely.
123
- console.log(process.title, 'Shutdown for Ctrl+C');
124
- clearInterval(update)
128
+ const id = cluster.worker.id;
129
+ const seconds = id * argv.spacing;
130
+ clearInterval(update);
131
+ log(process.title, 'Shutdown for Ctrl+C in', seconds, 'seconds.');
132
+ const health = network.peer.health();
133
+ const inRegionTags = network.inRegionConnections();
134
+ network.info(`Node ${id} has ${inRegionTags.length} connections in region.`);
135
+ network.info(network.ice(inRegionTags));
136
+ await delay(seconds * 1e3);
125
137
  await network.disconnect();
126
138
  process.exit(0);
127
- });
139
+ });
128
140
  await network.host();
129
-
130
141
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "uswest/80": "37.4852,-122.2364",
2
+ "uswest/80": "37,-122",
3
3
  "useast/89": "40,-75",
4
4
  "uscentle/88": "35,-83",
5
5
  "easteu/47": "50,17",
package/spec/axonSpec.js CHANGED
@@ -19,9 +19,7 @@
19
19
  Alice kills her publication and Emma joins.
20
20
  */
21
21
  const { describe, it, expect, beforeAll, afterAll, BigInt } = globalThis;
22
- import { createAuthorIdentity, regionCenter, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION, deriveTopicId, metricTopic } from '@axona/protocol';
23
- import { connect } from '@axona/protocol/connect.js';
24
- globalThis.RTCPeerConnection ||= await import('node-datachannel/polyfill').then(ndc => ndc.RTCPeerConnection);
22
+ import { connect, createAuthorIdentity, regionCenter, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION, deriveTopicId, metricTopic } from '@axona/protocol';
25
23
 
26
24
  class Node { // Stuff we have to do every time.
27
25
  static version = KERNEL_VERSION;
@@ -110,6 +108,7 @@ describe("CivilDefense", function () {
110
108
  const deliveryAllowanceMS = 60e3;
111
109
 
112
110
  //const location = regionCenter('uscentlw');
111
+ //const location = regionCenter('0x52');
113
112
  const location = regionCenter('uswest'); // TODO: pick one
114
113
 
115
114
  let alice, bob, carol, david, emma;
@@ -226,10 +225,12 @@ describe("CivilDefense", function () {
226
225
  });
227
226
  });
228
227
  afterAll(async function () {
229
- await alice.disconnect();
230
- await bob.disconnect();
231
- await carol?.disconnect();
232
- await david?.disconnect();
233
- await emma?.disconnect();
228
+ await Promise.all([
229
+ alice.disconnect(),
230
+ bob.disconnect(),
231
+ carol?.disconnect(),
232
+ david?.disconnect(),
233
+ emma?.disconnect(),
234
+ ]);
234
235
  });
235
236
  });
package/public/junk.html DELETED
@@ -1,145 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>Material Web Autocomplete Combo Box</title>
6
- <script type="importmap">
7
- {
8
- "imports": {
9
- "uuid": "https://unpkg.com/uuid@13.0.0/dist/index.js",
10
- "@material/web/": "https://esm.run/@material/web/",
11
- "s2js": "./s2js/s2js.esm.js",
12
- "bigfloat": "./bigfloat/esm/index.js",
13
- "leaflet": "./leaflet/leaflet-src.esm.js",
14
- "minidenticons": "./minidenticons/minidenticons.min.js",
15
- "@axona/protocol": "./axona-protocol/src/index.js",
16
- "@axona/protocol/std": "./axona-protocol/std/index.js",
17
- "@axona/protocol/connect.js": "./axona-protocol/src/connect.js"
18
- }
19
- }
20
- </script>
21
- <script type="module">
22
- import '@material/web/all.js';
23
- import {styles as typescaleStyles} from '@material/web/typography/md-typescale-styles.js';
24
- document.adoptedStyleSheets.push(typescaleStyles.styleSheet);
25
- </script>
26
- <!-- Load Material Web Components via CDN -->
27
- <!-- <script type="module"> -->
28
- <!-- import '@material/web/textfield/filled-text-field.js'; -->
29
- <!-- import '@material/web/menu/menu.js'; -->
30
- <!-- import '@material/web/menu/menu-item.js'; -->
31
- <!-- </script> -->
32
- <style>
33
- .combo-box-container {
34
- position: relative;
35
- display: inline-block;
36
- width: 300px;
37
- margin: 40px;
38
- }
39
- md-filled-text-field {
40
- width: 100%;
41
- }
42
- md-menu {
43
- /* Match the width of the textfield anchor */
44
- --md-menu-container-width: 300px;
45
- }
46
- </style>
47
- </head>
48
- <body>
49
-
50
- <div class="combo-box-container">
51
- <!-- The editable text input acting as the anchor -->
52
- <md-filled-text-field
53
- id="combo-input"
54
- label="Choose a fruit"
55
- placeholder="Type to search..."
56
- autocomplete="off">
57
- </md-filled-text-field>
58
-
59
- <!-- The dropdown menu containing autocomplete suggestions -->
60
- <md-menu id="combo-menu" anchor="combo-input" stay-open-on-outside-click>
61
- <!-- Populated dynamically via JavaScript -->
62
- </md-menu>
63
- </div>
64
-
65
- <script>
66
- const input = document.getElementById('combo-input');
67
- const menu = document.getElementById('combo-menu');
68
-
69
- // Dataset for autocomplete
70
- const itemsList = [
71
- 'Apple', 'Banana', 'Blueberry', 'Cherry', 'Grape',
72
- 'Lemon', 'Mango', 'Orange', 'Peach', 'Strawberry'
73
- ];
74
-
75
- // Build and filter menu items based on input value
76
- function updateMenu(filterText = '') {
77
- const normalizedFilter = filterText.toLowerCase().trim();
78
-
79
- // Filter list items
80
- const filtered = itemsList.filter(item =>
81
- item.toLowerCase().includes(normalizedFilter)
82
- );
83
-
84
- // Clear existing menu items
85
- menu.innerHTML = '';
86
-
87
- if (filtered.length === 0) {
88
- // Show a disabled helper item if no match found
89
- const noResult = document.createElement('md-menu-item');
90
- noResult.disabled = true;
91
- //noResult.headline = 'No matches found';
92
- noResult.textContent = 'No matches found';
93
- menu.appendChild(noResult);
94
- return;
95
- }
96
-
97
- // Append matched options
98
- filtered.forEach(item => {
99
- const menuItem = document.createElement('md-menu-item');
100
- //menuItem.headline = item;
101
- menuItem.textContent = item;
102
- // Store raw value on the element for extraction during selection
103
- menuItem.dataset.value = item;
104
- menu.appendChild(menuItem);
105
- });
106
- }
107
-
108
- // Filter list and open menu as user types
109
- input.addEventListener('input', (e) => {
110
- updateMenu(e.target.value);
111
- if (!menu.open) {
112
- menu.open = true;
113
- }
114
- });
115
-
116
- // Re-open full menu list when user clicks or focuses inside the input
117
- input.addEventListener('focus', () => {
118
- updateMenu(input.value);
119
- menu.open = true;
120
- });
121
-
122
- // Handle selection event from the Material menu
123
- menu.addEventListener('close-menu', (e) => {
124
- // e.detail.item holds the clicked md-menu-item instance
125
- const selectedItem = e.detail.item;
126
- if (selectedItem && selectedItem.dataset.value) {
127
- input.value = selectedItem.dataset.value;
128
- // Optionally trigger a change event for form tracking
129
- input.dispatchEvent(new Event('change'));
130
- }
131
- });
132
-
133
- // Close the dropdown cleanly if the user hits "Escape" or "Enter" inside input
134
- input.addEventListener('keydown', (e) => {
135
- if (e.key === 'Escape' || e.key === 'Enter') {
136
- menu.open = false;
137
- if(e.key === 'Enter') input.blur();
138
- }
139
- });
140
-
141
- // Initial population of the menu
142
- updateMenu();
143
- </script>
144
- </body>
145
- </html>
@@ -1,379 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
- <title>Material Web Autocomplete Combo Box</title>
7
-
8
- <!-- Material Web components (outlined text field + icon) -->
9
- <script type="module" src="https://esm.run/@material/web/textfield/outlined-text-field.js"></script>
10
- <script type="module" src="https://esm.run/@material/web/icon/icon.js"></script>
11
-
12
- <!-- Roboto + Material Symbols, since Material Web expects these by default -->
13
- <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" />
14
- <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" />
15
-
16
- <style>
17
- :root {
18
- color-scheme: light;
19
- font-family: 'Roboto', system-ui, sans-serif;
20
- }
21
-
22
- html, body {
23
- height: 100%;
24
- }
25
-
26
- body {
27
- margin: 0;
28
- background: #f6f6f9;
29
- /* Leave room at the bottom for the fixed field so page content
30
- never sits underneath it. */
31
- padding-bottom: 96px;
32
- box-sizing: border-box;
33
- }
34
-
35
- main.content {
36
- max-width: 480px;
37
- margin: 0 auto;
38
- padding: 24px 20px;
39
- }
40
-
41
- h1 {
42
- font-size: 1.1rem;
43
- font-weight: 500;
44
- color: #1c1b1f;
45
- margin: 0 0 4px;
46
- }
47
-
48
- p.hint {
49
- font-size: 0.85rem;
50
- color: #49454f;
51
- margin: 0 0 20px;
52
- }
53
-
54
- /* The field is pinned to the bottom of the *visual* viewport (see JS). */
55
- /* position: fixed + bottom: 0 alone anchors to the bottom of the */
56
- /* layout viewport, which on mobile stays put even when a keyboard */
57
- /* slides up (some browsers just draw the keyboard on top of it, or */
58
- /* slide the page instead). To keep the field visible directly above */
59
- /* the keyboard, JS below tracks window.visualViewport and applies a */
60
- /* translateY offset equal to the keyboard's height. */
61
-
62
- .combobox {
63
- position: fixed;
64
- left: 0;
65
- right: 0;
66
- bottom: 0;
67
- z-index: 10;
68
- box-sizing: border-box;
69
- padding: 12px 20px calc(12px + env(safe-area-inset-bottom));
70
- background: #f6f6f9;
71
- box-shadow: 0 -1px 0 rgba(0,0,0,0.08);
72
- max-width: 480px;
73
- margin: 0 auto;
74
- /* set by JS to compensate for the on-screen keyboard */
75
- transform: translateY(0px);
76
- will-change: transform;
77
- }
78
-
79
- md-outlined-text-field {
80
- width: 100%;
81
- }
82
-
83
- /*
84
- The suggestion list is positioned independently (fixed, computed in
85
- JS) rather than nested inside .combobox, so its top edge can be
86
- clamped to the available space above the field regardless of how
87
- tall the list's content is.
88
- */
89
- .combobox-listbox {
90
- position: fixed;
91
- z-index: 20;
92
- margin: 0;
93
- padding: 8px 0;
94
- list-style: none;
95
- overflow-y: auto;
96
- background: #fff;
97
- border-radius: 4px;
98
- box-shadow: 0 2px 6px 2px rgba(0,0,0,0.15), 0 1px 2px rgba(0,0,0,0.3);
99
- display: none;
100
- }
101
-
102
- .combobox-listbox.open {
103
- display: block;
104
- }
105
-
106
- .combobox-option {
107
- padding: 10px 16px;
108
- font-size: 0.95rem;
109
- color: #1c1b1f;
110
- cursor: pointer;
111
- white-space: nowrap;
112
- overflow: hidden;
113
- text-overflow: ellipsis;
114
- }
115
-
116
- .combobox-option mark {
117
- background: none;
118
- color: #6750a4;
119
- font-weight: 500;
120
- }
121
-
122
- .combobox-option:hover,
123
- .combobox-option.active {
124
- background: #eaddff;
125
- }
126
-
127
- .combobox-empty {
128
- padding: 10px 16px;
129
- font-size: 0.9rem;
130
- color: #79747e;
131
- }
132
- </style>
133
- </head>
134
- <body>
135
-
136
- <main class="content">
137
- <h1>Country picker</h1>
138
- <p class="hint">
139
- Start typing a country name. Use ↑ / ↓ to navigate, Enter to select, Esc to close.
140
- The field stays pinned to the bottom of the screen, and the suggestion list opens
141
- upward above it — try this on a phone to see it stay clear of the keyboard.
142
- </p>
143
- </main>
144
-
145
- <div class="combobox">
146
- <md-outlined-text-field
147
- id="country-input"
148
- label="Country"
149
- role="combobox"
150
- aria-expanded="false"
151
- aria-controls="country-listbox"
152
- aria-autocomplete="list"
153
- autocomplete="off"
154
- >
155
- <md-icon slot="leading-icon">search</md-icon>
156
- </md-outlined-text-field>
157
- </div>
158
-
159
- <ul class="combobox-listbox" id="country-listbox" role="listbox"></ul>
160
-
161
- <script type="module">
162
- // Wait for the custom element to be defined so its internal <input> exists.
163
- await customElements.whenDefined('md-outlined-text-field');
164
-
165
- const DATA = [
166
- "Argentina","Australia","Austria","Bangladesh","Belgium","Brazil","Canada",
167
- "Chile","China","Colombia","Denmark","Egypt","Finland","France","Germany",
168
- "Greece","India","Indonesia","Ireland","Italy","Japan","Kenya","Malaysia",
169
- "Mexico","Netherlands","New Zealand","Nigeria","Norway","Pakistan","Peru",
170
- "Philippines","Poland","Portugal","Russia","Saudi Arabia","Singapore",
171
- "South Africa","South Korea","Spain","Sweden","Switzerland","Thailand",
172
- "Turkey","Ukraine","United Arab Emirates","United Kingdom","United States",
173
- "Vietnam"
174
- ];
175
-
176
- const footer = document.querySelector('.combobox');
177
- const field = document.getElementById('country-input');
178
- const listbox = document.getElementById('country-listbox');
179
-
180
- let matches = [];
181
- let activeIndex = -1;
182
-
183
- function getValue() {
184
- return field.value ?? '';
185
- }
186
-
187
- function escapeRegExp(str) {
188
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
189
- }
190
-
191
- function highlight(text, query) {
192
- if (!query) return text;
193
- const re = new RegExp(`(${escapeRegExp(query)})`, 'ig');
194
- return text.replace(re, '<mark>$1</mark>');
195
- }
196
-
197
- // ---------------------------------------------------------------------
198
- // Keyboard-aware positioning
199
- // ---------------------------------------------------------------------
200
- // window.visualViewport reports the *actually visible* area, shrinking
201
- // when a mobile keyboard appears. window.innerHeight / layout viewport
202
- // rects do not shrink the same way, which is why a plain
203
- // `position: fixed; bottom: 0` element can end up hidden behind the
204
- // keyboard. We measure the gap between the two and shift the field up
205
- // by exactly that amount.
206
- function keyboardInset() {
207
- const vv = window.visualViewport;
208
- if (!vv) return 0;
209
- return Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
210
- }
211
-
212
- function repositionField() {
213
- footer.style.transform = `translateY(-${keyboardInset()}px)`;
214
- }
215
-
216
- // Positions the listbox so its bottom edge sits just above the field,
217
- // and clamps its top edge (via max-height) to the visible viewport,
218
- // so it never runs off-screen above a shrunken (keyboard-open) viewport.
219
- function repositionListbox() {
220
- const rect = field.getBoundingClientRect();
221
- const vv = window.visualViewport;
222
- const viewportTop = vv ? vv.offsetTop : 0;
223
- const gap = 4;
224
- const topMargin = 8;
225
-
226
- listbox.style.left = `${rect.left}px`;
227
- listbox.style.width = `${rect.width}px`;
228
- listbox.style.bottom = `${window.innerHeight - rect.top + gap}px`;
229
-
230
- const available = rect.top - viewportTop - gap - topMargin;
231
- listbox.style.maxHeight = `${Math.max(80, available)}px`;
232
- }
233
-
234
- function repositionAll() {
235
- repositionField();
236
- // Wait a frame so the field's transform has applied before we read
237
- // its position for the listbox.
238
- requestAnimationFrame(repositionListbox);
239
- }
240
-
241
- window.visualViewport?.addEventListener('resize', repositionAll);
242
- window.visualViewport?.addEventListener('scroll', repositionAll);
243
- window.addEventListener('resize', repositionAll);
244
- window.addEventListener('orientationchange', repositionAll);
245
- repositionAll();
246
-
247
- // ---------------------------------------------------------------------
248
- // Combobox behavior
249
- // ---------------------------------------------------------------------
250
- function closeListbox() {
251
- listbox.classList.remove('open');
252
- field.setAttribute('aria-expanded', 'false');
253
- field.removeAttribute('aria-activedescendant');
254
- activeIndex = -1;
255
- }
256
-
257
- function openListbox() {
258
- listbox.classList.add('open');
259
- field.setAttribute('aria-expanded', 'true');
260
- repositionListbox();
261
- }
262
-
263
- function setActive(index) {
264
- const options = listbox.querySelectorAll('.combobox-option');
265
- options.forEach(opt => opt.classList.remove('active'));
266
- activeIndex = index;
267
- if (index >= 0 && options[index]) {
268
- options[index].classList.add('active');
269
- options[index].scrollIntoView({ block: 'nearest' });
270
- field.setAttribute('aria-activedescendant', options[index].id);
271
- } else {
272
- field.removeAttribute('aria-activedescendant');
273
- }
274
- }
275
-
276
- function selectValue(value) {
277
- field.value = value;
278
- closeListbox();
279
- field.focus();
280
- }
281
-
282
- function renderMatches(query) {
283
- listbox.innerHTML = '';
284
-
285
- if (!query) {
286
- closeListbox();
287
- return;
288
- }
289
-
290
- matches = DATA.filter(item =>
291
- item.toLowerCase().includes(query.toLowerCase())
292
- );
293
-
294
- if (matches.length === 0) {
295
- const li = document.createElement('li');
296
- li.className = 'combobox-empty';
297
- li.textContent = 'No matches found';
298
- listbox.appendChild(li);
299
- openListbox();
300
- setActive(-1);
301
- return;
302
- }
303
-
304
- matches.forEach((item, i) => {
305
- const li = document.createElement('li');
306
- li.className = 'combobox-option';
307
- li.id = `country-option-${i}`;
308
- li.setAttribute('role', 'option');
309
- li.innerHTML = highlight(item, query);
310
- li.addEventListener('mousedown', (e) => {
311
- // mousedown (not click) so it fires before the field's blur event
312
- e.preventDefault();
313
- selectValue(item);
314
- });
315
- listbox.appendChild(li);
316
- });
317
-
318
- openListbox();
319
- setActive(-1);
320
- }
321
-
322
- field.addEventListener('input', () => {
323
- renderMatches(getValue());
324
- });
325
-
326
- // The keyboard opening/closing changes visualViewport size, which
327
- // fires our resize listener above and keeps everything positioned
328
- // correctly without any extra work here.
329
- field.addEventListener('focus', () => {
330
- if (getValue()) renderMatches(getValue());
331
- });
332
-
333
- field.addEventListener('keydown', (e) => {
334
- const isOpen = listbox.classList.contains('open');
335
- const optionCount = listbox.querySelectorAll('.combobox-option').length;
336
-
337
- switch (e.key) {
338
- case 'ArrowDown':
339
- e.preventDefault();
340
- if (!isOpen) { renderMatches(getValue()); return; }
341
- if (optionCount > 0) setActive((activeIndex + 1) % optionCount);
342
- break;
343
-
344
- case 'ArrowUp':
345
- e.preventDefault();
346
- if (!isOpen) { renderMatches(getValue()); return; }
347
- if (optionCount > 0) setActive((activeIndex - 1 + optionCount) % optionCount);
348
- break;
349
-
350
- case 'Enter':
351
- if (isOpen && activeIndex >= 0 && matches[activeIndex]) {
352
- e.preventDefault();
353
- selectValue(matches[activeIndex]);
354
- }
355
- break;
356
-
357
- case 'Escape':
358
- if (isOpen) {
359
- e.preventDefault();
360
- closeListbox();
361
- }
362
- break;
363
-
364
- case 'Tab':
365
- closeListbox();
366
- break;
367
- }
368
- });
369
-
370
- // Close when clicking outside the combobox/listbox.
371
- document.addEventListener('click', (e) => {
372
- if (!e.target.closest('.combobox') && !e.target.closest('.combobox-listbox')) {
373
- closeListbox();
374
- }
375
- });
376
- </script>
377
-
378
- </body>
379
- </html>