Improve settings modal navigation

Add settings sidebar search with filtered section results and clear affordance. Decouple sidebar accordion expansion from the active section so groups can be opened or collapsed manually. Keep the settings menu/search column fixed while the content pane scrolls, and promote Backup & Restore to its own section under Self Update.

Alessandro committed Jun 9, 2026 at 00:47 UTC 60462eabbdab4432338212bbcb154cad3bcf3a38
4 files changed +201 -21
webui/components/settings/backup/backup-settings.html
+4 -14
@@ -16,9 +16,9 @@
16 </a>
17 </li>
18 <li>
19 - <a href="#section-update-advanced">
19 + <a href="#section-backup-restore">
20 <img src="/public/backup_restore.svg" alt="Backup & Restore" />
21 - <span>Advanced Settings</span>
21 + <span>Backup &amp; Restore</span>
22 </a>
23 </li>
24 </ul>
@@ -28,18 +28,8 @@
28 <x-component path="settings/backup/self-update.html"></x-component>
29 </div>
30
31 - <div id="section-update-advanced" class="section" x-data="{ advOpen: false }">
32 - <div class="settings-advanced-section">
33 - <button type="button" class="settings-advanced-toggle" @click="advOpen = !advOpen">
34 - <span class="material-symbols-outlined settings-advanced-toggle-icon"
35 - :style="advOpen ? 'transform:rotate(90deg)' : ''">chevron_right</span>
36 - <span>Advanced Settings</span>
37 - </button>
38 -
39 - <div class="settings-advanced-body" x-show="advOpen" x-transition.opacity>
40 - <x-component path="settings/backup/backup_restore.html"></x-component>
41 - </div>
42 - </div>
31 + <div id="section-backup-restore" class="section">
32 + <x-component path="settings/backup/backup_restore.html"></x-component>
33 </div>
34 </div>
35 </template>
webui/components/settings/settings-store.js
+83 -1
@@ -74,7 +74,7 @@ const TAB_ITEMS = Object.freeze([
74 icon: "system_update_alt",
75 sections: [
76 { id: "section-self-update", label: "Self Update", icon: "system_update_alt" },
77 - { id: "section-update-advanced", label: "Advanced Settings", icon: "tune" },
77 + { id: "section-backup-restore", label: "Backup & Restore", icon: "backup" },
78 ],
79 },
80 ]);
@@ -106,6 +106,8 @@ const model = {
106 _paneScrollPane: null,
107 _scrollSyncFrame: null,
108 _updateStatusRefreshedAt: 0,
109 + expandedNavGroups: {},
110 + searchQuery: "",
111
112 // Tab state
113 _activeTab: DEFAULT_TAB,
@@ -130,6 +132,7 @@ const model = {
132 if (saved) this._activeTab = this.normalizeTabId(saved);
133 } catch {}
134 this._activeSection = this.getFirstSectionId(this._activeTab);
135 + this.expandedNavGroups = this.createDefaultExpandedNavGroups(this._activeTab);
136 },
137
138 async onOpen() {
@@ -176,6 +179,7 @@ const model = {
179 this.additional = null;
180 this.error = null;
181 this.isLoading = false;
182 + this.searchQuery = "";
183 },
184
185 // Tab management
@@ -189,6 +193,7 @@ const model = {
193 localStorage.setItem(VIEW_MODE_STORAGE_KEY, current);
194 } catch {}
195
196 + this.setNavGroupExpanded(current, true);
197 this.bindPaneScroll();
198 },
199
@@ -204,6 +209,29 @@ const model = {
209 return TAB_ITEMS;
210 },
211
212 + get normalizedSearchQuery() {
213 + return String(this.searchQuery || "").trim().toLowerCase();
214 + },
215 +
216 + get hasSearchQuery() {
217 + return this.normalizedSearchQuery.length > 0;
218 + },
219 +
220 + get filteredNavItems() {
221 + const query = this.normalizedSearchQuery;
222 + if (!query) return TAB_ITEMS;
223 +
224 + return TAB_ITEMS
225 + .map((item) => {
226 + const itemMatches = this.getNavSearchText(item).includes(query);
227 + const sections = itemMatches
228 + ? item.sections
229 + : item.sections.filter((section) => this.getNavSearchText(section).includes(query));
230 + return sections.length ? { ...item, sections } : null;
231 + })
232 + .filter(Boolean);
233 + },
234 +
235 get activeTabItem() {
236 return TAB_ITEMS.find((item) => item.id === this.activeTab) || TAB_ITEMS[0];
237 },
@@ -217,6 +245,59 @@ const model = {
245 return tab?.sections?.[0]?.id || null;
246 },
247
248 + getNavSearchText(item) {
249 + return `${item?.label || ""} ${item?.id || ""}`.toLowerCase();
250 + },
251 +
252 + createDefaultExpandedNavGroups(activeTab = this.activeTab) {
253 + return TAB_ITEMS.reduce((groups, item) => {
254 + groups[item.id] = item.id === activeTab;
255 + return groups;
256 + }, {});
257 + },
258 +
259 + isNavGroupExpanded(tabName) {
260 + if (this.hasSearchQuery) return true;
261 + const tabId = this.normalizeTabId(tabName);
262 + return Boolean(this.expandedNavGroups?.[tabId]);
263 + },
264 +
265 + setNavGroupExpanded(tabName, expanded) {
266 + const tabId = this.normalizeTabId(tabName);
267 + this.expandedNavGroups = {
268 + ...(this.expandedNavGroups || {}),
269 + [tabId]: Boolean(expanded),
270 + };
271 + },
272 +
273 + toggleNavGroup(tabName) {
274 + const tabId = this.normalizeTabId(tabName);
275 + if (this.hasSearchQuery) {
276 + this.enterTab(tabId);
277 + return;
278 + }
279 + if (this.activeTab !== tabId) {
280 + this.enterTab(tabId);
281 + this.setNavGroupExpanded(tabId, true);
282 + return;
283 + }
284 + this.setNavGroupExpanded(tabId, !this.isNavGroupExpanded(tabId));
285 + },
286 +
287 + clearSearch() {
288 + this.searchQuery = "";
289 + },
290 +
291 + openFirstSearchResult() {
292 + const item = this.filteredNavItems[0];
293 + const section = item?.sections?.[0];
294 + if (section?.id) {
295 + this.scrollToSection(section.id);
296 + } else if (item?.id) {
297 + this.enterTab(item.id);
298 + }
299 + },
300 +
301 get browserTimezone() {
302 return getBrowserTimezone();
303 },
@@ -281,6 +362,7 @@ const model = {
362 enterTab(tabName) {
363 this.activeTab = tabName;
364 this._activeSection = this.getFirstSectionId(this.activeTab);
365 + this.setNavGroupExpanded(this.activeTab, true);
366 this.resetPaneScroll();
367 if (tabName === "backup") this.refreshUpdateStatus();
368 },
webui/components/settings/settings.html
+32 -5
@@ -29,20 +29,44 @@
29 <div x-show="$store.settings.settings" class="settings-content">
30 <!-- Tab Navigation -->
31 <aside class="settings-tabs-container" aria-label="Settings categories">
32 + <div class="settings-search" role="search">
33 + <span class="material-symbols-outlined" aria-hidden="true">search</span>
34 + <input
35 + type="search"
36 + x-model.debounce.100ms="$store.settings.searchQuery"
37 + @keydown.enter.prevent="$store.settings.openFirstSearchResult()"
38 + placeholder="Search"
39 + aria-label="Search settings"
40 + autocomplete="off"
41 + />
42 + <button
43 + type="button"
44 + class="settings-search-clear"
45 + x-show="$store.settings.hasSearchQuery"
46 + @click="$store.settings.clearSearch()"
47 + aria-label="Clear search"
48 + >
49 + <span class="material-symbols-outlined" aria-hidden="true">close</span>
50 + </button>
51 + </div>
52 +
53 <div class="settings-tabs no-scrollbar" role="tablist" aria-orientation="vertical">
33 - <template x-for="item in $store.settings.navItems" :key="item.id">
54 + <template x-for="item in $store.settings.filteredNavItems" :key="item.id">
55 <div class="settings-nav-group"
35 - :class="{'settings-nav-group-active': $store.settings.activeTab === item.id}">
56 + :class="{
57 + 'settings-nav-group-active': $store.settings.activeTab === item.id,
58 + 'settings-nav-group-open': $store.settings.isNavGroupExpanded(item.id)
59 + }">
60 <button type="button"
61 class="settings-tab settings-parent-tab"
62 role="tab"
63 :aria-selected="$store.settings.activeTab === item.id"
40 - :aria-expanded="$store.settings.activeTab === item.id"
64 + :aria-expanded="$store.settings.isNavGroupExpanded(item.id)"
65 :class="{
66 'active': $store.settings.activeTab === item.id,
67 'settings-tab-attention': $store.settings.navItemHasAttention(item)
68 }"
45 - @click="$store.settings.enterTab(item.id)">
69 + @click="$store.settings.toggleNavGroup(item.id)">
70 <span class="material-symbols-outlined" aria-hidden="true" x-text="item.icon"></span>
71 <span class="settings-tab-label" x-text="item.label"></span>
72 <span class="settings-tab-meta">
@@ -55,7 +79,7 @@
79 </button>
80
81 <div class="settings-section-list"
58 - x-show="$store.settings.activeTab === item.id"
82 + x-show="$store.settings.isNavGroupExpanded(item.id)"
83 x-transition.opacity.duration.120ms>
84 <template x-for="section in item.sections" :key="section.id">
85 <a class="settings-section-link"
@@ -76,6 +100,9 @@
100 </div>
101 </div>
102 </template>
103 + <div class="settings-search-empty" x-show="$store.settings.hasSearchQuery && !$store.settings.filteredNavItems.length">
104 + No settings found
105 + </div>
106 </div>
107 </aside>
108
webui/css/settings.css
+82 -1
@@ -196,6 +196,9 @@ select:disabled {
196 .settings-tabs-container {
197 position: sticky;
198 top: 0;
199 + display: flex;
200 + flex-direction: column;
201 + gap: 10px;
202 align-self: stretch;
203 z-index: 2;
204 min-width: 0;
@@ -207,10 +210,81 @@ select:disabled {
210 background: color-mix(in srgb, var(--color-panel) 44%, transparent);
211 }
212
213 +.settings-search {
214 + display: grid;
215 + grid-template-columns: 20px minmax(0, 1fr) 24px;
216 + align-items: center;
217 + gap: 5px;
218 + flex: 0 0 auto;
219 + width: 100%;
220 + min-width: 0;
221 + height: 34px;
222 + padding: 0 7px;
223 + border: 1px solid color-mix(in srgb, var(--color-border) 42%, transparent);
224 + border-radius: 7px;
225 + background: color-mix(in srgb, var(--color-text) 10%, transparent);
226 + color: var(--color-text-muted);
227 +}
228 +
229 +.settings-search:focus-within {
230 + border-color: color-mix(in srgb, var(--color-primary) 46%, var(--color-border));
231 + background: color-mix(in srgb, var(--color-text) 13%, transparent);
232 +}
233 +
234 +.settings-search .material-symbols-outlined {
235 + font-size: 19px;
236 +}
237 +
238 +.settings-search input[type="search"] {
239 + width: 100%;
240 + min-width: 0;
241 + height: 100%;
242 + padding: 0;
243 + border: 0;
244 + outline: 0;
245 + appearance: none;
246 + background: transparent;
247 + color: var(--color-text);
248 + font: inherit;
249 + font-size: 0.86rem;
250 +}
251 +
252 +.settings-search input[type="search"]::-webkit-search-decoration,
253 +.settings-search input[type="search"]::-webkit-search-cancel-button,
254 +.settings-search input[type="search"]::-webkit-search-results-button,
255 +.settings-search input[type="search"]::-webkit-search-results-decoration {
256 + display: none;
257 +}
258 +
259 +.settings-search input[type="search"]::placeholder {
260 + color: var(--color-text-muted);
261 + opacity: 0.9;
262 +}
263 +
264 +.settings-search-clear {
265 + display: inline-grid;
266 + place-items: center;
267 + width: 24px;
268 + height: 24px;
269 + min-width: 24px;
270 + padding: 0;
271 + border: 0;
272 + border-radius: 5px;
273 + background: transparent;
274 + color: var(--color-text-muted);
275 + cursor: pointer;
276 +}
277 +
278 +.settings-search-clear:hover {
279 + background: color-mix(in srgb, var(--color-text) 10%, transparent);
280 + color: var(--color-text);
281 +}
282 +
283 .settings-tabs {
284 display: flex;
285 flex-direction: column;
286 gap: 4px;
287 + flex: 1 1 auto;
288 width: 100%;
289 min-width: 0;
290 min-height: 0;
@@ -262,7 +336,7 @@ select:disabled {
336 transition: transform 0.16s ease;
337 }
338
265 -.settings-nav-group-active .settings-tab-chevron {
339 +.settings-nav-group-open .settings-tab-chevron {
340 transform: rotate(0deg);
341 }
342
@@ -353,6 +427,13 @@ select:disabled {
427 opacity: 1;
428 }
429
430 +.settings-search-empty {
431 + padding: 10px 9px;
432 + color: var(--color-text-muted);
433 + font-size: 0.8rem;
434 + line-height: 1.25;
435 +}
436 +
437 .settings-pane {
438 min-width: 0;
439 min-height: 0;