Skip to content

Commit 61b6a0d

Browse files
committed
address Copilot review on #109
Six findings (4 inline + 2 in the review body, all valid): 1. `clear()` in header-search.js fired BOTH `filter.onInput('')` AND `filter.onClear()`. Most gears were re-filtering twice; any side- effectful onClear would run on top of the empty-input path. Pick one path: prefer onClear if registered, otherwise dispatch empty onInput. Removed the now-unused dispatchClear helper. 2. toggleSidebar() / applySidebarState() only updated the hidden `#sidebar-icon-{collapse,expand}` shim left behind in the sidebar for legacy code. The VISIBLE icons live in the new header toggle (`#sidebar-icon-{collapse,expand}-header`) and were left untouched, so clicking the toggle moved the sidebar but the icon never swapped. Mirror the state to both pairs. 3. The `?` shortcut overlay is `role="dialog" aria-modal="true"` but open() just removed `hidden` — focus stayed wherever it was, so the dialog wasn't announced and keyboard focus could land outside it. Make the overlay focusable on open and move focus into it; restore focus to the prior element on close. No formal focus trap is needed since the overlay has no interactive controls (clicking anywhere closes). 4. The Overview gear restored the saved text filter by writing __backendFilterQuery directly THEN calling HeaderSearch.setValue, BEFORE registering the filter — so the bridge would never carry restored state through the registered onInput. Swap the order: register first, then setValue, and let the dispatch update the query variable through the same code path keystrokes use. 5. The outside-click handler that collapses the compact mobile search row checked `row.classList.contains('hidden')` to bail when closed — but the row's templ markup is `hidden md:flex`, so it ALWAYS carries `hidden` regardless of overlay state. The check effectively disabled the feature. Use the .header-search-expanded marker class instead, which the overlay state actually toggles. 6. The HeaderSearch templ doc-comment still described the original "registered placeholder switches the input's placeholder" plan; we dropped that earlier so the box reads the same on every page. Updated the comment to match. Verified in the browser: toggling the sidebar swaps the visible header icon; clear() with both callbacks registered fires onClear exactly once and onInput zero times; opening the help overlay moves focus onto it. Refs #92
1 parent 76b787b commit 61b6a0d

4 files changed

Lines changed: 98 additions & 40 deletions

File tree

gearbox/internal/framework/templates/layouts/base.templ

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -892,27 +892,41 @@ templ Base(title string, user *models.User, currentPath ...string) {
892892
// Header now spans full viewport width and lives ABOVE the
893893
// sidebar (issue #92), so it no longer tracks sidebar width.
894894
// We only flip the sidebar/main-content classes here.
895+
// Two pairs of icon elements need to be kept in sync:
896+
// - `sidebar-icon-{collapse,expand}` — hidden shim
897+
// inside the sidebar, kept so legacy lookups still
898+
// resolve to something.
899+
// - `sidebar-icon-{collapse,expand}-header` — the
900+
// VISIBLE icons inside the new header toggle.
901+
// Updating only the shim left the visible icon stuck
902+
// (Copilot review).
895903
const sidebar = document.getElementById('sidebar');
896904
const mainContent = document.getElementById('main-content');
897905
const iconCollapse = document.getElementById('sidebar-icon-collapse');
898906
const iconExpand = document.getElementById('sidebar-icon-expand');
907+
const iconCollapseHeader = document.getElementById('sidebar-icon-collapse-header');
908+
const iconExpandHeader = document.getElementById('sidebar-icon-expand-header');
899909
const isCollapsed = sidebar.classList.contains('sidebar-collapsed');
900910

901911
if (isCollapsed) {
902912
sidebar.classList.remove('sidebar-collapsed');
903913
sidebar.classList.add('sidebar-expanded');
904914
mainContent.classList.remove('ml-16');
905915
mainContent.classList.add('ml-64');
906-
if (iconCollapse) iconCollapse.classList.remove('hidden');
907-
if (iconExpand) iconExpand.classList.add('hidden');
916+
if (iconCollapse) iconCollapse.classList.remove('hidden');
917+
if (iconExpand) iconExpand.classList.add('hidden');
918+
if (iconCollapseHeader) iconCollapseHeader.classList.remove('hidden');
919+
if (iconExpandHeader) iconExpandHeader.classList.add('hidden');
908920
localStorage.setItem('sidebarCollapsed', 'false');
909921
} else {
910922
sidebar.classList.add('sidebar-collapsed');
911923
sidebar.classList.remove('sidebar-expanded');
912924
mainContent.classList.add('ml-16');
913925
mainContent.classList.remove('ml-64');
914-
if (iconCollapse) iconCollapse.classList.add('hidden');
915-
if (iconExpand) iconExpand.classList.remove('hidden');
926+
if (iconCollapse) iconCollapse.classList.add('hidden');
927+
if (iconExpand) iconExpand.classList.remove('hidden');
928+
if (iconCollapseHeader) iconCollapseHeader.classList.add('hidden');
929+
if (iconExpandHeader) iconExpandHeader.classList.remove('hidden');
916930
localStorage.setItem('sidebarCollapsed', 'true');
917931
}
918932
}
@@ -923,6 +937,10 @@ templ Base(title string, user *models.User, currentPath ...string) {
923937
const mainContent = document.getElementById('main-content');
924938
const iconCollapse = document.getElementById('sidebar-icon-collapse');
925939
const iconExpand = document.getElementById('sidebar-icon-expand');
940+
// Visible header-button icons — see toggleSidebar()
941+
// for context on why both pairs are tracked.
942+
const iconCollapseHeader = document.getElementById('sidebar-icon-collapse-header');
943+
const iconExpandHeader = document.getElementById('sidebar-icon-expand-header');
926944

927945
// IMPORTANT: apply the correct sidebar/main-content classes
928946
// BEFORE removing the .sidebar-initially-collapsed pin from
@@ -945,15 +963,19 @@ templ Base(title string, user *models.User, currentPath ...string) {
945963
sidebar.classList.remove('sidebar-expanded');
946964
mainContent.classList.add('ml-16');
947965
mainContent.classList.remove('ml-64');
948-
if (iconCollapse) iconCollapse.classList.add('hidden');
949-
if (iconExpand) iconExpand.classList.remove('hidden');
966+
if (iconCollapse) iconCollapse.classList.add('hidden');
967+
if (iconExpand) iconExpand.classList.remove('hidden');
968+
if (iconCollapseHeader) iconCollapseHeader.classList.add('hidden');
969+
if (iconExpandHeader) iconExpandHeader.classList.remove('hidden');
950970
} else {
951971
sidebar.classList.remove('sidebar-collapsed');
952972
sidebar.classList.add('sidebar-expanded');
953973
mainContent.classList.remove('ml-16');
954974
mainContent.classList.add('ml-64');
955-
if (iconCollapse) iconCollapse.classList.remove('hidden');
956-
if (iconExpand) iconExpand.classList.add('hidden');
975+
if (iconCollapse) iconCollapse.classList.remove('hidden');
976+
if (iconExpand) iconExpand.classList.add('hidden');
977+
if (iconCollapseHeader) iconCollapseHeader.classList.remove('hidden');
978+
if (iconExpandHeader) iconExpandHeader.classList.add('hidden');
957979
}
958980
}
959981

@@ -2473,12 +2495,13 @@ templ Header(currentPath string) {
24732495
24742496
// HeaderSearch is the unified search / filter / command-palette input.
24752497
// One text input drives three behaviours, chosen by what the user types:
2476-
// - empty / search mode: hint reads "Type / to search · Cmd+K for palette".
2477-
// Per-gear pages can register a filter callback via window.gearbox.filter
2478-
// (see common/gear-commands.js); when active, the placeholder switches
2479-
// to whatever the page asked for and each keystroke calls the callback.
2480-
// With no filter callback registered, Enter falls back to a DuckDuckGo
2481-
// web search.
2498+
// - empty / search mode: the same hint chips appear on every page —
2499+
// "/ to search · Cmd+K for palette" — overlaid on the input. There
2500+
// is no per-gear placeholder; gears that want to react to keystrokes
2501+
// register a filter callback via window.gearbox.filter (see
2502+
// common/gear-commands.js) and the controller dispatches each input
2503+
// event to it. With no filter callback registered, Enter falls back
2504+
// to a DuckDuckGo web search.
24822505
// - leading `>` (typed manually or inserted by Cmd+K): palette mode.
24832506
// The slide-down `#header-search-panel` opens just below the input and
24842507
// ranks boxes / gears / settings pages / global actions / per-gear

gearbox/internal/framework/templates/pages/overview.templ

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -572,22 +572,10 @@ templ Overview(user *models.User, servers []models.BoxConfig) {
572572
// Move page header content to main header
573573
setupPageHeader();
574574

575-
// Restore global filters from localStorage. The text query
576-
// lives in __backendFilterQuery now (issue #92); the health
577-
// select is still a per-page dropdown.
578-
const savedTextFilter = localStorage.getItem('global-text-filter') || '';
579-
const savedHealthFilter = localStorage.getItem('global-health-filter') || 'all';
580-
const healthSelect = document.getElementById('global-health-filter');
581-
582-
if (savedTextFilter) {
583-
__backendFilterQuery = savedTextFilter.toLowerCase();
584-
if (window.HeaderSearch) window.HeaderSearch.setValue(savedTextFilter);
585-
}
586-
if (healthSelect && savedHealthFilter) {
587-
healthSelect.value = savedHealthFilter;
588-
}
589-
590575
// HeaderSearch + command palette wiring (issue #92).
576+
// Register BEFORE replaying the saved filter so setValue()'s
577+
// dispatch flows through the registered onInput and keeps
578+
// __backendFilterQuery in sync (Copilot review).
591579
if (window.gearbox && window.gearbox.filter) {
592580
window.gearbox.filter.register({
593581
placeholder: 'Filter backends…',
@@ -598,6 +586,20 @@ templ Overview(user *models.User, servers []models.BoxConfig) {
598586
onClear: function () { __backendFilterQuery = ''; applyGlobalFilters(); },
599587
});
600588
}
589+
590+
// Restore global filters from localStorage. The text query
591+
// flows through HeaderSearch → registered onInput now; the
592+
// health select is still a per-page dropdown.
593+
const savedTextFilter = localStorage.getItem('global-text-filter') || '';
594+
const savedHealthFilter = localStorage.getItem('global-health-filter') || 'all';
595+
const healthSelect = document.getElementById('global-health-filter');
596+
597+
if (savedTextFilter && window.HeaderSearch) {
598+
window.HeaderSearch.setValue(savedTextFilter);
599+
}
600+
if (healthSelect && savedHealthFilter) {
601+
healthSelect.value = savedHealthFilter;
602+
}
601603
if (window.gearbox && window.gearbox.commands) {
602604
const cmds = window.gearbox.commands;
603605
const hSel = document.getElementById('global-health-filter');

gearbox/static/js/common/header-search.js

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -181,14 +181,6 @@
181181
clear();
182182
}
183183

184-
function dispatchClear() {
185-
const filter = window.gearbox && window.gearbox.filter && window.gearbox.filter.current();
186-
if (filter && typeof filter.onClear === 'function') {
187-
try { filter.onClear(); }
188-
catch (e) { console.error('filter.onClear threw', e); }
189-
}
190-
}
191-
192184
/* -------------------------------------------------------------- *
193185
* Public mode / value mutators
194186
* -------------------------------------------------------------- */
@@ -218,8 +210,22 @@
218210
if (!input) return;
219211
input.value = '';
220212
repaint();
221-
dispatchSearchInput();
222-
dispatchClear();
213+
// Fire exactly one filter callback. Earlier this called both
214+
// dispatchSearchInput() (→ filter.onInput('')) AND
215+
// dispatchClear() (→ filter.onClear()), so most gears
216+
// re-filtered twice and any side-effectful onClear would
217+
// run alongside the empty-query path (Copilot review).
218+
// Prefer onClear if the gear registered one; otherwise fall
219+
// back to the standard empty-query dispatch.
220+
notify(queryListeners, '');
221+
const filter = window.gearbox && window.gearbox.filter && window.gearbox.filter.current();
222+
if (filter && typeof filter.onClear === 'function') {
223+
try { filter.onClear(); }
224+
catch (e) { console.error('filter.onClear threw', e); }
225+
} else if (filter && typeof filter.onInput === 'function') {
226+
try { filter.onInput(''); }
227+
catch (e) { console.error('filter.onInput threw', e); }
228+
}
223229
}
224230

225231
function focus(selectAll) {
@@ -339,9 +345,15 @@
339345
}
340346

341347
// Compact-mode: clicking outside the search collapses the row.
348+
// Check the .header-search-expanded marker rather than the
349+
// `hidden` class — the row's templ markup is `hidden md:flex`,
350+
// so it ALWAYS carries `hidden` and the old check effectively
351+
// disabled the outside-click collapse (Copilot review). The
352+
// overlay state is driven by the marker class, not by toggling
353+
// `hidden` directly.
342354
document.addEventListener('click', function (e) {
343355
if (!window.matchMedia('(max-width: 767px)').matches) return;
344-
if (!row || row.classList.contains('hidden')) return;
356+
if (!row || !row.classList.contains('header-search-expanded')) return;
345357
if (wrap && wrap.contains(e.target)) return;
346358
// Don't collapse while the palette panel is the click target.
347359
const panel = document.getElementById('header-search-panel');

gearbox/static/js/common/shortcut-help.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,16 +77,37 @@
7777
});
7878
}
7979

80+
let returnFocusEl = null;
81+
8082
function open() {
8183
const overlay = document.getElementById('shortcuts-help-overlay');
8284
if (!overlay) return;
8385
renderShortcuts();
86+
// Remember whoever had focus so we can hand it back on close.
87+
returnFocusEl = document.activeElement;
8488
overlay.classList.remove('hidden');
89+
// The overlay is role="dialog" aria-modal="true"; move focus
90+
// into it so the dialog is announced by screen readers and
91+
// subsequent Tab/Esc keystrokes land here, not on the page
92+
// behind. The overlay has no interactive controls (clicking
93+
// anywhere closes), so a single focusable container is enough
94+
// — no formal trap needed (Copilot a11y review).
95+
overlay.setAttribute('tabindex', '-1');
96+
try { overlay.focus({ preventScroll: true }); }
97+
catch (_) { overlay.focus(); }
8598
}
8699
function close() {
87100
const overlay = document.getElementById('shortcuts-help-overlay');
88101
if (!overlay) return;
89102
overlay.classList.add('hidden');
103+
// Restore focus to whatever opened the overlay (typically the
104+
// page body or a button), so keyboard users don't get dumped
105+
// back at the top of the document.
106+
if (returnFocusEl && typeof returnFocusEl.focus === 'function') {
107+
try { returnFocusEl.focus({ preventScroll: true }); }
108+
catch (_) { returnFocusEl.focus(); }
109+
}
110+
returnFocusEl = null;
90111
}
91112

92113
function init() {

0 commit comments

Comments
 (0)