Skip to content

Instantly share code, notes, and snippets.

@shelllee
Created April 18, 2026 10:02
Show Gist options
  • Select an option

  • Save shelllee/b2e63e8cf3f2a97e3f8d0e0b153bf7de to your computer and use it in GitHub Desktop.

Select an option

Save shelllee/b2e63e8cf3f2a97e3f8d0e0b153bf7de to your computer and use it in GitHub Desktop.
GitHub Star List Filter
// ==UserScript==
// @name GitHub Star List Filter
// @namespace https://github.com/
// @version 1.0.0
// @description Restore the search filter in GitHub's "Add to list" dropdown
// @author Claude
// @match https://github.com/*/*
// @grant none
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// Filter input styles
const STYLE = `
.gh-star-filter-wrap {
padding: 8px 8px 4px 8px;
border-bottom: 1px solid var(--borderColor-default, #d1d9e0);
}
.gh-star-filter-input {
width: 100%;
padding: 5px 8px;
border: 1px solid var(--borderColor-default, #d1d9e0);
border-radius: 6px;
background: var(--bgColor-default, #fff);
color: var(--fgColor-default, #1f2328);
font-size: 13px;
outline: none;
box-sizing: border-box;
}
.gh-star-filter-input:focus {
border-color: var(--borderColor-accent-emphasis, #0969da);
box-shadow: 0 0 0 3px var(--borderColor-accent-muted, rgba(9,105,218,0.3));
}
.gh-star-filter-input::placeholder {
color: var(--fgColor-muted, #656d76);
}
.gh-star-filter-count {
font-size: 11px;
color: var(--fgColor-muted, #656d76);
padding: 2px 8px 0 8px;
text-align: right;
}
`;
// Inject styles
const styleEl = document.createElement('style');
styleEl.textContent = STYLE;
document.head.appendChild(styleEl);
// Inject filter input into the dialog
function injectFilter(dialog) {
// Prevent duplicate injection
if (dialog.querySelector('.gh-star-filter-wrap')) return;
const listbox = dialog.querySelector('[role="listbox"]');
if (!listbox) return;
// Create filter container
const wrap = document.createElement('div');
wrap.className = 'gh-star-filter-wrap';
const input = document.createElement('input');
input.type = 'text';
input.className = 'gh-star-filter-input';
input.placeholder = 'Filter lists...';
const countEl = document.createElement('div');
countEl.className = 'gh-star-filter-count';
wrap.appendChild(input);
wrap.appendChild(countEl);
// Insert before the listbox
listbox.parentElement.insertBefore(wrap, listbox);
// Collect all list items
const items = listbox.querySelectorAll('li[role="none"]');
let visibleCount = items.length;
countEl.textContent = `${visibleCount} / ${items.length}`;
// Filter logic
input.addEventListener('input', () => {
const keyword = input.value.trim().toLowerCase();
visibleCount = 0;
items.forEach((item) => {
const label = item.querySelector('.ActionListItem-label');
const text = label ? label.textContent.trim().toLowerCase() : '';
const match = !keyword || text.includes(keyword);
item.style.display = match ? '' : 'none';
if (match) visibleCount++;
});
countEl.textContent = `${visibleCount} / ${items.length}`;
});
// Stop all keyboard events from bubbling to prevent GitHub's
// focus-group from triggering letter-based navigation
input.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const visibleItems = [...items].filter(
(item) => item.style.display !== 'none'
);
if (visibleItems.length === 0) return;
// Find currently focused item
const focusedBtn = dialog.querySelector(
'[role="option"]:focus, .ActionListContent:focus'
);
let idx = focusedBtn
? visibleItems.indexOf(focusedBtn.closest('li'))
: -1;
if (e.key === 'ArrowDown') {
idx = idx < visibleItems.length - 1 ? idx + 1 : 0;
} else {
idx = idx > 0 ? idx - 1 : visibleItems.length - 1;
}
const targetBtn = visibleItems[idx]?.querySelector('[role="option"]');
if (targetBtn) targetBtn.focus();
}
// Enter to select the focused item
if (e.key === 'Enter') {
const focusedBtn = dialog.querySelector(
'[role="option"]:focus, .ActionListContent:focus'
);
if (focusedBtn) {
focusedBtn.click();
}
}
// Escape to clear filter or close dialog
if (e.key === 'Escape') {
if (input.value) {
e.preventDefault();
input.value = '';
input.dispatchEvent(new Event('input'));
}
}
});
// Also stop keyup and keypress from bubbling
input.addEventListener('keyup', (e) => e.stopPropagation());
input.addEventListener('keypress', (e) => e.stopPropagation());
// Auto-focus the filter input
requestAnimationFrame(() => input.focus());
}
// Use MutationObserver to detect dialog opening
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node instanceof HTMLElement) {
// Check if the added node is a dialog
if (node.tagName === 'DIALOG' && node.hasAttribute('open')) {
waitForContent(node);
}
// Also check for dialogs within added nodes
const dialogs = node.querySelectorAll?.('dialog[open]');
if (dialogs) {
dialogs.forEach((d) => waitForContent(d));
}
}
}
// Watch for attribute changes (dialog open)
if (
mutation.type === 'attributes' &&
mutation.target.tagName === 'DIALOG'
) {
if (mutation.target.hasAttribute('open')) {
waitForContent(mutation.target);
}
}
}
});
// Wait for list content to finish loading
function waitForContent(dialog) {
// Check if content is already available
if (dialog.querySelector('[role="listbox"]')) {
injectFilter(dialog);
return;
}
// Content may load asynchronously, poll for it
let attempts = 0;
const timer = setInterval(() => {
attempts++;
if (dialog.querySelector('[role="listbox"]')) {
clearInterval(timer);
injectFilter(dialog);
}
if (attempts > 50) {
// 5 second timeout
clearInterval(timer);
}
}, 100);
}
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['open'],
});
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment