Created
April 18, 2026 10:02
-
-
Save shelllee/b2e63e8cf3f2a97e3f8d0e0b153bf7de to your computer and use it in GitHub Desktop.
GitHub Star List Filter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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