Adds a filter field to the "Add this repository to a list" dialog so you can find lists without scrolling.
// ==UserScript==
// @name GitHub — search star lists
// @namespace https://github.com/leocaseiro/leocaseiro-userscripts
// @version 1.0.0
// @description Adds a filter field to the "Add this repository to a list" dialog so you can find lists without scrolling.
// @author leocaseiro
// @homepageURL https://github.com/leocaseiro/leocaseiro-userscripts/tree/main/scripts/github-star-list-search
// @supportURL https://github.com/leocaseiro/leocaseiro-userscripts/issues
// @match https://github.com/*
// @match https://gist.github.com/*
// @icon https://github.githubassets.com/favicons/favicon.svg
// @run-at document-idle
// @grant none
// ==/UserScript==
(function () {
'use strict';
const WRAP_ATTR = 'data-gh-star-list-search';
const INPUT_ATTR = 'data-gh-star-list-search-input';
const DEBOUNCE_MS = 200;
const listObservers = new WeakMap();
const lifecycleAttached = new WeakSet();
function debounce(fn, delay) {
let timer = null;
return (...args) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
/**
* Star-list picker dialog: id ends with -starred-dialog (repo id is in the middle).
*/
function isStarListDialog(dialog) {
if (!(dialog instanceof HTMLDialogElement) || !dialog.open) return false;
if (!dialog.id || !dialog.id.endsWith('-starred-dialog')) return false;
const title = dialog.querySelector('h1.Overlay-title');
return title?.textContent?.trim() === 'Lists';
}
function getListRoot(dialog) {
return dialog.querySelector('ul.ActionListWrap[role="listbox"]');
}
function filterListItems(dialog, rawTerm) {
const ul = getListRoot(dialog);
if (!ul) return;
const term = rawTerm.trim().toLowerCase();
ul.querySelectorAll('li.ActionListItem').forEach((li) => {
const label = li.querySelector('.ActionListItem-label');
const text = (label?.textContent ?? '').trim().toLowerCase();
const show = !term || text.includes(term);
li.hidden = !show;
});
}
function disconnectListObserver(dialog) {
const obs = listObservers.get(dialog);
if (obs) {
obs.disconnect();
listObservers.delete(dialog);
}
}
function ensureListMutationObserver(dialog, input) {
const ul = getListRoot(dialog);
if (!ul || listObservers.has(dialog)) return;
const obs = new MutationObserver(() => {
filterListItems(dialog, input.value);
});
obs.observe(ul, { childList: true });
listObservers.set(dialog, obs);
}
function createSearchUI(dialog) {
const overlayBody = dialog.querySelector('.Overlay-body');
if (!overlayBody) return null;
const wrap = document.createElement('div');
wrap.setAttribute(WRAP_ATTR, '');
wrap.className = 'px-3 py-2 border-bottom';
const label = document.createElement('label');
label.className = 'sr-only';
label.textContent = 'Filter lists';
const input = document.createElement('input');
input.type = 'search';
input.id = `gh-star-list-search__${dialog.id}`;
input.setAttribute(INPUT_ATTR, '');
input.className = 'form-control input-contrast width-full';
input.setAttribute('autocomplete', 'off');
input.setAttribute('spellcheck', 'false');
input.placeholder = 'Search lists…';
const runFilter = debounce((value) => {
filterListItems(dialog, value);
}, DEBOUNCE_MS);
// Avoid the overlay / focus-group swallowing clicks (same idea as the YouTube reference).
input.addEventListener('click', (e) => e.stopPropagation());
input.addEventListener('mousedown', (e) => e.stopPropagation());
input.addEventListener('input', (e) => {
e.stopPropagation();
runFilter(e.target.value);
});
label.setAttribute('for', input.id);
wrap.appendChild(label);
wrap.appendChild(input);
overlayBody.insertBefore(wrap, overlayBody.firstChild);
return wrap;
}
function ensureDialogLifecycle(dialog) {
if (lifecycleAttached.has(dialog)) return;
lifecycleAttached.add(dialog);
dialog.addEventListener(
'close',
() => {
disconnectListObserver(dialog);
delete dialog.dataset.ghStarListSearchSession;
},
{ passive: true }
);
}
function enhanceDialog(dialog) {
ensureDialogLifecycle(dialog);
let wrap = dialog.querySelector(`[${WRAP_ATTR}]`);
if (!wrap) {
wrap = createSearchUI(dialog);
}
if (!wrap) return;
const input = wrap.querySelector(`input[${INPUT_ATTR}]`);
if (!input) return;
// Only reset/focus once per dialog open so DOM updates do not steal focus.
if (dialog.dataset.ghStarListSearchSession === '1') {
ensureListMutationObserver(dialog, input);
return;
}
dialog.dataset.ghStarListSearchSession = '1';
input.value = '';
filterListItems(dialog, '');
ensureListMutationObserver(dialog, input);
requestAnimationFrame(() => {
input.focus({ preventScroll: true });
});
}
function scanForOpenDialog() {
document.querySelectorAll('dialog[id$="-starred-dialog"]').forEach((dialog) => {
if (isStarListDialog(dialog)) {
enhanceDialog(dialog);
}
});
}
const mo = new MutationObserver(() => {
scanForOpenDialog();
});
mo.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['open'],
});
scanForOpenDialog();
})();