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 & 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;