diff --git a/static/css/components/header.css b/static/css/components/header.css index 3ae3ae8a..e4e5a8f4 100644 --- a/static/css/components/header.css +++ b/static/css/components/header.css @@ -149,7 +149,7 @@ width: 100%; padding: 0.5rem 0.75rem; padding-left: 2.25rem !important; - padding-right: 5rem !important; + padding-right: 6.75rem !important; /* clear room for options + filter + clear/cue toggles */ border: none; background: transparent; color: var(--text-color); @@ -190,6 +190,81 @@ right: 2.25rem; } +/* Clear button: sit immediately left of the search-options toggle */ +.header-search .search-clear { + position: absolute; + right: 4.25rem; /* 2.25rem (options toggle) + 28px toggle width + 4px gap */ + top: 50%; + transform: translateY(-50%); + width: 28px; + height: 28px; + display: none; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + border-radius: var(--border-radius-xs, 4px); + padding: 0; + line-height: 1; + transition: background-color var(--transition-base), color var(--transition-base); +} + +.header-search .search-clear.visible { + display: flex; +} + +.header-search .search-clear:hover { + background: color-mix(in oklch, var(--text-muted) 15%, transparent); + color: var(--lora-accent); +} + +/* Keyboard shortcut cue: shown when search is empty, hidden when typing */ +.header-search .search-shortcut-cue { + position: absolute; + right: 4.25rem; /* same slot as clear button */ + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 2px; + pointer-events: none; + font-family: inherit; + font-size: 0.7rem; + line-height: 1; + color: var(--text-muted); + opacity: 0.7; + white-space: nowrap; + transition: opacity 0.2s ease; +} + +.header-search .search-shortcut-cue kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 4px; + font-family: inherit; + font-size: 0.68rem; + font-weight: 500; + color: var(--text-muted); + /* Subtle tint derived from text color so it adapts to both light & dark themes */ + background: color-mix(in oklch, var(--text-muted) 12%, transparent); + border: 1px solid color-mix(in oklch, var(--text-muted) 25%, transparent); + border-radius: var(--border-radius-xs, 3px); + line-height: 1; +} + +.header-search .search-shortcut-cue.hidden { + display: none; +} + +.header-search.disabled .search-shortcut-cue { + display: none; +} + .header-search .search-options-toggle:hover, .header-search .search-filter-toggle:hover, .header-search .search-filter-toggle:focus-visible { diff --git a/static/js/managers/SearchManager.js b/static/js/managers/SearchManager.js index b82e1232..21fd7b08 100644 --- a/static/js/managers/SearchManager.js +++ b/static/js/managers/SearchManager.js @@ -27,6 +27,9 @@ export class SearchManager { // Create clear button for search input this.createClearButton(); + // Keyboard shortcut cue element (static, exists in the HTML) + this.searchShortcutCue = document.getElementById('searchShortcutCue'); + this.initEventListeners(); this.loadSearchPreferences(); this.setupKeyboardShortcuts(); @@ -163,8 +166,13 @@ export class SearchManager { } updateClearButtonVisibility() { + const hasText = this.searchInput.value.length > 0; if (this.clearButton) { - this.clearButton.classList.toggle('visible', this.searchInput.value.length > 0); + this.clearButton.classList.toggle('visible', hasText); + } + // Toggle the keyboard shortcut cue: visible only when search is empty + if (this.searchShortcutCue) { + this.searchShortcutCue.classList.toggle('hidden', hasText); } } diff --git a/templates/components/header.html b/templates/components/header.html index fa106056..a039bb41 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -53,6 +53,7 @@ + CtrlF diff --git a/tests/frontend/components/pageControls.filtering.test.js b/tests/frontend/components/pageControls.filtering.test.js index 2409eff6..d4d6cfe4 100644 --- a/tests/frontend/components/pageControls.filtering.test.js +++ b/tests/frontend/components/pageControls.filtering.test.js @@ -96,6 +96,7 @@ function renderControlsDom(pageKey) { + CtrlF 0 @@ -215,6 +216,40 @@ describe('SearchManager filtering scenarios', () => { expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledWith(true, false); expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledTimes(1); }); + + it.each([ + ['loras'], + ['checkpoints'], + ])('toggles clear button and shortcut cue visibility for %s page', async (pageKey) => { + vi.useFakeTimers(); + + renderControlsDom(pageKey); + const stateModule = await import('../../../static/js/state/index.js'); + stateModule.initPageState(pageKey); + const { SearchManager } = await import('../../../static/js/managers/SearchManager.js'); + + new SearchManager({ page: pageKey, searchDelay: 0 }); + + const input = document.getElementById('searchInput'); + const cue = document.getElementById('searchShortcutCue'); + const clearBtn = document.querySelector('.search-clear'); + + // Initially empty: cue visible, clear hidden + expect(cue.classList.contains('hidden')).toBe(false); + expect(clearBtn.classList.contains('visible')).toBe(false); + + // Type something: cue hidden, clear visible + input.value = 'flux'; + input.dispatchEvent(new Event('input', { bubbles: true })); + expect(cue.classList.contains('hidden')).toBe(true); + expect(clearBtn.classList.contains('visible')).toBe(true); + + // Clear via click: cue visible, clear hidden + clearBtn.click(); + expect(input.value).toBe(''); + expect(cue.classList.contains('hidden')).toBe(false); + expect(clearBtn.classList.contains('visible')).toBe(false); + }); }); describe('FilterManager tag and base model filters', () => {