@yz-social/civildefense.io 4.5.8 → 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/index.js +2 -1
- package/package.json +5 -3
- package/public/about/en.html +1 -1
- package/public/about/es.html +1 -1
- package/public/images/loading.gif +0 -0
- package/public/index.html +106 -107
- package/public/javascripts/agent.js +37 -30
- package/public/javascripts/alert.js +112 -64
- package/public/javascripts/conversation.js +8 -5
- package/public/javascripts/hashtags.js +247 -43
- package/public/javascripts/main.js +6 -2
- package/public/javascripts/map.js +4 -2
- package/public/javascripts/p2pWebNetwork.js +31 -10
- package/public/javascripts/s2.js +7 -3
- package/public/javascripts/service-manager.js +5 -4
- package/public/javascripts/translations.js +1 -0
- package/public/javascripts/versions.js +16 -5
- package/public/service-worker.js +7 -7
- package/public/stylesheets/style.css +74 -12
- package/server/app.js +34 -23
- package/server/{location.json → location.save.json} +1 -1
- package/spec/axonSpec.js +9 -8
- package/spec/conversationSpec.js +2 -2
- package/public/junk.html +0 -145
package/public/service-worker.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { Request, Response, URL, clients} = self;
|
|
2
2
|
// Little point in trying to automatically pull this through package.json, as we need a byte change in THIS file to trigger a new worker.
|
|
3
|
-
const serviceVersion = '4.5.
|
|
3
|
+
const serviceVersion = '4.5.16';
|
|
4
4
|
|
|
5
5
|
const cacheList = [ // The files we need.
|
|
6
6
|
"/",
|
|
@@ -172,13 +172,13 @@ async function cacheSource(version, event) { // Cache source in the given versio
|
|
|
172
172
|
await Promise.all([
|
|
173
173
|
// These are referenced within material web, but missing. Turns out we don't need them,
|
|
174
174
|
// but let's cache empty responses to keep the console cleaner.
|
|
175
|
-
"https://esm.run/npm/lit@3.3.
|
|
175
|
+
"https://esm.run/npm/lit@3.3.3/+esm",
|
|
176
176
|
"https://esm.run/npm/tslib@2.8.1/+esm",
|
|
177
|
-
"https://esm.run/npm/lit@3.3.
|
|
178
|
-
"https://esm.run/npm/lit@3.3.
|
|
179
|
-
"https://esm.run/npm/lit@3.3.
|
|
180
|
-
"https://esm.run/npm/lit@3.3.
|
|
181
|
-
"https://esm.run/npm/lit@3.3.
|
|
177
|
+
"https://esm.run/npm/lit@3.3.3/static-html.js/+esm",
|
|
178
|
+
"https://esm.run/npm/lit@3.3.3/decorators.js/+esm",
|
|
179
|
+
"https://esm.run/npm/lit@3.3.3/directives/style-map.js/+esm",
|
|
180
|
+
"https://esm.run/npm/lit@3.3.3/directives/class-map.js/+esm",
|
|
181
|
+
"https://esm.run/npm/lit@3.3.3/directives/when.js/+esm",
|
|
182
182
|
"https://esm.run/npm/lit@3.3.1/directives/live.js/+esm",
|
|
183
183
|
].map(url => cache.put(new Request(url),
|
|
184
184
|
new Response("", {headers: { "Content-Type": "text/javascript" }}))));
|
|
@@ -72,13 +72,31 @@ md-outlined-text-field {
|
|
|
72
72
|
max-height: 80vh;
|
|
73
73
|
overflow: auto;
|
|
74
74
|
}
|
|
75
|
+
/* Safari. Example dimensions are iPhone 17 pro max simulator */
|
|
76
|
+
/* In landscape, regardless of compact/bottom/top setting: */
|
|
77
|
+
/* - There is a thick tabbed address bar above map. */
|
|
78
|
+
/* - Notch partially obscures logo in landscape-right, but that's ok */
|
|
79
|
+
/* - Height should be dvh (956 x 330), as lvh (440) extends below bottom of screen. */
|
|
80
|
+
/* In portrait, regardless of compact/bottom/top setting: */
|
|
81
|
+
/* - There is a white space at top and bottom that is a little larger than the stuff controls displayed in it. */
|
|
82
|
+
/* - If height of map, etc, is dvh (440 x 796), everything works, including keyboard appearance and dismissal. */
|
|
83
|
+
/* Position .watching-hashtags box bottom:18px to leave space for map attribution. */
|
|
84
|
+
/* - If height of map is lvh (440 x 836) and #subPopoverControls remains dvh (.watching-hashtags bottom:0), */
|
|
85
|
+
/* then the map has a nice-ish fade under the bottom controls, without losing so much to whitespace. */
|
|
86
|
+
/* BUT: */
|
|
87
|
+
/* - It still has a hard edge to start of white, that is now directly under controls, which is ugly. */
|
|
88
|
+
/* - The map attribution is under the controls, which isn't fair. */
|
|
89
|
+
/* - I don't know how to make the keyboard work without covering the input box. */
|
|
75
90
|
#map {
|
|
76
|
-
width:
|
|
77
|
-
height:
|
|
91
|
+
width: 100dvw;
|
|
92
|
+
height: 100dvh;
|
|
78
93
|
position: absolute;
|
|
79
94
|
top: 0;
|
|
80
95
|
left: 0;
|
|
81
96
|
}
|
|
97
|
+
#mapCapture {
|
|
98
|
+
height: 100dvh;
|
|
99
|
+
}
|
|
82
100
|
|
|
83
101
|
.info-banner {
|
|
84
102
|
position: absolute;
|
|
@@ -115,9 +133,6 @@ md-outlined-text-field {
|
|
|
115
133
|
transition: opacity 2s;
|
|
116
134
|
}
|
|
117
135
|
|
|
118
|
-
#mapCapture {
|
|
119
|
-
height: 100vh;
|
|
120
|
-
}
|
|
121
136
|
#mapCapture > img {
|
|
122
137
|
position: absolute;
|
|
123
138
|
opacity: 0;
|
|
@@ -155,6 +170,7 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
|
|
|
155
170
|
height: 100dvh;
|
|
156
171
|
position: fixed;
|
|
157
172
|
filter: drop-shadow(4px 4px 8px rgba(0, 0, 0, 0.5));
|
|
173
|
+
--mapAttributionHeight: 18px;
|
|
158
174
|
}
|
|
159
175
|
#subPopoverControls > * > * {
|
|
160
176
|
pointer-events: auto;
|
|
@@ -170,19 +186,64 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
|
|
|
170
186
|
.watching-hashtags {
|
|
171
187
|
position: absolute;
|
|
172
188
|
width: calc(100% - 130px);
|
|
173
|
-
bottom: calc((sign(calc(100vh - 100dvh)) - 1) * -18px);
|
|
189
|
+
/*bottom: calc((sign(calc(100vh - 100dvh)) - 1) * -18px); *//* 18px for attribution in desktop, none if iphone dynamic viewsize. */
|
|
190
|
+
bottom: var(--mapAttributionHeight);
|
|
174
191
|
right: 5px;
|
|
175
192
|
flex-direction: row-reverse;
|
|
176
193
|
flex-wrap: wrap-reverse;
|
|
177
194
|
align-items: flex-end;
|
|
178
195
|
}
|
|
196
|
+
.combobox {
|
|
197
|
+
position: relative;
|
|
198
|
+
--textFieldHeight: 34px;
|
|
199
|
+
--minWidth: 125px;
|
|
200
|
+
}
|
|
201
|
+
.combobox-listbox {
|
|
202
|
+
padding: 5px;
|
|
203
|
+
position: absolute;
|
|
204
|
+
bottom: calc(var(--textFieldHeight) + 2px);
|
|
205
|
+
left: 0;
|
|
206
|
+
max-height: 50dvh; /* leaving room for portrait keyboard. landscape keyboard has no room at all */
|
|
207
|
+
width: fit-content;
|
|
208
|
+
min-width: var(--minWidth);
|
|
209
|
+
overflow-y: auto;
|
|
210
|
+
z-index: 9999;
|
|
211
|
+
background: rgba(255, 255, 255, 0.95);
|
|
212
|
+
border-top-left-radius: 12px;
|
|
213
|
+
border-top-right-radius: 12px;
|
|
214
|
+
}
|
|
215
|
+
.combobox-option {
|
|
216
|
+
font-size: 0.95rem;
|
|
217
|
+
color: var(--md-sys-color-primary);
|
|
218
|
+
cursor: pointer;
|
|
219
|
+
white-space: nowrap;
|
|
220
|
+
}
|
|
221
|
+
.combobox-option minidenticon-svg {
|
|
222
|
+
display: inline-block;
|
|
223
|
+
position: relative;
|
|
224
|
+
top: 5px;
|
|
225
|
+
height: 20px;
|
|
226
|
+
width: 20px;
|
|
227
|
+
}
|
|
228
|
+
.combobox-option mark {
|
|
229
|
+
background: none;
|
|
230
|
+
color: var(--md-sys-color-secondary);
|
|
231
|
+
font-weight: 900;
|
|
232
|
+
}
|
|
233
|
+
.combobox-empty {
|
|
234
|
+
font-style: italic;
|
|
235
|
+
}
|
|
236
|
+
.combobox-option:hover,
|
|
237
|
+
.combobox-option.active {
|
|
238
|
+
background: #B8C2D7;
|
|
239
|
+
}
|
|
179
240
|
.watching-hashtags md-filled-text-field.newtag {
|
|
180
241
|
--md-filled-field-top-space: 0px;
|
|
181
242
|
--md-filled-field-bottom-space: 0px;
|
|
182
243
|
--md-filled-text-field-leading-space: 8px;
|
|
183
244
|
--md-filled-text-field-trailing-space: 0px;
|
|
184
|
-
|
|
185
|
-
|
|
245
|
+
height: var(--textFieldHeight);
|
|
246
|
+
width: var(--minWidth);
|
|
186
247
|
}
|
|
187
248
|
.alert-marker {
|
|
188
249
|
text-shadow: 8px -3px 10px rgb(0 0 0 / 60%);
|
|
@@ -262,6 +323,7 @@ md-filter-chip minidenticon-svg {
|
|
|
262
323
|
md-outlined-icon-button minidenticon-svg, md-outlined-icon-button img {
|
|
263
324
|
height: 40px;
|
|
264
325
|
width: 40px;
|
|
326
|
+
object-fit: cover;
|
|
265
327
|
border-radius: 12px;
|
|
266
328
|
}
|
|
267
329
|
.alert span minidenticon-svg {
|
|
@@ -327,9 +389,9 @@ md-menu {
|
|
|
327
389
|
md-outlined-button {
|
|
328
390
|
padding: 10px;
|
|
329
391
|
}
|
|
330
|
-
.attribution > div:last-child > span {
|
|
331
|
-
|
|
332
|
-
}
|
|
392
|
+
/* .attribution > div:last-child > span { */
|
|
393
|
+
/* text-wrap: nowrap; */
|
|
394
|
+
/* } */
|
|
333
395
|
md-menu-item minidenticon-svg {
|
|
334
396
|
display: inline-block;
|
|
335
397
|
height: 20px;
|
|
@@ -363,7 +425,7 @@ md-menu-item minidenticon-svg {
|
|
|
363
425
|
.attribution-metadata {
|
|
364
426
|
display: flex;
|
|
365
427
|
flex-direction: column;
|
|
366
|
-
align-items: end;
|
|
428
|
+
/* align-items: end; */
|
|
367
429
|
}
|
|
368
430
|
.alert {
|
|
369
431
|
font-family: roboto;
|
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.
|
|
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
|
|
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('
|
|
39
|
+
.option('spacing', {
|
|
40
40
|
type: 'number',
|
|
41
|
-
default:
|
|
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
|
-
|
|
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
|
|
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:
|
|
114
|
-
debugLogger:
|
|
120
|
+
infoLogger: log,
|
|
121
|
+
debugLogger: debug
|
|
115
122
|
});
|
|
116
123
|
process.title = 'axona-' + network.nodeIdentity.id;
|
|
117
|
-
let update = setInterval(() => {
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
}
|
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
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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/spec/conversationSpec.js
CHANGED
|
@@ -31,8 +31,8 @@ describe("Conversation", function () {
|
|
|
31
31
|
let keep = true;
|
|
32
32
|
let tag = "caching";
|
|
33
33
|
class CacheConversation extends Conversation {
|
|
34
|
-
update() { return keep &&
|
|
35
|
-
initialize() { return keep &&
|
|
34
|
+
update(properties) { return keep && super.update(properties); }
|
|
35
|
+
initialize(properties) { return keep && super.initialize(properties); }
|
|
36
36
|
}
|
|
37
37
|
it("ends with explicit removal.", async function () {
|
|
38
38
|
let initial = await Conversation.ensure({tag, agent, payload});
|
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>
|