Skip to content

Commit 91e05a8

Browse files
authored
feat: add keyboard shortcut and enhance input styling (#220)
1 parent 98774b1 commit 91e05a8

File tree

1 file changed

+150
-23
lines changed

1 file changed

+150
-23
lines changed

theme/js/osl-search.js

Lines changed: 150 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,57 @@
7171
opacity: .8;
7272
}
7373
.osl-search-mark { background: rgba(255, 208, 0, .35); border-radius: .2rem; }
74+
75+
/* Added outline and keyboard focus enhancements */
76+
#mkdocs-search,
77+
#mkdocs-search-mobile {
78+
outline: 1px solid #e0e0e0;
79+
outline-offset: 2px;
80+
transition: all 0.2s ease;
81+
border: 1px solid var(--border-color, rgba(255,255,255,.12));
82+
border-radius: 6px;
83+
}
84+
85+
#mkdocs-search:hover,
86+
#mkdocs-search-mobile:hover {
87+
border-color: var(--md-primary-fg-color, #1976d2);
88+
}
89+
90+
#mkdocs-search:focus,
91+
#mkdocs-search-mobile:focus {
92+
outline: 2px solid var(--md-primary-fg-color, #1976d2);
93+
border-color: var(--md-primary-fg-color, #1976d2);
94+
box-shadow: 0 0 0 4px var(--md-primary-fg-color-transparent, rgba(25, 118, 210, 0.1));
95+
}
96+
97+
/* Search container styles */
98+
.search-container {
99+
position: relative;
100+
display: inline-block;
101+
}
102+
103+
.search-container:focus-within .search-hint {
104+
opacity: 1;
105+
}
106+
107+
/* Add keyboard shortcut hint */
108+
.search-hint {
109+
position: absolute;
110+
right: 10px;
111+
top: 50%;
112+
transform: translateY(-50%);
113+
font-size: 12px;
114+
opacity: 0.6;
115+
pointer-events: none;
116+
transition: opacity 0.2s ease;
117+
}
118+
119+
/* Hide hint when input has content */
120+
.search-container input:not(:placeholder-shown) + .search-hint,
121+
.search-container input:focus + .search-hint {
122+
opacity: 0;
123+
visibility: hidden;
124+
}
74125
`;
75126
const style = document.createElement('style');
76127
style.id = 'osl-search-styles';
@@ -95,14 +146,14 @@
95146
if (!res.ok) throw new Error(`Failed to fetch ${INDEX_URL}: ${res.status}`);
96147
const json = await res.json();
97148
// MkDocs "search" plugin typically returns { docs: [...] }
98-
return Array.isArray(json) ? json : (json.docs || []);
149+
return Array.isArray(json) ? json : json.docs || [];
99150
}
100151

101152
function normalizeDocs(arr) {
102-
return arr.map(d => ({
153+
return arr.map((d) => ({
103154
title: d.title || '',
104155
text: d.text || '',
105-
location: d.location || ''
156+
location: d.location || '',
106157
}));
107158
}
108159

@@ -114,20 +165,27 @@
114165

115166
// find first occurrence of any term (basic, case-insensitive)
116167
const terms = q.split(/\s+/).filter(Boolean);
117-
let hit = -1, termUsed = '';
168+
let hit = -1,
169+
termUsed = '';
118170
for (const t of terms) {
119171
const idx = text.toLowerCase().indexOf(t.toLowerCase());
120-
if (idx !== -1 && (hit === -1 || idx < hit)) { hit = idx; termUsed = t; }
172+
if (idx !== -1 && (hit === -1 || idx < hit)) {
173+
hit = idx;
174+
termUsed = t;
175+
}
121176
}
122177
if (hit === -1) {
123178
return text.slice(0, MAX) + (text.length > MAX ? '…' : '');
124179
}
125180
const start = Math.max(0, hit - 40);
126181
const end = Math.min(text.length, hit + 120);
127-
let snip = (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '');
182+
let snip =
183+
(start > 0 ? '…' : '') +
184+
text.slice(start, end) +
185+
(end < text.length ? '…' : '');
128186

129187
// simple highlight for all terms
130-
terms.forEach(t => {
188+
terms.forEach((t) => {
131189
if (!t) return;
132190
const re = new RegExp(`(${escapeRegExp(t)})`, 'ig');
133191
snip = snip.replace(re, '<span class="osl-search-mark">$1</span>');
@@ -148,7 +206,7 @@
148206

149207
const raw = await fetchIndexJSON();
150208
_docs = normalizeDocs(raw);
151-
_byRef = new Map(_docs.map(d => [d.location, d]));
209+
_byRef = new Map(_docs.map((d) => [d.location, d]));
152210

153211
// Build index
154212
const hasMulti = typeof lunr.multiLanguage === 'function';
@@ -161,7 +219,7 @@
161219
this.field('title', { boost: 10 });
162220
this.field('text');
163221

164-
_docs.forEach(doc => this.add(doc));
222+
_docs.forEach((doc) => this.add(doc));
165223
});
166224

167225
return _idx;
@@ -180,18 +238,29 @@
180238
// Keep it simple; allow prefix matches
181239
let q = query.trim();
182240
// Improve small queries a bit: foo -> foo*
183-
if (!/[~^*]/.test(q)) q = q.split(/\s+/).map(t => t + '*').join(' ');
241+
if (!/[~^*]/.test(q))
242+
q = q
243+
.split(/\s+/)
244+
.map((t) => t + '*')
245+
.join(' ');
184246
let hits = [];
185247
try {
186248
hits = _idx.search(q);
187249
} catch (e) {
188250
// fallback: plain search without wildcard if syntax error
189-
try { hits = _idx.search(query); } catch (_e) { hits = []; }
251+
try {
252+
hits = _idx.search(query);
253+
} catch (_e) {
254+
hits = [];
255+
}
190256
}
191-
return hits.slice(0, MAX_RESULTS).map(h => {
192-
const doc = _byRef.get(h.ref);
193-
return doc ? { doc, score: h.score } : null;
194-
}).filter(Boolean);
257+
return hits
258+
.slice(0, MAX_RESULTS)
259+
.map((h) => {
260+
const doc = _byRef.get(h.ref);
261+
return doc ? { doc, score: h.score } : null;
262+
})
263+
.filter(Boolean);
195264
}
196265

197266
// --- UI (panel) ---------------------------------------------------------
@@ -220,7 +289,10 @@
220289
if (!items.length) {
221290
const empty = document.createElement('div');
222291
empty.className = 'osl-search-empty';
223-
empty.textContent = (rawQuery && rawQuery.length >= MIN_QUERY_LEN) ? 'No results' : 'Type to search…';
292+
empty.textContent =
293+
rawQuery && rawQuery.length >= MIN_QUERY_LEN
294+
? 'No results'
295+
: 'Type to search…';
224296
panel.appendChild(empty);
225297
return;
226298
}
@@ -247,7 +319,7 @@
247319
function activateItem(panel, nextIndex) {
248320
const items = Array.from(panel.querySelectorAll('.osl-search-item'));
249321
if (!items.length) return -1;
250-
items.forEach(el => el.classList.remove('is-active'));
322+
items.forEach((el) => el.classList.remove('is-active'));
251323
const idx = Math.max(0, Math.min(nextIndex, items.length - 1));
252324
items[idx].classList.add('is-active');
253325
items[idx].scrollIntoView({ block: 'nearest' });
@@ -260,8 +332,9 @@
260332
}
261333

262334
function navigateActive(panel) {
263-
const active = panel.querySelector('.osl-search-item.is-active') ||
264-
panel.querySelector('.osl-search-item');
335+
const active =
336+
panel.querySelector('.osl-search-item.is-active') ||
337+
panel.querySelector('.osl-search-item');
265338
if (active) window.location.assign(active.href);
266339
}
267340

@@ -276,6 +349,11 @@
276349
if (!inputEl || inputEl.__oslWired__) return;
277350
inputEl.__oslWired__ = true;
278351

352+
// Add ARIA attributes
353+
inputEl.setAttribute('role', 'searchbox');
354+
inputEl.setAttribute('aria-label', 'Search');
355+
inputEl.setAttribute('aria-expanded', 'false');
356+
279357
injectBaseStylesOnce();
280358
const panel = mkPanel();
281359
let lastQuery = '';
@@ -284,12 +362,18 @@
284362
function openPanel() {
285363
positionPanel(panel, inputEl);
286364
panel.style.display = 'block';
365+
inputEl.setAttribute('aria-expanded', 'true');
287366
}
288367

289368
function updatePosition() {
290369
if (panel.style.display !== 'none') positionPanel(panel, inputEl);
291370
}
292371

372+
function closePanel(panel) {
373+
panel.style.display = 'none';
374+
panel.innerHTML = '';
375+
inputEl.setAttribute('aria-expanded', 'false');
376+
}
293377
// Debounce to keep it snappy
294378
let t = null;
295379
function onInput() {
@@ -318,7 +402,10 @@
318402
}
319403

320404
function onKey(e) {
321-
if (panel.style.display === 'none' && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
405+
if (
406+
panel.style.display === 'none' &&
407+
(e.key === 'ArrowDown' || e.key === 'ArrowUp')
408+
) {
322409
openPanel();
323410
}
324411
switch (e.key) {
@@ -397,6 +484,21 @@
397484
inputEl.addEventListener('blur', onBlur);
398485
}
399486

487+
function setupKeyboardShortcuts() {
488+
document.addEventListener('keydown', (e) => {
489+
// Check for Ctrl+K or Cmd+K
490+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
491+
e.preventDefault();
492+
const searchInput =
493+
document.getElementById('mkdocs-search') ||
494+
document.getElementById('mkdocs-search-mobile');
495+
if (searchInput) {
496+
searchInput.focus();
497+
}
498+
}
499+
});
500+
}
501+
400502
// Public initializer (used by theme.js)
401503
window.initSearch = function (inputEl) {
402504
if (!inputEl) return;
@@ -407,8 +509,33 @@
407509
document.addEventListener('DOMContentLoaded', () => {
408510
const desktop = document.getElementById('mkdocs-search');
409511
const mobile = document.getElementById('mkdocs-search-mobile');
410-
if (desktop) wireInput(desktop);
411-
if (mobile) wireInput(mobile);
412-
});
413512

513+
if (desktop) {
514+
// Wrap search input in container
515+
const container = document.createElement('div');
516+
container.className = 'search-container';
517+
desktop.parentNode.insertBefore(container, desktop);
518+
container.appendChild(desktop);
519+
520+
wireInput(desktop);
521+
522+
// Add keyboard shortcut hint inside container
523+
const hint = document.createElement('span');
524+
hint.className = 'search-hint';
525+
hint.textContent = navigator.platform.includes('Mac') ? '⌘K' : 'Ctrl+K';
526+
container.appendChild(hint);
527+
}
528+
529+
if (mobile) {
530+
// Wrap mobile search input in container
531+
const container = document.createElement('div');
532+
container.className = 'search-container';
533+
mobile.parentNode.insertBefore(container, mobile);
534+
container.appendChild(mobile);
535+
536+
wireInput(mobile);
537+
}
538+
539+
setupKeyboardShortcuts();
540+
});
414541
})();

0 commit comments

Comments
 (0)