@dmthepm/commune 0.1.0 → 0.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/README.md +95 -405
- package/package.json +3 -7
- package/src/components/Backlinks.astro +5 -1
- package/src/components/BacklinksScript.astro +11 -1
- package/src/components/Header.astro +76 -1
- package/src/components/HeaderStarScript.astro +41 -3
- package/src/components/SearchModal.astro +165 -32
- package/src/components/SlidingPanes.astro +782 -0
- package/src/styles/design-system.css +88 -6
- package/src/styles/notes.css +36 -0
- package/src/components/PlausibleScript.astro +0 -13
- package/src/components/panes.ts +0 -61
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
---
|
|
3
|
+
<!-- First thing in the tab order on every page: reading pages put the whole
|
|
4
|
+
note between the header and the footer, and a keyboard reader should not
|
|
5
|
+
have to walk the header on each one. #main is on the page's <main>, or on
|
|
6
|
+
the first pane's scroll container where <main> is the pane container. -->
|
|
7
|
+
<a class="skip-link" href="#main">Skip to content</a>
|
|
8
|
+
|
|
3
9
|
<header class="commune-header">
|
|
4
10
|
<div class="wrap">
|
|
5
11
|
<a href="/" class="brand-section">
|
|
@@ -21,6 +27,30 @@
|
|
|
21
27
|
</header>
|
|
22
28
|
|
|
23
29
|
<style>
|
|
30
|
+
.skip-link{
|
|
31
|
+
/* Fixed, not absolute: the reader may be scrolled anywhere in a long note
|
|
32
|
+
when they reach for Tab, and the link has to be on screen to be useful. */
|
|
33
|
+
position:fixed;
|
|
34
|
+
left:0.5rem;
|
|
35
|
+
top:0.5rem;
|
|
36
|
+
z-index:calc(var(--z-modal) + 1);
|
|
37
|
+
padding:0.6rem 0.9rem;
|
|
38
|
+
border:1.5px solid var(--c-accent);
|
|
39
|
+
border-radius:var(--c-radius-md);
|
|
40
|
+
background:var(--c-bg);
|
|
41
|
+
color:var(--c-accent);
|
|
42
|
+
font-size:0.9rem;
|
|
43
|
+
font-weight:600;
|
|
44
|
+
box-shadow:var(--c-shadow-md);
|
|
45
|
+
transform:translateY(calc(-100% - 1rem));
|
|
46
|
+
transition:transform 0.15s ease;
|
|
47
|
+
}
|
|
48
|
+
.skip-link:focus{
|
|
49
|
+
transform:translateY(0);
|
|
50
|
+
}
|
|
51
|
+
@media print{
|
|
52
|
+
.skip-link{display:none}
|
|
53
|
+
}
|
|
24
54
|
.commune-header{
|
|
25
55
|
position:sticky;
|
|
26
56
|
top:0;
|
|
@@ -193,6 +223,16 @@
|
|
|
193
223
|
border-radius:var(--c-radius-sm);
|
|
194
224
|
}
|
|
195
225
|
|
|
226
|
+
/* A hover state whose whole content is movement loses the movement. The
|
|
227
|
+
rule lives here rather than in `design-system.css` because Astro scopes
|
|
228
|
+
this block's selectors, and a global `.theme-btn:hover` would lose the
|
|
229
|
+
specificity contest to the scoped rule above. */
|
|
230
|
+
@media (prefers-reduced-motion: reduce) {
|
|
231
|
+
.theme-btn:hover {
|
|
232
|
+
transform: none;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
196
236
|
/* Mobile: hide Command-K text and search text, show only icon */
|
|
197
237
|
@media (max-width: 768px) {
|
|
198
238
|
.search-btn kbd,
|
|
@@ -239,9 +279,44 @@
|
|
|
239
279
|
});
|
|
240
280
|
})();
|
|
241
281
|
|
|
282
|
+
// Publish the header's real height. The pane container positions itself
|
|
283
|
+
// against --header-height, and the fallback in the stylesheet is a
|
|
284
|
+
// hand-counted number the header has outgrown — it measures 73px at the
|
|
285
|
+
// default font size and more once the text is zoomed. Measuring keeps the
|
|
286
|
+
// pane's top gap equal to its bottom gap at any zoom level or font size,
|
|
287
|
+
// and means no consumer has to re-count the number after restyling the
|
|
288
|
+
// header.
|
|
289
|
+
(function trackHeaderHeight(){
|
|
290
|
+
const header = document.querySelector('.commune-header');
|
|
291
|
+
if (!header) return;
|
|
292
|
+
const publish = () => {
|
|
293
|
+
const height = Math.round(header.getBoundingClientRect().height);
|
|
294
|
+
if (height > 0) document.documentElement.style.setProperty('--header-height', height + 'px');
|
|
295
|
+
};
|
|
296
|
+
publish();
|
|
297
|
+
if ('ResizeObserver' in window) new ResizeObserver(publish).observe(header);
|
|
298
|
+
else addEventListener('resize', publish);
|
|
299
|
+
})();
|
|
300
|
+
|
|
301
|
+
// One platform check, shared with the search modal's own handler, so the
|
|
302
|
+
// printed shortcut and the key that actually works cannot drift apart.
|
|
303
|
+
// navigator.platform is deprecated but still the only thing every browser
|
|
304
|
+
// answers; userAgentData.platform is preferred where it exists.
|
|
305
|
+
window.CommuneIsMac = () => /mac/i.test(
|
|
306
|
+
(navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || ''
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
// The button printed ⌘K to everyone. On Windows and Linux the shortcut is
|
|
310
|
+
// Ctrl+K, so the label named a key combination that did nothing.
|
|
311
|
+
(function labelSearchShortcut(){
|
|
312
|
+
if (window.CommuneIsMac()) return;
|
|
313
|
+
document.querySelectorAll('.search-btn kbd').forEach((el) => { el.textContent = 'Ctrl K'; });
|
|
314
|
+
document.querySelectorAll('.search-btn').forEach((el) => { el.setAttribute('aria-label', 'Search (Ctrl K)'); });
|
|
315
|
+
})();
|
|
316
|
+
|
|
242
317
|
// cmd/ctrl-k opens modal
|
|
243
318
|
addEventListener('keydown', (e) => {
|
|
244
|
-
const mac =
|
|
319
|
+
const mac = window.CommuneIsMac();
|
|
245
320
|
if ((mac ? e.metaKey : e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
246
321
|
e.preventDefault();
|
|
247
322
|
dispatchEvent(new CustomEvent('commune:openSearch'));
|
|
@@ -5,13 +5,24 @@
|
|
|
5
5
|
(function() {
|
|
6
6
|
let backlinksData = null;
|
|
7
7
|
|
|
8
|
+
// Focus belongs to the dialog while it is open, and goes back to the star
|
|
9
|
+
// that opened it on close — otherwise a keyboard reader who opens the modal
|
|
10
|
+
// is dropped at the top of the document with no way back to where they were.
|
|
11
|
+
let starModalOpener = null;
|
|
12
|
+
|
|
13
|
+
const starModalFocusables = (modal) => Array.from(
|
|
14
|
+
modal.querySelectorAll('a[href], button, [tabindex]:not([tabindex="-1"])')
|
|
15
|
+
).filter((el) => !el.hasAttribute('disabled'));
|
|
16
|
+
|
|
8
17
|
// Show the star modal
|
|
9
18
|
function showStarModal() {
|
|
10
19
|
const modal = document.getElementById('star-modal');
|
|
11
20
|
if (modal) {
|
|
21
|
+
starModalOpener = document.activeElement;
|
|
12
22
|
modal.style.display = 'flex';
|
|
13
23
|
// Prevent body scroll when modal is open
|
|
14
24
|
document.body.style.overflow = 'hidden';
|
|
25
|
+
starModalFocusables(modal)[0]?.focus();
|
|
15
26
|
}
|
|
16
27
|
}
|
|
17
28
|
|
|
@@ -21,6 +32,10 @@
|
|
|
21
32
|
if (modal) {
|
|
22
33
|
modal.style.display = 'none';
|
|
23
34
|
document.body.style.overflow = '';
|
|
35
|
+
if (starModalOpener instanceof HTMLElement && document.contains(starModalOpener)) {
|
|
36
|
+
starModalOpener.focus({ preventScroll: true });
|
|
37
|
+
}
|
|
38
|
+
starModalOpener = null;
|
|
24
39
|
}
|
|
25
40
|
}
|
|
26
41
|
|
|
@@ -31,6 +46,9 @@
|
|
|
31
46
|
const modal = document.createElement('div');
|
|
32
47
|
modal.id = 'star-modal';
|
|
33
48
|
modal.className = 'star-modal';
|
|
49
|
+
modal.setAttribute('role', 'dialog');
|
|
50
|
+
modal.setAttribute('aria-modal', 'true');
|
|
51
|
+
modal.setAttribute('aria-label', 'Top 5%');
|
|
34
52
|
modal.innerHTML = `
|
|
35
53
|
<div class="star-modal-backdrop"></div>
|
|
36
54
|
<div class="star-modal-content">
|
|
@@ -60,10 +78,25 @@
|
|
|
60
78
|
hideStarModal();
|
|
61
79
|
});
|
|
62
80
|
|
|
63
|
-
// Close on Escape
|
|
81
|
+
// Close on Escape, and keep Tab inside the dialog while it is open.
|
|
64
82
|
document.addEventListener('keydown', (e) => {
|
|
65
|
-
if (
|
|
83
|
+
if (modal.style.display !== 'flex') return;
|
|
84
|
+
if (e.key === 'Escape') {
|
|
66
85
|
hideStarModal();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (e.key !== 'Tab') return;
|
|
89
|
+
const items = starModalFocusables(modal);
|
|
90
|
+
if (!items.length) { e.preventDefault(); return; }
|
|
91
|
+
const first = items[0];
|
|
92
|
+
const last = items[items.length - 1];
|
|
93
|
+
const active = document.activeElement;
|
|
94
|
+
if (e.shiftKey && (active === first || !modal.contains(active))) {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
last.focus();
|
|
97
|
+
} else if (!e.shiftKey && (active === last || !modal.contains(active))) {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
first.focus();
|
|
67
100
|
}
|
|
68
101
|
});
|
|
69
102
|
}
|
|
@@ -110,9 +143,13 @@
|
|
|
110
143
|
star.setAttribute('role', 'button');
|
|
111
144
|
star.setAttribute('tabindex', '0');
|
|
112
145
|
|
|
113
|
-
// Click handler to open modal
|
|
146
|
+
// Click handler to open modal. stopPropagation matters: the pane
|
|
147
|
+
// container's delegated click handler treats any click that is not
|
|
148
|
+
// on a link or a real button as "focus this pane", and would pull
|
|
149
|
+
// focus straight back out of the modal we just opened.
|
|
114
150
|
star.addEventListener('click', (e) => {
|
|
115
151
|
e.preventDefault();
|
|
152
|
+
e.stopPropagation();
|
|
116
153
|
showStarModal();
|
|
117
154
|
});
|
|
118
155
|
|
|
@@ -120,6 +157,7 @@
|
|
|
120
157
|
star.addEventListener('keydown', (e) => {
|
|
121
158
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
122
159
|
e.preventDefault();
|
|
160
|
+
e.stopPropagation();
|
|
123
161
|
showStarModal();
|
|
124
162
|
}
|
|
125
163
|
});
|
|
@@ -1,11 +1,32 @@
|
|
|
1
1
|
---
|
|
2
|
-
interface Result { url: string; title: string; excerpt?: string; score?: number; source?: '
|
|
2
|
+
interface Result { url: string; title: string; excerpt?: string; score?: number; source?: 'substring'|'semantic'; }
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
/**
|
|
6
|
+
* A URL this site serves that answers `GET <endpoint>?q=<query>` with
|
|
7
|
+
* `{ results: [{ url, title, snippet }] }`. Supplying it adds a second,
|
|
8
|
+
* semantic search tier, merged with the local substring tier by RRF.
|
|
9
|
+
*
|
|
10
|
+
* There is no default, deliberately. The engine ships no component that
|
|
11
|
+
* calls a URL a stranger does not have (#56): with this prop unset, the
|
|
12
|
+
* semantic tier is not rendered at all — no fetch, no code for one — and
|
|
13
|
+
* search is the static substring pass over `/backlinks.json`, which the
|
|
14
|
+
* build itself produces.
|
|
15
|
+
*/
|
|
16
|
+
semanticEndpoint?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { semanticEndpoint } = Astro.props;
|
|
3
20
|
---
|
|
4
21
|
<div id="commune-search" hidden>
|
|
5
22
|
<div class="shade" onclick="window.CommuneSearch.close()"></div>
|
|
6
|
-
<div class="modal" role="dialog" aria-modal="true">
|
|
7
|
-
<
|
|
8
|
-
<
|
|
23
|
+
<div class="modal" role="dialog" aria-modal="true" aria-label="Search">
|
|
24
|
+
<label class="sr-only" for="q">Search notes and pages</label>
|
|
25
|
+
<input id="q" type="search" autocomplete="off" autocorrect="off" spellcheck="false"
|
|
26
|
+
placeholder="Search notes & docs… (Esc to close)"
|
|
27
|
+
aria-describedby="results-status" />
|
|
28
|
+
<p id="results-status" class="empty" role="status" aria-live="polite">Start typing to search.</p>
|
|
29
|
+
<ul id="results" aria-label="Search results"></ul>
|
|
9
30
|
</div>
|
|
10
31
|
</div>
|
|
11
32
|
|
|
@@ -48,6 +69,13 @@ interface Result { url: string; title: string; excerpt?: string; score?: number;
|
|
|
48
69
|
#q:focus{
|
|
49
70
|
border-color:var(--c-accent);
|
|
50
71
|
}
|
|
72
|
+
/* type="search" earns the Search key on touch keyboards; its stock clear
|
|
73
|
+
button is an unthemed browser widget in the middle of a designed modal. */
|
|
74
|
+
#q::-webkit-search-cancel-button,
|
|
75
|
+
#q::-webkit-search-decoration{
|
|
76
|
+
-webkit-appearance:none;
|
|
77
|
+
appearance:none;
|
|
78
|
+
}
|
|
51
79
|
#results{
|
|
52
80
|
list-style:none;
|
|
53
81
|
margin:1rem 0 .5rem;
|
|
@@ -55,6 +83,11 @@ interface Result { url: string; title: string; excerpt?: string; score?: number;
|
|
|
55
83
|
max-height:60vh;
|
|
56
84
|
overflow:auto;
|
|
57
85
|
}
|
|
86
|
+
/* The status line stands in for the list when it is empty; the list should
|
|
87
|
+
not also reserve its own margin underneath. */
|
|
88
|
+
#results:empty{
|
|
89
|
+
margin:0;
|
|
90
|
+
}
|
|
58
91
|
#results li{
|
|
59
92
|
padding:.85rem .75rem;
|
|
60
93
|
border-radius:var(--c-radius-sm);
|
|
@@ -78,12 +111,47 @@ interface Result { url: string; title: string; excerpt?: string; score?: number;
|
|
|
78
111
|
}
|
|
79
112
|
.empty{
|
|
80
113
|
text-align:center;
|
|
81
|
-
padding:2rem;
|
|
114
|
+
padding:2rem 1rem;
|
|
115
|
+
margin:0;
|
|
82
116
|
color:var(--c-text-muted);
|
|
83
117
|
font-size:.95rem;
|
|
118
|
+
line-height:1.6;
|
|
119
|
+
}
|
|
120
|
+
.empty[hidden]{display:none}
|
|
121
|
+
.empty .term{
|
|
122
|
+
color:var(--c-text);
|
|
123
|
+
font-weight:600;
|
|
124
|
+
overflow-wrap:anywhere;
|
|
125
|
+
}
|
|
126
|
+
.sr-only{
|
|
127
|
+
position:absolute;
|
|
128
|
+
width:1px;
|
|
129
|
+
height:1px;
|
|
130
|
+
padding:0;
|
|
131
|
+
margin:-1px;
|
|
132
|
+
overflow:hidden;
|
|
133
|
+
clip:rect(0, 0, 0, 0);
|
|
134
|
+
white-space:nowrap;
|
|
135
|
+
border:0;
|
|
84
136
|
}
|
|
85
137
|
</style>
|
|
86
138
|
|
|
139
|
+
{semanticEndpoint && (
|
|
140
|
+
<script is:inline define:vars={{ semanticEndpoint }}>
|
|
141
|
+
// The semantic tier, rendered only because a page passed an endpoint.
|
|
142
|
+
// `semanticEndpoint` is that page's own URL; the engine never picks one.
|
|
143
|
+
window.CommuneSemanticSearch = async function (query) {
|
|
144
|
+
try {
|
|
145
|
+
const sep = semanticEndpoint.includes('?') ? '&' : '?';
|
|
146
|
+
const r = await fetch(`${semanticEndpoint}${sep}q=${encodeURIComponent(query)}`);
|
|
147
|
+
if (!r.ok) return [];
|
|
148
|
+
const j = await r.json();
|
|
149
|
+
return (j.results ?? []).slice(0, 10).map(x => ({ url: x.url, title: x.title, excerpt: x.snippet, source: 'semantic' }));
|
|
150
|
+
} catch { return []; }
|
|
151
|
+
};
|
|
152
|
+
</script>
|
|
153
|
+
)}
|
|
154
|
+
|
|
87
155
|
<script is:inline>
|
|
88
156
|
// Lightweight RRF combiner
|
|
89
157
|
const rrf = (lists) => {
|
|
@@ -97,18 +165,6 @@ interface Result { url: string; title: string; excerpt?: string; score?: number;
|
|
|
97
165
|
return Array.from(map.values()).sort((a,b) => b.score - a.score);
|
|
98
166
|
};
|
|
99
167
|
|
|
100
|
-
// Pagefind removed - semantic search will handle full-text needs
|
|
101
|
-
// If we ever need dedicated lexical full-text search, we can re-add it
|
|
102
|
-
|
|
103
|
-
async function doSemantic(query){
|
|
104
|
-
try{
|
|
105
|
-
const r = await fetch(`/api/ask?q=${encodeURIComponent(query)}`);
|
|
106
|
-
if (!r.ok) return [];
|
|
107
|
-
const j = await r.json();
|
|
108
|
-
return (j.results ?? []).slice(0,10).map(x => ({ url: x.url, title: x.title, excerpt: x.snippet, source:'semantic' }));
|
|
109
|
-
}catch{ return []; }
|
|
110
|
-
}
|
|
111
|
-
|
|
112
168
|
// DEV fallback: use backlinks graph as minimal search index
|
|
113
169
|
async function devIndex() {
|
|
114
170
|
try {
|
|
@@ -135,70 +191,147 @@ interface Result { url: string; title: string; excerpt?: string; score?: number;
|
|
|
135
191
|
const pos = hay.indexOf(norm);
|
|
136
192
|
return pos === -1 ? null : { ...d, score: 1 / (1 + pos) };
|
|
137
193
|
}).filter(Boolean).sort((a,b) => b.score - a.score);
|
|
138
|
-
return scored.slice(0, 20).map(d => ({ url: d.url, title: d.title, excerpt: d.excerpt, source: '
|
|
194
|
+
return scored.slice(0, 20).map(d => ({ url: d.url, title: d.title, excerpt: d.excerpt, source: 'substring' }));
|
|
139
195
|
}
|
|
140
196
|
|
|
141
197
|
// Wire up modal
|
|
142
198
|
(function mountSearch(){
|
|
143
199
|
const root = document.getElementById('commune-search');
|
|
200
|
+
const modal = root.querySelector('.modal');
|
|
144
201
|
const input = root.querySelector('#q');
|
|
145
202
|
const list = root.querySelector('#results');
|
|
203
|
+
const status = root.querySelector('#results-status');
|
|
204
|
+
|
|
205
|
+
const IDLE_MESSAGE = 'Start typing to search.';
|
|
146
206
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
207
|
+
// Titles and summaries are the vault author's prose. A stray <, & or " in
|
|
208
|
+
// one of them should read as that character, not break out of the markup
|
|
209
|
+
// being built here.
|
|
210
|
+
const escapeHtml = (value) => String(value ?? '')
|
|
211
|
+
.replace(/&/g, '&')
|
|
212
|
+
.replace(/</g, '<')
|
|
213
|
+
.replace(/>/g, '>')
|
|
214
|
+
.replace(/"/g, '"')
|
|
215
|
+
.replace(/'/g, ''');
|
|
216
|
+
|
|
217
|
+
function showStatus(html) {
|
|
218
|
+
status.innerHTML = html;
|
|
219
|
+
status.hidden = false;
|
|
220
|
+
list.replaceChildren();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function render(results, query) {
|
|
224
|
+
// Before anything is typed there is no result set to be empty, so
|
|
225
|
+
// saying "no results" is a false negative. Only a query with no matches
|
|
226
|
+
// is one.
|
|
227
|
+
if (!query) {
|
|
228
|
+
showStatus(IDLE_MESSAGE);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!results.length) {
|
|
232
|
+
showStatus(`No results for <span class="term">${escapeHtml(query)}</span>.<br />Try a shorter word, a tag, or an alias.`);
|
|
150
233
|
return;
|
|
151
234
|
}
|
|
235
|
+
status.hidden = true;
|
|
152
236
|
list.innerHTML = results.map(r => (
|
|
153
237
|
`<li>
|
|
154
|
-
<a href="${r.url}">${r.title}</a>
|
|
155
|
-
${r.excerpt ? `<div class="excerpt">${r.excerpt}</div>` : ''}
|
|
238
|
+
<a href="${escapeHtml(r.url)}">${escapeHtml(r.title)}</a>
|
|
239
|
+
${r.excerpt ? `<div class="excerpt">${escapeHtml(r.excerpt)}</div>` : ''}
|
|
156
240
|
</li>`
|
|
157
241
|
)).join('');
|
|
158
242
|
// Wire close on click and enter-to-go
|
|
159
243
|
list.querySelectorAll('a').forEach(a => a.onclick = () => window.CommuneSearch.close());
|
|
160
244
|
}
|
|
161
245
|
|
|
162
|
-
// SEARCH STRATEGY
|
|
163
|
-
// 1. Substring
|
|
164
|
-
//
|
|
165
|
-
//
|
|
246
|
+
// SEARCH STRATEGY:
|
|
247
|
+
// 1. Substring over `/backlinks.json` — always present, same-origin, built
|
|
248
|
+
// by the engine. Exact matching on title, summary, aliases and tags.
|
|
249
|
+
// 2. Semantic — present only when the page passed `semanticEndpoint`, in
|
|
250
|
+
// which case the block above defined `window.CommuneSemanticSearch`.
|
|
251
|
+
// With both, RRF merges them; with one, RRF preserves its order.
|
|
166
252
|
|
|
167
253
|
let t;
|
|
254
|
+
let queryToken = 0;
|
|
168
255
|
async function search(q) {
|
|
256
|
+
const token = ++queryToken;
|
|
257
|
+
const semanticTier = typeof window.CommuneSemanticSearch === 'function'
|
|
258
|
+
? window.CommuneSemanticSearch(q).catch(() => [])
|
|
259
|
+
: Promise.resolve([]);
|
|
169
260
|
const [substring, semantic] = await Promise.all([
|
|
170
|
-
doDevSearch(q),
|
|
171
|
-
|
|
261
|
+
doDevSearch(q),
|
|
262
|
+
semanticTier,
|
|
172
263
|
]);
|
|
264
|
+
// A slower earlier query must not overwrite a later one's results.
|
|
265
|
+
if (token !== queryToken) return;
|
|
173
266
|
let merged = rrf([substring, semantic]);
|
|
174
|
-
render(merged);
|
|
267
|
+
render(merged, q);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Focus belongs to the dialog while it is open: tabbing past the last
|
|
271
|
+
// control must not land on the page behind it, and closing must put focus
|
|
272
|
+
// back where the reader left it rather than at the top of the document.
|
|
273
|
+
let lastFocused = null;
|
|
274
|
+
|
|
275
|
+
const focusables = () => Array.from(
|
|
276
|
+
modal.querySelectorAll('a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])')
|
|
277
|
+
).filter((el) => !el.hasAttribute('disabled') && el.offsetParent !== null);
|
|
278
|
+
|
|
279
|
+
function trapTab(e) {
|
|
280
|
+
if (e.key !== 'Tab' || root.hidden) return;
|
|
281
|
+
const items = focusables();
|
|
282
|
+
if (!items.length) { e.preventDefault(); return; }
|
|
283
|
+
const first = items[0];
|
|
284
|
+
const last = items[items.length - 1];
|
|
285
|
+
const active = document.activeElement;
|
|
286
|
+
if (e.shiftKey && (active === first || !modal.contains(active))) {
|
|
287
|
+
e.preventDefault();
|
|
288
|
+
last.focus();
|
|
289
|
+
} else if (!e.shiftKey && (active === last || !modal.contains(active))) {
|
|
290
|
+
e.preventDefault();
|
|
291
|
+
first.focus();
|
|
292
|
+
}
|
|
175
293
|
}
|
|
176
294
|
|
|
177
295
|
function open() {
|
|
296
|
+
if (!root.hidden) return;
|
|
297
|
+
lastFocused = document.activeElement;
|
|
178
298
|
root.hidden = false;
|
|
179
299
|
document.body.classList.add('modal-open');
|
|
180
300
|
input.value = '';
|
|
181
|
-
|
|
301
|
+
queryToken++;
|
|
302
|
+
render([], '');
|
|
182
303
|
input.focus();
|
|
183
304
|
}
|
|
184
305
|
function close() {
|
|
306
|
+
if (root.hidden) return;
|
|
185
307
|
root.hidden = true;
|
|
186
308
|
document.body.classList.remove('modal-open');
|
|
309
|
+
queryToken++;
|
|
310
|
+
if (lastFocused instanceof HTMLElement && document.contains(lastFocused)) {
|
|
311
|
+
lastFocused.focus({ preventScroll: true });
|
|
312
|
+
}
|
|
313
|
+
lastFocused = null;
|
|
187
314
|
}
|
|
188
315
|
window.CommuneSearch = { open, close };
|
|
189
316
|
|
|
190
317
|
addEventListener('commune:openSearch', open);
|
|
191
318
|
addEventListener('keydown', e => {
|
|
192
|
-
const mac =
|
|
319
|
+
const mac = window.CommuneIsMac
|
|
320
|
+
? window.CommuneIsMac()
|
|
321
|
+
: /mac/i.test((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || '');
|
|
193
322
|
if ((mac ? e.metaKey : e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
194
323
|
e.preventDefault(); open();
|
|
195
324
|
}
|
|
196
325
|
if (e.key === 'Escape' && !root.hidden) close();
|
|
326
|
+
trapTab(e);
|
|
197
327
|
});
|
|
198
328
|
|
|
199
329
|
input.addEventListener('input', () => {
|
|
200
330
|
clearTimeout(t);
|
|
201
331
|
const q = input.value.trim();
|
|
332
|
+
// Clearing the box returns to the idle state at once rather than
|
|
333
|
+
// running an empty query 200ms later and calling it "no results".
|
|
334
|
+
if (!q) { queryToken++; render([], ''); return; }
|
|
202
335
|
t = setTimeout(() => search(q), 200);
|
|
203
336
|
});
|
|
204
337
|
|