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.
This commit is contained in:
Alessandro 2026-06-09 00:47:57 +02:00
parent f9031b7575
commit 60462eabbd
4 changed files with 201 additions and 21 deletions

View file

@ -16,9 +16,9 @@
</a>
</li>
<li>
<a href="#section-update-advanced">
<a href="#section-backup-restore">
<img src="/public/backup_restore.svg" alt="Backup & Restore" />
<span>Advanced Settings</span>
<span>Backup &amp; Restore</span>
</a>
</li>
</ul>
@ -28,18 +28,8 @@
<x-component path="settings/backup/self-update.html"></x-component>
</div>
<div id="section-update-advanced" class="section" x-data="{ advOpen: false }">
<div class="settings-advanced-section">
<button type="button" class="settings-advanced-toggle" @click="advOpen = !advOpen">
<span class="material-symbols-outlined settings-advanced-toggle-icon"
:style="advOpen ? 'transform:rotate(90deg)' : ''">chevron_right</span>
<span>Advanced Settings</span>
</button>
<div class="settings-advanced-body" x-show="advOpen" x-transition.opacity>
<x-component path="settings/backup/backup_restore.html"></x-component>
</div>
</div>
<div id="section-backup-restore" class="section">
<x-component path="settings/backup/backup_restore.html"></x-component>
</div>
</div>
</template>

View file

@ -74,7 +74,7 @@ const TAB_ITEMS = Object.freeze([
icon: "system_update_alt",
sections: [
{ id: "section-self-update", label: "Self Update", icon: "system_update_alt" },
{ id: "section-update-advanced", label: "Advanced Settings", icon: "tune" },
{ id: "section-backup-restore", label: "Backup & Restore", icon: "backup" },
],
},
]);
@ -106,6 +106,8 @@ const model = {
_paneScrollPane: null,
_scrollSyncFrame: null,
_updateStatusRefreshedAt: 0,
expandedNavGroups: {},
searchQuery: "",
// Tab state
_activeTab: DEFAULT_TAB,
@ -130,6 +132,7 @@ const model = {
if (saved) this._activeTab = this.normalizeTabId(saved);
} catch {}
this._activeSection = this.getFirstSectionId(this._activeTab);
this.expandedNavGroups = this.createDefaultExpandedNavGroups(this._activeTab);
},
async onOpen() {
@ -176,6 +179,7 @@ const model = {
this.additional = null;
this.error = null;
this.isLoading = false;
this.searchQuery = "";
},
// Tab management
@ -189,6 +193,7 @@ const model = {
localStorage.setItem(VIEW_MODE_STORAGE_KEY, current);
} catch {}
this.setNavGroupExpanded(current, true);
this.bindPaneScroll();
},
@ -204,6 +209,29 @@ const model = {
return TAB_ITEMS;
},
get normalizedSearchQuery() {
return String(this.searchQuery || "").trim().toLowerCase();
},
get hasSearchQuery() {
return this.normalizedSearchQuery.length > 0;
},
get filteredNavItems() {
const query = this.normalizedSearchQuery;
if (!query) return TAB_ITEMS;
return TAB_ITEMS
.map((item) => {
const itemMatches = this.getNavSearchText(item).includes(query);
const sections = itemMatches
? item.sections
: item.sections.filter((section) => this.getNavSearchText(section).includes(query));
return sections.length ? { ...item, sections } : null;
})
.filter(Boolean);
},
get activeTabItem() {
return TAB_ITEMS.find((item) => item.id === this.activeTab) || TAB_ITEMS[0];
},
@ -217,6 +245,59 @@ const model = {
return tab?.sections?.[0]?.id || null;
},
getNavSearchText(item) {
return `${item?.label || ""} ${item?.id || ""}`.toLowerCase();
},
createDefaultExpandedNavGroups(activeTab = this.activeTab) {
return TAB_ITEMS.reduce((groups, item) => {
groups[item.id] = item.id === activeTab;
return groups;
}, {});
},
isNavGroupExpanded(tabName) {
if (this.hasSearchQuery) return true;
const tabId = this.normalizeTabId(tabName);
return Boolean(this.expandedNavGroups?.[tabId]);
},
setNavGroupExpanded(tabName, expanded) {
const tabId = this.normalizeTabId(tabName);
this.expandedNavGroups = {
...(this.expandedNavGroups || {}),
[tabId]: Boolean(expanded),
};
},
toggleNavGroup(tabName) {
const tabId = this.normalizeTabId(tabName);
if (this.hasSearchQuery) {
this.enterTab(tabId);
return;
}
if (this.activeTab !== tabId) {
this.enterTab(tabId);
this.setNavGroupExpanded(tabId, true);
return;
}
this.setNavGroupExpanded(tabId, !this.isNavGroupExpanded(tabId));
},
clearSearch() {
this.searchQuery = "";
},
openFirstSearchResult() {
const item = this.filteredNavItems[0];
const section = item?.sections?.[0];
if (section?.id) {
this.scrollToSection(section.id);
} else if (item?.id) {
this.enterTab(item.id);
}
},
get browserTimezone() {
return getBrowserTimezone();
},
@ -281,6 +362,7 @@ const model = {
enterTab(tabName) {
this.activeTab = tabName;
this._activeSection = this.getFirstSectionId(this.activeTab);
this.setNavGroupExpanded(this.activeTab, true);
this.resetPaneScroll();
if (tabName === "backup") this.refreshUpdateStatus();
},

View file

@ -29,20 +29,44 @@
<div x-show="$store.settings.settings" class="settings-content">
<!-- Tab Navigation -->
<aside class="settings-tabs-container" aria-label="Settings categories">
<div class="settings-search" role="search">
<span class="material-symbols-outlined" aria-hidden="true">search</span>
<input
type="search"
x-model.debounce.100ms="$store.settings.searchQuery"
@keydown.enter.prevent="$store.settings.openFirstSearchResult()"
placeholder="Search"
aria-label="Search settings"
autocomplete="off"
/>
<button
type="button"
class="settings-search-clear"
x-show="$store.settings.hasSearchQuery"
@click="$store.settings.clearSearch()"
aria-label="Clear search"
>
<span class="material-symbols-outlined" aria-hidden="true">close</span>
</button>
</div>
<div class="settings-tabs no-scrollbar" role="tablist" aria-orientation="vertical">
<template x-for="item in $store.settings.navItems" :key="item.id">
<template x-for="item in $store.settings.filteredNavItems" :key="item.id">
<div class="settings-nav-group"
:class="{'settings-nav-group-active': $store.settings.activeTab === item.id}">
:class="{
'settings-nav-group-active': $store.settings.activeTab === item.id,
'settings-nav-group-open': $store.settings.isNavGroupExpanded(item.id)
}">
<button type="button"
class="settings-tab settings-parent-tab"
role="tab"
:aria-selected="$store.settings.activeTab === item.id"
:aria-expanded="$store.settings.activeTab === item.id"
:aria-expanded="$store.settings.isNavGroupExpanded(item.id)"
:class="{
'active': $store.settings.activeTab === item.id,
'settings-tab-attention': $store.settings.navItemHasAttention(item)
}"
@click="$store.settings.enterTab(item.id)">
@click="$store.settings.toggleNavGroup(item.id)">
<span class="material-symbols-outlined" aria-hidden="true" x-text="item.icon"></span>
<span class="settings-tab-label" x-text="item.label"></span>
<span class="settings-tab-meta">
@ -55,7 +79,7 @@
</button>
<div class="settings-section-list"
x-show="$store.settings.activeTab === item.id"
x-show="$store.settings.isNavGroupExpanded(item.id)"
x-transition.opacity.duration.120ms>
<template x-for="section in item.sections" :key="section.id">
<a class="settings-section-link"
@ -76,6 +100,9 @@
</div>
</div>
</template>
<div class="settings-search-empty" x-show="$store.settings.hasSearchQuery && !$store.settings.filteredNavItems.length">
No settings found
</div>
</div>
</aside>

View file

@ -196,6 +196,9 @@ select:disabled {
.settings-tabs-container {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 10px;
align-self: stretch;
z-index: 2;
min-width: 0;
@ -207,10 +210,81 @@ select:disabled {
background: color-mix(in srgb, var(--color-panel) 44%, transparent);
}
.settings-search {
display: grid;
grid-template-columns: 20px minmax(0, 1fr) 24px;
align-items: center;
gap: 5px;
flex: 0 0 auto;
width: 100%;
min-width: 0;
height: 34px;
padding: 0 7px;
border: 1px solid color-mix(in srgb, var(--color-border) 42%, transparent);
border-radius: 7px;
background: color-mix(in srgb, var(--color-text) 10%, transparent);
color: var(--color-text-muted);
}
.settings-search:focus-within {
border-color: color-mix(in srgb, var(--color-primary) 46%, var(--color-border));
background: color-mix(in srgb, var(--color-text) 13%, transparent);
}
.settings-search .material-symbols-outlined {
font-size: 19px;
}
.settings-search input[type="search"] {
width: 100%;
min-width: 0;
height: 100%;
padding: 0;
border: 0;
outline: 0;
appearance: none;
background: transparent;
color: var(--color-text);
font: inherit;
font-size: 0.86rem;
}
.settings-search input[type="search"]::-webkit-search-decoration,
.settings-search input[type="search"]::-webkit-search-cancel-button,
.settings-search input[type="search"]::-webkit-search-results-button,
.settings-search input[type="search"]::-webkit-search-results-decoration {
display: none;
}
.settings-search input[type="search"]::placeholder {
color: var(--color-text-muted);
opacity: 0.9;
}
.settings-search-clear {
display: inline-grid;
place-items: center;
width: 24px;
height: 24px;
min-width: 24px;
padding: 0;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
}
.settings-search-clear:hover {
background: color-mix(in srgb, var(--color-text) 10%, transparent);
color: var(--color-text);
}
.settings-tabs {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1 1 auto;
width: 100%;
min-width: 0;
min-height: 0;
@ -262,7 +336,7 @@ select:disabled {
transition: transform 0.16s ease;
}
.settings-nav-group-active .settings-tab-chevron {
.settings-nav-group-open .settings-tab-chevron {
transform: rotate(0deg);
}
@ -353,6 +427,13 @@ select:disabled {
opacity: 1;
}
.settings-search-empty {
padding: 10px 9px;
color: var(--color-text-muted);
font-size: 0.8rem;
line-height: 1.25;
}
.settings-pane {
min-width: 0;
min-height: 0;