Same-day shipping

5.0 Rating
500,000+ Customers
Trusted Since 2007
LK

Lenovo Thinkpad 20EF000JUS Laptop Keyboard Keys

Lenovo logo

Every kit ships complete.

Every kit ships with all 3 pieces: keycap, retainer clip, rubber cup

Keycap

Retainer clip

Rubber cup

Lenovo Thinkpad 20EF000JUS Laptop Keyboard Keys

Sometimes laptop keyboards can look the same on the outside, however they have different hinge styles underneath the keyboard keys. Please view the different hinge styles below and select the model number that matches your key. This process is necessary in order to send you the proper hinge type for your keyboard.

1

Step #1: Select your retainer clip model

Model # Regular Sized Key Larger Keys Smaller Keys
L107
L107 regular key
L107 large key
L107 small key
L117
L117 regular key
L117 large key
L117 small key
L123
L123 regular key
L123 large key
L123 small key
L476
L476 regular key
L476 large key
L476 small key

Keyboard Image

Full size
Lenovo Thinkpad 20EF000JUS full keyboard layout

Click image to enlarge.

Need the entire keyboard?

Replace the whole Lenovo Thinkpad 20EF000JUS keyboard.

Shop the full replacement keyboard at our sister site, laptopkeyboard.com — same-day shipping, lifetime warranty.

Shop full keyboard

500K+

Happy customers

192K+

Models stocked

5.0 ★

387 reviews

Same day

Worldwide shipping

') + ')', 'gi'); safe = safe.replace(re, ' '); } return safe; } // ===== Shared static index ===== // { brands: [name,…], series: [[name, brand_idx],…], models: [[name, series_idx, model_num?],…] } // Served as a STATIC JSON file (~800KB gzipped) — bypasses PHP/ModSec // so first-load TTFB is ~400ms, not 4s. Cached in memory + browser HTTP // cache, so subsequent searches and reloads are instant. // Shared across all forms via window.__lkSearchIdx. // Served from /search-data.json (NOT /api/...) so ad/privacy blockers // don't eat it. No force-cache: retry once bypassing the cache if the // first (possibly cached/blocked) response fails to load or parse. const INDEX_URL = '/search-data.json'; const loadIndex = () => { if (window.__lkSearchIdx) return window.__lkSearchIdx; const fetchJson = (opts) => fetch(INDEX_URL, opts).then((r) => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))); window.__lkSearchIdx = fetchJson() .catch(() => fetchJson({ cache: 'reload' })) .catch((err) => { window.__lkSearchIdx = null; throw err; }); return window.__lkSearchIdx; }; // ===== Background preload ===== // Start fetching the index as soon as the page is idle (or after 1s). // By the time the user clicks into the search bar, it's usually already // in memory — first keystroke is INSTANT, not a 1-second wait. const preload = () => { try { loadIndex(); } catch (_) {} }; if ('requestIdleCallback' in window) { window.requestIdleCallback(preload, { timeout: 2000 }); } else { setTimeout(preload, 1000); } // Normalize aggressively: lowercase + strip everything that isn't a letter // or digit. Makes "55-D6", "55 D6", "(55)D6" all match the query "55d6". const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, ''); // Subsequence test: does `needle` appear in `hay` in order, possibly // with gaps? Returns the gap sum if yes (lower = tighter), -1 if no. // Catches typos like "55d6" → "5567d6" via gap of 2. function subseqGap(needle, hay) { let ni = 0, last = -1, gap = 0; const nlen = needle.length, hlen = hay.length; for (let i = 0; i < hlen && ni < nlen; i++) { if (hay.charCodeAt(i) === needle.charCodeAt(ni)) { if (last >= 0) gap += i - last - 1; last = i; ni++; } } return ni === nlen ? gap : -1; } // Edit-distance-1 substring check: does `needle` appear in `hay` // allowing up to one edit (substitution, insertion, or deletion)? // Catches typos like "lenono" → "lenovo" (substitution) or // "thinkapd" → "thinkpad" (transposition counted as 2 edits but // the sub-block check still catches it via insertion+deletion within // a sliding window). Only triggered when straight substring + subseq // both fail — keeps the cheap paths cheap. function lev1Substring(needle, hay) { const nlen = needle.length, hlen = hay.length; if (nlen < 3) return false; // skip very short tokens (too lossy) // 1) Substitution (one char different at same position) if (nlen <= hlen) { const stop = hlen - nlen; for (let i = 0; i <= stop; i++) { let mm = 0, j = 0; for (; j < nlen; j++) { if (needle.charCodeAt(j) !== hay.charCodeAt(i + j)) { if (++mm > 1) break; } } if (j === nlen && mm <= 1) return true; } } // 2) Insertion in needle = needle is 1 longer than the matching span // (i.e. extra char user typed). Compare needle[..idx]+needle[idx+1..] // to hay window of length nlen-1. if (nlen - 1 <= hlen) { for (let idx = 0; idx < nlen; idx++) { const before = needle.slice(0, idx); const after = needle.slice(idx + 1); const len = before.length + after.length; // = nlen - 1 for (let i = 0; i <= hlen - len; i++) { let ok = true; for (let k = 0; k < before.length; k++) { if (before.charCodeAt(k) !== hay.charCodeAt(i + k)) { ok = false; break; } } if (!ok) continue; for (let k = 0; k < after.length; k++) { if (after.charCodeAt(k) !== hay.charCodeAt(i + before.length + k)) { ok = false; break; } } if (ok) return true; } } } return false; } // Build per-row normalized strings once, cache on the loaded index so // every subsequent keystroke just runs cheap string ops over them. function ensureNorm(idx) { if (idx.__rowNorm) return; const { brands, series, models } = idx; const rowNorm = new Array(models.length); const nameNorm = new Array(models.length); for (let i = 0; i < models.length; i++) { const m = models[i]; const ser = series[m[1]]; const b = ser ? brands[ser[1]] : ''; const sName = ser ? ser[0] : ''; nameNorm[i] = norm(m[0]); rowNorm[i] = nameNorm[i] + norm(b) + norm(sName) + norm(m[2] || ''); } idx.__rowNorm = rowNorm; idx.__nameNorm = nameNorm; } // Score one variant of a token against a row. Returns 0 = no match. // // Two tightenings vs. the naive subseq-anywhere approach: // (1) Tokens of 1–2 chars MUST be exact substrings — subseq for 1–2 // chars false-matches almost everything (e.g. "gd" subseq-matches // "logitech…keyboard" via the g in logitech + d in keyboard). // (2) Subseq matches must be REASONABLY tight: gap ≤ token-length × 3. // A 4-char token (e.g. "5536") can have up to 12 chars of gap. // Bigger gaps mean the letters happen to fall in the row in the // right order by coincidence, not because the row actually // contains the model the user typed. function scoreOneVariant(t, rowN) { const idx = rowN.indexOf(t); if (idx !== -1) { let s = 50; if (idx === 0) s += 10; return s; } if (t.length < 3) return 0; // (1) short tokens: substring-only const gap = subseqGap(t, rowN); const gapCap = t.length * 3; // (2) bound the gap if (gap >= 0 && gap <= gapCap) return Math.max(2, 25 - gap); if (lev1Substring(t, rowN)) return 12; return 0; } // Lightweight scorer. Tries (1) substring → (2) subsequence → (3) Lev-1 // edit-distance substring per token, falling through in order from // cheapest to most expensive. Every token must match somehow — but a // token's SYNONYM (e.g. "thinkpad" → "lenovo") also counts as a hit. function scoreRow(qFullNorm, tokensNorm, wsTokensNorm, rowN, nameN, modelLen) { // Strong override A: when the model NAME is a non-trivial prefix of the // (normalized) query and the query is strictly longer, the user is typing // a sub-spec under this base model — e.g. "t40-998" → Lenovo Thinkpad T40, // "5536-9988" → Acer Aspire 5536. Short-circuit token-by-token gating // (subspec parts like "998" usually aren't in the index) and return a high // score directly. A small length-weighted bias breaks ties so a more // specific name wins (T400 beats T40 for "t400-998"). if (nameN.length >= 3 && qFullNorm.length > nameN.length && qFullNorm.indexOf(nameN) === 0) { return 300 + nameN.length * 0.5; } // Strong override B: any query token EQUALS the model name — user // typed the model as a standalone piece among brand and sub-spec noise. // "acer 5536 ffe9984" → "5536" matches Acer Aspire 5536 (ws-token) // "acer5536gd6ts" → "5536" matches Acer Aspire 5536 (no-space // compound: split at letter↔digit boundaries // into ["acer","5536","gd","6","ts"]) // We check BOTH the whitespace-split tokens AND the letter↔digit-split // tokens so compound queries without whitespace still hit. Skip when // the whole query equals the name (normal exact-name path scores // that higher at +400). if (nameN.length >= 3 && qFullNorm !== nameN) { for (let i = 0; i < wsTokensNorm.length; i++) { if (wsTokensNorm[i] === nameN) return 280 + nameN.length * 0.5; } for (let i = 0; i < tokensNorm.length; i++) { if (tokensNorm[i] === nameN) return 280 + nameN.length * 0.5; } } let s = 0; // Whole-query contiguous substring is the strongest signal. if (qFullNorm && rowN.indexOf(qFullNorm) !== -1) s += 200; // Token-by-token coverage. for (let i = 0; i < tokensNorm.length; i++) { const t = tokensNorm[i]; let best = scoreOneVariant(t, rowN); // Try synonym(s) too — best-of-all wins. const syn = SYNONYMS[t]; if (syn) { const synParts = syn.split(/\s+/).filter(Boolean); let synScore = 0; for (const sp of synParts) { const v = scoreOneVariant(sp, rowN); if (v > 0) synScore += Math.max(2, v - 5); // slightly discounted } if (synScore > best) best = synScore; } if (best <= 0) return 0; // token has no match s += best; } // Name-specific bonuses. if (nameN === qFullNorm) s += 400; else if (nameN.indexOf(qFullNorm) === 0) s += 150; else if (nameN.indexOf(qFullNorm) !== -1) s += 60; // Shorter name = more specific match (good tie-break). s -= modelLen * 0.05; return s; } // Letter↔digit boundary split: "lenoot500" → ["lenoot","500"], // "t400s" → ["t","400","s"]. Mirrors how Google/Algolia auto-segment // compound queries. Lets us match even when the user types without // any separators between brand and model number. function splitLetterDigit(s) { const parts = []; if (!s) return parts; let start = 0; let prevIsDigit = /\d/.test(s[0]); for (let i = 1; i < s.length; i++) { const curIsDigit = /\d/.test(s[i]); if (curIsDigit !== prevIsDigit) { parts.push(s.slice(start, i)); start = i; prevIsDigit = curIsDigit; } } parts.push(s.slice(start)); return parts; } function searchIndex(idx, qFull) { const qFullNorm = norm(qFull); if (!qFullNorm) return []; // Split on whitespace first, then break each WS-token at letter↔digit // boundaries. If a token has a real boundary, we use its parts. // Otherwise keep the original token unchanged. const wsTokens = qFull.toLowerCase().split(/\s+/).map(norm).filter(Boolean); let tokensNorm = []; for (const t of wsTokens) { const parts = splitLetterDigit(t); if (parts.length > 1) { for (const p of parts) if (p) tokensNorm.push(p); } else { tokensNorm.push(t); } } if (!tokensNorm.length) return []; ensureNorm(idx); const { brands, series, models, __rowNorm, __nameNorm } = idx; const N = 7; const top = []; // min-heap-ish: top[0] is smallest score // Pre-filter: cheap check that AT LEAST ONE token has a chance. // We test the first (usually most-specific) token via subseq; rows // where it doesn't even appear loosely are skipped. The full scorer // does the real per-token Lev-1 work only on viable candidates. const firstTok = tokensNorm[0]; for (let i = 0; i < models.length; i++) { const m = models[i]; const rowN = __rowNorm[i]; if (subseqGap(firstTok, rowN) < 0) { // First token isn't even a loose subseq — try Lev-1 only for // 4+ char tokens to keep this fast. if (firstTok.length < 4 || !lev1Substring(firstTok, rowN)) continue; } const sc = scoreRow(qFullNorm, tokensNorm, wsTokens, rowN, __nameNorm[i], m[0].length); if (sc <= 0) continue; if (top.length < N) { top.push([sc, i]); if (top.length === N) top.sort((a, b) => a[0] - b[0]); } else if (sc > top[0][0]) { top[0] = [sc, i]; for (let j = 0; j < N - 1; j++) { if (top[j][0] > top[j + 1][0]) { const tmp = top[j]; top[j] = top[j + 1]; top[j + 1] = tmp; } } } } top.sort((a, b) => b[0] - a[0]); // When the top hit is a high-confidence name-prefix override (≥ 280), // drop the loose subseq/Lev-1 stragglers (score < 200) so the typeahead // stays focused on the actual base model the user was sub-specifying. const finalTop = (top.length && top[0][0] >= 280) ? top.filter(([s]) => s >= 200) : top; return finalTop.map(([score, i]) => { const m = models[i]; const ser = series[m[1]]; const brand = brands[ser[1]]; // Always link to the model-level page — visitor picks variant on the page. const url = `/KeyboardKeys.php/${encodeURIComponent(brand)}/${encodeURIComponent(ser[0])}/${encodeURIComponent(m[0])}`; return { brand, series: ser[0], model: m[0], model_num: m[2] || null, price: null, url, score }; }); } const forms = document.querySelectorAll('form[action="/search"]:not([data-no-typeahead])'); forms.forEach(attach); // ===== Global "/" keyboard shortcut → focus the first search box ===== // Same UX as GitHub / DocSearch. Ignored when the user is already // typing in any input/textarea/contenteditable element. document.addEventListener('keydown', (e) => { if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey) return; const t = e.target; if (!t) return; const tag = (t.tagName || '').toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select' || (t.isContentEditable)) return; const first = document.querySelector('form[action="/search"] input[type="search"]'); if (first) { e.preventDefault(); first.focus(); first.select(); } }); function attach(form) { const input = form.querySelector('input[type="search"]'); if (!input) return; // Portal the dropdown to with FIXED positioning so it can never // be clipped by an ancestor with overflow:hidden. The homepage hero + // final-CTA search boxes live inside `
` // (that clips their decorative blur blobs) — an absolutely-positioned // dropdown nested in the form was being cut off there, so it appeared // to "do nothing". Positioning off the form's bounding box, on , // sidesteps every clipping/stacking-context ancestor. const dd = document.createElement('div'); dd.className = 'lk-typeahead fixed z-50 rounded-2xl bg-white text-navy-900 shadow-2xl shadow-black/30 ring-1 ring-navy-200 overflow-hidden hidden'; dd.setAttribute('role', 'listbox'); document.body.appendChild(dd); let highlighted = -1; let cachedQ = ''; let cachedResults = []; // Pin the dropdown directly under the search box (8px gap), matching // its width. Recomputed every open + on scroll/resize while open. const place = () => { const r = form.getBoundingClientRect(); dd.style.left = r.left + 'px'; dd.style.top = (r.bottom + 8) + 'px'; dd.style.width = r.width + 'px'; }; const close = () => { dd.classList.add('hidden'); highlighted = -1; }; const open = () => { place(); dd.classList.remove('hidden'); }; window.addEventListener('scroll', () => { if (!dd.classList.contains('hidden')) place(); }, true); window.addEventListener('resize', () => { if (!dd.classList.contains('hidden')) place(); }); const renderEmpty = () => { const recent = getRecent(); const recentHtml = recent.length ? `

Recent searches

${recent.map((q) => ` `).join('')} ` : ''; const popHtml = `

${recent.length ? 'Popular' : 'Try one of these'}

${POPULAR.map((q) => ``).join('')}
`; dd.innerHTML = recentHtml + popHtml; open(); }; const render = (q, results, status, allTokens = []) => { if (status === 'loading') { dd.innerHTML = '
Loading instant search…
'; open(); return; } if (status === 'error') { dd.innerHTML = `
Search unavailable. Try the full search →
`; open(); return; } if (!results.length) { dd.innerHTML = `
No matches for "${escape(q)}". Browse by brand →
`; open(); return; } // Highlight tokens: use the original WS-split lowercased query (not the normalized stripped form) const hlTokens = q.toLowerCase().split(/\s+/).filter((t) => t.length >= 2); const head = `

Top matches

`; const rows = results.map((r, i) => { const partN = r.model_num ? `${highlight(r.model_num, hlTokens)}` : ''; const logo = brandLogo(r.brand); const logoHtml = logo ? `` : ''; return ` ${logoHtml}

${highlight(r.brand, hlTokens)} · ${highlight(r.series, hlTokens)}

${partN}

${highlight(r.model, hlTokens)}

`; }).join(''); const footer = `See all results →`; dd.innerHTML = head + rows + footer; open(); }; // Renamed from `highlight` so it doesn't shadow the outer // term-highlighting helper used inside render(). const setHighlightedRow = (idx) => { const rows = dd.querySelectorAll('.lk-ta-row'); rows.forEach((r) => r.classList.remove('bg-accent-50')); if (idx >= 0 && idx < rows.length) { rows[idx].classList.add('bg-accent-50'); rows[idx].scrollIntoView({ block: 'nearest' }); highlighted = idx; } else { highlighted = -1; } }; // Search with trim-from-end fallback: if a query has no matches, // drop one character off the end and retry until we find some (or // hit a 3-char floor). Lets a long/over-specific part number like // "5536-99887" still find the base "5536". const searchTrim = (idx, q0) => { let attempt = q0; let results = searchIndex(idx, attempt); while (!results.length && attempt.length > 3) { attempt = attempt.slice(0, -1).trim(); if (attempt.length < 3) break; results = searchIndex(idx, attempt); } return results; }; // The hot path: on every keystroke, search the in-memory index synchronously. input.addEventListener('input', async () => { const q = input.value.trim(); if (!q) { close(); cachedQ = ''; return; } if (q === cachedQ) return; cachedQ = q; try { const idx = window.__lkSearchIdx ? await window.__lkSearchIdx : null; if (idx) { cachedResults = searchTrim(idx, q); render(q, cachedResults, 'ok'); return; } // First keystroke: kick off the index load. Show a brief loading state. render(q, [], 'loading'); const loaded = await loadIndex(); // User may have kept typing — use the LATEST query, not q. const qNow = input.value.trim(); if (!qNow) { close(); return; } cachedQ = qNow; cachedResults = searchTrim(loaded, qNow); render(qNow, cachedResults, 'ok'); } catch (err) { render(q, [], 'error'); } }); input.addEventListener('focus', () => { const q = input.value.trim(); if (q && cachedResults.length) render(q, cachedResults, 'ok'); else if (!q) renderEmpty(); }); // Click a recent / popular chip → put it in the box + run search. dd.addEventListener('click', (e) => { const btn = e.target && e.target.closest && e.target.closest('[data-recent]'); if (!btn) return; e.preventDefault(); const q = btn.getAttribute('data-recent'); input.value = q; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); }); // Persist on submit (Enter / click Search). form.addEventListener('submit', () => { const q = input.value.trim(); if (q) pushRecent(q); }); input.addEventListener('keydown', (e) => { const rows = dd.querySelectorAll('.lk-ta-row'); if (dd.classList.contains('hidden') || rows.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); setHighlightedRow((highlighted + 1) % rows.length); } else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlightedRow((highlighted - 1 + rows.length) % rows.length); } else if (e.key === 'Enter' && highlighted >= 0) { e.preventDefault(); window.location.href = rows[highlighted].href; } else if (e.key === 'Escape') { close(); input.blur(); } }); document.addEventListener('mousedown', (e) => { if (!form.contains(e.target) && !dd.contains(e.target)) close(); }); } })(); ') + ')', 'gi'); safe = safe.replace(re, ' '); } return safe; } // ===== Shared static index ===== // { brands: [name,…], series: [[name, brand_idx],…], models: [[name, series_idx, model_num?],…] } // Served as a STATIC JSON file (~800KB gzipped) — bypasses PHP/ModSec // so first-load TTFB is ~400ms, not 4s. Cached in memory + browser HTTP // cache, so subsequent searches and reloads are instant. // Shared across all forms via window.__lkSearchIdx. // Served from /search-data.json (NOT /api/...) so ad/privacy blockers // don't eat it. No force-cache: retry once bypassing the cache if the // first (possibly cached/blocked) response fails to load or parse. const INDEX_URL = '/search-data.json'; const loadIndex = () => { if (window.__lkSearchIdx) return window.__lkSearchIdx; const fetchJson = (opts) => fetch(INDEX_URL, opts).then((r) => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))); window.__lkSearchIdx = fetchJson() .catch(() => fetchJson({ cache: 'reload' })) .catch((err) => { window.__lkSearchIdx = null; throw err; }); return window.__lkSearchIdx; }; // ===== Background preload ===== // Start fetching the index as soon as the page is idle (or after 1s). // By the time the user clicks into the search bar, it's usually already // in memory — first keystroke is INSTANT, not a 1-second wait. const preload = () => { try { loadIndex(); } catch (_) {} }; if ('requestIdleCallback' in window) { window.requestIdleCallback(preload, { timeout: 2000 }); } else { setTimeout(preload, 1000); } // Normalize aggressively: lowercase + strip everything that isn't a letter // or digit. Makes "55-D6", "55 D6", "(55)D6" all match the query "55d6". const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, ''); // Subsequence test: does `needle` appear in `hay` in order, possibly // with gaps? Returns the gap sum if yes (lower = tighter), -1 if no. // Catches typos like "55d6" → "5567d6" via gap of 2. function subseqGap(needle, hay) { let ni = 0, last = -1, gap = 0; const nlen = needle.length, hlen = hay.length; for (let i = 0; i < hlen && ni < nlen; i++) { if (hay.charCodeAt(i) === needle.charCodeAt(ni)) { if (last >= 0) gap += i - last - 1; last = i; ni++; } } return ni === nlen ? gap : -1; } // Edit-distance-1 substring check: does `needle` appear in `hay` // allowing up to one edit (substitution, insertion, or deletion)? // Catches typos like "lenono" → "lenovo" (substitution) or // "thinkapd" → "thinkpad" (transposition counted as 2 edits but // the sub-block check still catches it via insertion+deletion within // a sliding window). Only triggered when straight substring + subseq // both fail — keeps the cheap paths cheap. function lev1Substring(needle, hay) { const nlen = needle.length, hlen = hay.length; if (nlen < 3) return false; // skip very short tokens (too lossy) // 1) Substitution (one char different at same position) if (nlen <= hlen) { const stop = hlen - nlen; for (let i = 0; i <= stop; i++) { let mm = 0, j = 0; for (; j < nlen; j++) { if (needle.charCodeAt(j) !== hay.charCodeAt(i + j)) { if (++mm > 1) break; } } if (j === nlen && mm <= 1) return true; } } // 2) Insertion in needle = needle is 1 longer than the matching span // (i.e. extra char user typed). Compare needle[..idx]+needle[idx+1..] // to hay window of length nlen-1. if (nlen - 1 <= hlen) { for (let idx = 0; idx < nlen; idx++) { const before = needle.slice(0, idx); const after = needle.slice(idx + 1); const len = before.length + after.length; // = nlen - 1 for (let i = 0; i <= hlen - len; i++) { let ok = true; for (let k = 0; k < before.length; k++) { if (before.charCodeAt(k) !== hay.charCodeAt(i + k)) { ok = false; break; } } if (!ok) continue; for (let k = 0; k < after.length; k++) { if (after.charCodeAt(k) !== hay.charCodeAt(i + before.length + k)) { ok = false; break; } } if (ok) return true; } } } return false; } // Build per-row normalized strings once, cache on the loaded index so // every subsequent keystroke just runs cheap string ops over them. function ensureNorm(idx) { if (idx.__rowNorm) return; const { brands, series, models } = idx; const rowNorm = new Array(models.length); const nameNorm = new Array(models.length); for (let i = 0; i < models.length; i++) { const m = models[i]; const ser = series[m[1]]; const b = ser ? brands[ser[1]] : ''; const sName = ser ? ser[0] : ''; nameNorm[i] = norm(m[0]); rowNorm[i] = nameNorm[i] + norm(b) + norm(sName) + norm(m[2] || ''); } idx.__rowNorm = rowNorm; idx.__nameNorm = nameNorm; } // Score one variant of a token against a row. Returns 0 = no match. // // Two tightenings vs. the naive subseq-anywhere approach: // (1) Tokens of 1–2 chars MUST be exact substrings — subseq for 1–2 // chars false-matches almost everything (e.g. "gd" subseq-matches // "logitech…keyboard" via the g in logitech + d in keyboard). // (2) Subseq matches must be REASONABLY tight: gap ≤ token-length × 3. // A 4-char token (e.g. "5536") can have up to 12 chars of gap. // Bigger gaps mean the letters happen to fall in the row in the // right order by coincidence, not because the row actually // contains the model the user typed. function scoreOneVariant(t, rowN) { const idx = rowN.indexOf(t); if (idx !== -1) { let s = 50; if (idx === 0) s += 10; return s; } if (t.length < 3) return 0; // (1) short tokens: substring-only const gap = subseqGap(t, rowN); const gapCap = t.length * 3; // (2) bound the gap if (gap >= 0 && gap <= gapCap) return Math.max(2, 25 - gap); if (lev1Substring(t, rowN)) return 12; return 0; } // Lightweight scorer. Tries (1) substring → (2) subsequence → (3) Lev-1 // edit-distance substring per token, falling through in order from // cheapest to most expensive. Every token must match somehow — but a // token's SYNONYM (e.g. "thinkpad" → "lenovo") also counts as a hit. function scoreRow(qFullNorm, tokensNorm, wsTokensNorm, rowN, nameN, modelLen) { // Strong override A: when the model NAME is a non-trivial prefix of the // (normalized) query and the query is strictly longer, the user is typing // a sub-spec under this base model — e.g. "t40-998" → Lenovo Thinkpad T40, // "5536-9988" → Acer Aspire 5536. Short-circuit token-by-token gating // (subspec parts like "998" usually aren't in the index) and return a high // score directly. A small length-weighted bias breaks ties so a more // specific name wins (T400 beats T40 for "t400-998"). if (nameN.length >= 3 && qFullNorm.length > nameN.length && qFullNorm.indexOf(nameN) === 0) { return 300 + nameN.length * 0.5; } // Strong override B: any query token EQUALS the model name — user // typed the model as a standalone piece among brand and sub-spec noise. // "acer 5536 ffe9984" → "5536" matches Acer Aspire 5536 (ws-token) // "acer5536gd6ts" → "5536" matches Acer Aspire 5536 (no-space // compound: split at letter↔digit boundaries // into ["acer","5536","gd","6","ts"]) // We check BOTH the whitespace-split tokens AND the letter↔digit-split // tokens so compound queries without whitespace still hit. Skip when // the whole query equals the name (normal exact-name path scores // that higher at +400). if (nameN.length >= 3 && qFullNorm !== nameN) { for (let i = 0; i < wsTokensNorm.length; i++) { if (wsTokensNorm[i] === nameN) return 280 + nameN.length * 0.5; } for (let i = 0; i < tokensNorm.length; i++) { if (tokensNorm[i] === nameN) return 280 + nameN.length * 0.5; } } let s = 0; // Whole-query contiguous substring is the strongest signal. if (qFullNorm && rowN.indexOf(qFullNorm) !== -1) s += 200; // Token-by-token coverage. for (let i = 0; i < tokensNorm.length; i++) { const t = tokensNorm[i]; let best = scoreOneVariant(t, rowN); // Try synonym(s) too — best-of-all wins. const syn = SYNONYMS[t]; if (syn) { const synParts = syn.split(/\s+/).filter(Boolean); let synScore = 0; for (const sp of synParts) { const v = scoreOneVariant(sp, rowN); if (v > 0) synScore += Math.max(2, v - 5); // slightly discounted } if (synScore > best) best = synScore; } if (best <= 0) return 0; // token has no match s += best; } // Name-specific bonuses. if (nameN === qFullNorm) s += 400; else if (nameN.indexOf(qFullNorm) === 0) s += 150; else if (nameN.indexOf(qFullNorm) !== -1) s += 60; // Shorter name = more specific match (good tie-break). s -= modelLen * 0.05; return s; } // Letter↔digit boundary split: "lenoot500" → ["lenoot","500"], // "t400s" → ["t","400","s"]. Mirrors how Google/Algolia auto-segment // compound queries. Lets us match even when the user types without // any separators between brand and model number. function splitLetterDigit(s) { const parts = []; if (!s) return parts; let start = 0; let prevIsDigit = /\d/.test(s[0]); for (let i = 1; i < s.length; i++) { const curIsDigit = /\d/.test(s[i]); if (curIsDigit !== prevIsDigit) { parts.push(s.slice(start, i)); start = i; prevIsDigit = curIsDigit; } } parts.push(s.slice(start)); return parts; } function searchIndex(idx, qFull) { const qFullNorm = norm(qFull); if (!qFullNorm) return []; // Split on whitespace first, then break each WS-token at letter↔digit // boundaries. If a token has a real boundary, we use its parts. // Otherwise keep the original token unchanged. const wsTokens = qFull.toLowerCase().split(/\s+/).map(norm).filter(Boolean); let tokensNorm = []; for (const t of wsTokens) { const parts = splitLetterDigit(t); if (parts.length > 1) { for (const p of parts) if (p) tokensNorm.push(p); } else { tokensNorm.push(t); } } if (!tokensNorm.length) return []; ensureNorm(idx); const { brands, series, models, __rowNorm, __nameNorm } = idx; const N = 7; const top = []; // min-heap-ish: top[0] is smallest score // Pre-filter: cheap check that AT LEAST ONE token has a chance. // We test the first (usually most-specific) token via subseq; rows // where it doesn't even appear loosely are skipped. The full scorer // does the real per-token Lev-1 work only on viable candidates. const firstTok = tokensNorm[0]; for (let i = 0; i < models.length; i++) { const m = models[i]; const rowN = __rowNorm[i]; if (subseqGap(firstTok, rowN) < 0) { // First token isn't even a loose subseq — try Lev-1 only for // 4+ char tokens to keep this fast. if (firstTok.length < 4 || !lev1Substring(firstTok, rowN)) continue; } const sc = scoreRow(qFullNorm, tokensNorm, wsTokens, rowN, __nameNorm[i], m[0].length); if (sc <= 0) continue; if (top.length < N) { top.push([sc, i]); if (top.length === N) top.sort((a, b) => a[0] - b[0]); } else if (sc > top[0][0]) { top[0] = [sc, i]; for (let j = 0; j < N - 1; j++) { if (top[j][0] > top[j + 1][0]) { const tmp = top[j]; top[j] = top[j + 1]; top[j + 1] = tmp; } } } } top.sort((a, b) => b[0] - a[0]); // When the top hit is a high-confidence name-prefix override (≥ 280), // drop the loose subseq/Lev-1 stragglers (score < 200) so the typeahead // stays focused on the actual base model the user was sub-specifying. const finalTop = (top.length && top[0][0] >= 280) ? top.filter(([s]) => s >= 200) : top; return finalTop.map(([score, i]) => { const m = models[i]; const ser = series[m[1]]; const brand = brands[ser[1]]; // Always link to the model-level page — visitor picks variant on the page. const url = `/KeyboardKeys.php/${encodeURIComponent(brand)}/${encodeURIComponent(ser[0])}/${encodeURIComponent(m[0])}`; return { brand, series: ser[0], model: m[0], model_num: m[2] || null, price: null, url, score }; }); } const forms = document.querySelectorAll('form[action="/search"]:not([data-no-typeahead])'); forms.forEach(attach); // ===== Global "/" keyboard shortcut → focus the first search box ===== // Same UX as GitHub / DocSearch. Ignored when the user is already // typing in any input/textarea/contenteditable element. document.addEventListener('keydown', (e) => { if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey) return; const t = e.target; if (!t) return; const tag = (t.tagName || '').toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select' || (t.isContentEditable)) return; const first = document.querySelector('form[action="/search"] input[type="search"]'); if (first) { e.preventDefault(); first.focus(); first.select(); } }); function attach(form) { const input = form.querySelector('input[type="search"]'); if (!input) return; // Portal the dropdown to with FIXED positioning so it can never // be clipped by an ancestor with overflow:hidden. The homepage hero + // final-CTA search boxes live inside `
` // (that clips their decorative blur blobs) — an absolutely-positioned // dropdown nested in the form was being cut off there, so it appeared // to "do nothing". Positioning off the form's bounding box, on , // sidesteps every clipping/stacking-context ancestor. const dd = document.createElement('div'); dd.className = 'lk-typeahead fixed z-50 rounded-2xl bg-white text-navy-900 shadow-2xl shadow-black/30 ring-1 ring-navy-200 overflow-hidden hidden'; dd.setAttribute('role', 'listbox'); document.body.appendChild(dd); let highlighted = -1; let cachedQ = ''; let cachedResults = []; // Pin the dropdown directly under the search box (8px gap), matching // its width. Recomputed every open + on scroll/resize while open. const place = () => { const r = form.getBoundingClientRect(); dd.style.left = r.left + 'px'; dd.style.top = (r.bottom + 8) + 'px'; dd.style.width = r.width + 'px'; }; const close = () => { dd.classList.add('hidden'); highlighted = -1; }; const open = () => { place(); dd.classList.remove('hidden'); }; window.addEventListener('scroll', () => { if (!dd.classList.contains('hidden')) place(); }, true); window.addEventListener('resize', () => { if (!dd.classList.contains('hidden')) place(); }); const renderEmpty = () => { const recent = getRecent(); const recentHtml = recent.length ? `

Recent searches

${recent.map((q) => ` `).join('')} ` : ''; const popHtml = `

${recent.length ? 'Popular' : 'Try one of these'}

${POPULAR.map((q) => ``).join('')}
`; dd.innerHTML = recentHtml + popHtml; open(); }; const render = (q, results, status, allTokens = []) => { if (status === 'loading') { dd.innerHTML = '
Loading instant search…
'; open(); return; } if (status === 'error') { dd.innerHTML = `
Search unavailable. Try the full search →
`; open(); return; } if (!results.length) { dd.innerHTML = `
No matches for "${escape(q)}". Browse by brand →
`; open(); return; } // Highlight tokens: use the original WS-split lowercased query (not the normalized stripped form) const hlTokens = q.toLowerCase().split(/\s+/).filter((t) => t.length >= 2); const head = `

Top matches

`; const rows = results.map((r, i) => { const partN = r.model_num ? `${highlight(r.model_num, hlTokens)}` : ''; const logo = brandLogo(r.brand); const logoHtml = logo ? `` : ''; return ` ${logoHtml}

${highlight(r.brand, hlTokens)} · ${highlight(r.series, hlTokens)}

${partN}

${highlight(r.model, hlTokens)}

`; }).join(''); const footer = `See all results →`; dd.innerHTML = head + rows + footer; open(); }; // Renamed from `highlight` so it doesn't shadow the outer // term-highlighting helper used inside render(). const setHighlightedRow = (idx) => { const rows = dd.querySelectorAll('.lk-ta-row'); rows.forEach((r) => r.classList.remove('bg-accent-50')); if (idx >= 0 && idx < rows.length) { rows[idx].classList.add('bg-accent-50'); rows[idx].scrollIntoView({ block: 'nearest' }); highlighted = idx; } else { highlighted = -1; } }; // Search with trim-from-end fallback: if a query has no matches, // drop one character off the end and retry until we find some (or // hit a 3-char floor). Lets a long/over-specific part number like // "5536-99887" still find the base "5536". const searchTrim = (idx, q0) => { let attempt = q0; let results = searchIndex(idx, attempt); while (!results.length && attempt.length > 3) { attempt = attempt.slice(0, -1).trim(); if (attempt.length < 3) break; results = searchIndex(idx, attempt); } return results; }; // The hot path: on every keystroke, search the in-memory index synchronously. input.addEventListener('input', async () => { const q = input.value.trim(); if (!q) { close(); cachedQ = ''; return; } if (q === cachedQ) return; cachedQ = q; try { const idx = window.__lkSearchIdx ? await window.__lkSearchIdx : null; if (idx) { cachedResults = searchTrim(idx, q); render(q, cachedResults, 'ok'); return; } // First keystroke: kick off the index load. Show a brief loading state. render(q, [], 'loading'); const loaded = await loadIndex(); // User may have kept typing — use the LATEST query, not q. const qNow = input.value.trim(); if (!qNow) { close(); return; } cachedQ = qNow; cachedResults = searchTrim(loaded, qNow); render(qNow, cachedResults, 'ok'); } catch (err) { render(q, [], 'error'); } }); input.addEventListener('focus', () => { const q = input.value.trim(); if (q && cachedResults.length) render(q, cachedResults, 'ok'); else if (!q) renderEmpty(); }); // Click a recent / popular chip → put it in the box + run search. dd.addEventListener('click', (e) => { const btn = e.target && e.target.closest && e.target.closest('[data-recent]'); if (!btn) return; e.preventDefault(); const q = btn.getAttribute('data-recent'); input.value = q; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); }); // Persist on submit (Enter / click Search). form.addEventListener('submit', () => { const q = input.value.trim(); if (q) pushRecent(q); }); input.addEventListener('keydown', (e) => { const rows = dd.querySelectorAll('.lk-ta-row'); if (dd.classList.contains('hidden') || rows.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); setHighlightedRow((highlighted + 1) % rows.length); } else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlightedRow((highlighted - 1 + rows.length) % rows.length); } else if (e.key === 'Enter' && highlighted >= 0) { e.preventDefault(); window.location.href = rows[highlighted].href; } else if (e.key === 'Escape') { close(); input.blur(); } }); document.addEventListener('mousedown', (e) => { if (!form.contains(e.target) && !dd.contains(e.target)) close(); }); } })();