Organize settings into searchable sections with responsive navigation

This commit is contained in:
Jonathan Sykes
2026-10-03 14:42:39 +08:00
parent 264bb26a36
commit 09ae6a2a85
8 changed files with 193 additions and 0 deletions

View File

@@ -9674,6 +9674,21 @@ async function renderSettings() {
</div>`; </div>`;
c.appendChild(wrap); c.appendChild(wrap);
const definitions = [
['appearance', 'Appearance', 'Listening', () => `${data.settings.theme || 'dark'} · ${data.settings.fontScale || 'normal'} text`, '<circle cx="12" cy="12" r="8"/><path d="M12 4v16"/>'],
['playback', 'Playback & sound', 'Listening', () => `${data.settings.quality || 'auto'} quality · ${Level.enabled() ? 'Levelled volume' : 'Original volume'}`, '<path d="m9 5 10 7-10 7zM4 5v14"/>'],
['sharing', 'Sharing', 'Library & storage', () => data.settings.p2pShare !== false ? 'Sharing saved videos' : 'Sharing off', '<circle cx="6" cy="12" r="2"/><circle cx="18" cy="5" r="2"/><circle cx="18" cy="19" r="2"/><path d="m8 11 8-5M8 13l8 5"/>'],
['offline', 'Offline storage', 'Library & storage', () => `${SaveSlots.limit ? SaveSlots.limit() : Number(data.settings.parallelSaves) || 4} at once · ${$('cacheTotal')?.textContent || 'Checking storage'}`, '<path d="M12 3v12m-4-4 4 4 4-4M4 16v5h16v-5"/>'],
['searches', 'Saved searches', 'Library & storage', () => `${SearchLibrary.list().length} saved searches`, '<circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/>'],
['playlists', 'Playlists', 'Library & storage', () => `${data.playlists.length} playlists`, '<path d="M4 5h14M4 10h14M4 15h8m5-2v8m-3-4h6"/>'],
['backup', 'Backup & restore', 'Account & sync', () => data.settings.autoBackupEnabled ? 'Scheduled backup on' : 'Manual backups', '<path d="M3 12a9 9 0 1 0 3-7M3 3v6h6M12 8v8m-3-3 3 3 3-3"/>'],
['reset', 'Refresh & reset', 'Advanced & about', () => 'Refresh the app or clear device data', '<path d="M3 12a9 9 0 1 0 3-7M3 3v6h6"/>'],
['profile', 'Profile & sync', 'Account & sync', () => data.profile?.name || 'Not linked', '<circle cx="12" cy="8" r="4"/><path d="M4 21v-3a8 8 0 0 1 16 0v3"/>'],
['about', 'About', 'Advanced & about', () => `Version ${APP_VERSION}`, '<circle cx="12" cy="12" r="9"/><path d="M12 11v6m0-10v1"/>'],
];
const groups = [...wrap.querySelectorAll(':scope > .set-group')];
definitions.forEach(([id, title, cluster, summary, icon], i) => SettingsSections.register({ id, title, cluster, summary, icon, render(container) { container.append(groups[i]); } }));
SettingsSections.mount(wrap, { settings: data.settings, data, persist, toast });
// ---- Wire appearance / accessibility ---- // ---- Wire appearance / accessibility ----
$('setTheme').addEventListener('change', (e) => { data.settings.theme = e.target.value; applyAppearance(); persist(); }); $('setTheme').addEventListener('change', (e) => { data.settings.theme = e.target.value; applyAppearance(); persist(); });

View File

@@ -27,6 +27,7 @@
<link rel="stylesheet" href="theme-glass.css" /> <link rel="stylesheet" href="theme-glass.css" />
<link rel="stylesheet" href="theme-classic.css" /> <link rel="stylesheet" href="theme-classic.css" />
<link rel="stylesheet" href="recommendations.css" /> <link rel="stylesheet" href="recommendations.css" />
<link rel="stylesheet" href="settings-sections.css" />
</head> </head>
<body> <body>
<div class="app"> <div class="app">
@@ -652,6 +653,7 @@
<script src="p2p-transfer.js"></script> <script src="p2p-transfer.js"></script>
<script src="async-guard.js"></script> <script src="async-guard.js"></script>
<script src="sw-update.js"></script> <script src="sw-update.js"></script>
<script src="settings-sections.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,26 @@
.settings-browser { display: block; width: 100%; min-width: 0; }
.settings-browser [hidden] { display: none !important; }
.settings-search { margin-bottom: 12px; }
.settings-search input { width: 100%; min-height: 44px; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; font: inherit; }
.settings-overview h3 { color: var(--text-dim); margin: 16px 0 4px; font: 600 12px var(--ui, sans-serif); }
.settings-section-row { width: 100%; display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 8px; background: transparent; color: var(--text); border: 0; border-bottom: 1px solid var(--line-soft); text-align: left; cursor: pointer; font: inherit; }
.settings-section-row svg { width: 20px; height: 20px; flex: none; color: var(--accent-bright); }
.settings-section-row span { flex: 1; min-width: 0; }
.settings-section-row b { font-size: 14px; font-weight: 600; }
.settings-section-row small { display: block; color: var(--text-2); font-size: 12px; line-height: 1.4; margin-top: 2px; }
.settings-section-row:hover, .settings-section-row[aria-current="true"] { background: var(--bg-3); border-radius: 8px; }
.settings-browser :is(button,input,select,textarea):focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.settings-back { min-height: 44px; padding: 8px 12px; margin-bottom: 12px; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; font: inherit; cursor: pointer; }
.settings-browser .set-row { min-height: 44px; }
.settings-browser .set-row small { font-size: 12px; max-width: 100%; }
.settings-browser .set-row > span { min-width: 0; }
.settings-browser .set-row input[type="range"] { max-width: 42%; }
.settings-browser .settings-detail { display: none; min-width: 0; }
.settings-browser[data-detail="1"] .settings-overview { display: none; }
.settings-browser[data-detail="1"] .settings-detail { display: block; }
@media (min-width: 900px) {
.settings-grid { display: grid; grid-template-columns: minmax(160px, .8fr) minmax(0, 1.4fr); gap: 16px; }
.settings-browser .settings-overview, .settings-browser[data-detail="1"] .settings-overview, .settings-browser .settings-detail { display: block; }
.settings-back { display: none; }
.settings-browser .set-row { flex-wrap: wrap; gap: 12px; }
}

View File

@@ -0,0 +1,104 @@
/* Settings navigation and extension registry. All controls stay mounted. */
(function (root) {
'use strict';
const clusters = ['Listening', 'Library & storage', 'Live & worship', 'Account & sync', 'Advanced & about'];
const normalize = value => String(value || '').normalize('NFKD').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
function search(entries, query) {
const words = normalize(query).split(' ').filter(Boolean);
return entries.filter(entry => words.every(word => normalize(`${entry.title} ${entry.description || ''}`).includes(word)));
}
function createRegistry() {
const entries = new Map();
return {
register(section) {
if (!section || !/^[a-z][a-z0-9-]*$/.test(section.id) || !section.title || typeof section.render !== 'function') throw new TypeError('A settings section needs an id, title and render function.');
entries.set(section.id, { cluster: 'Advanced & about', summary: () => '', ...section });
return () => { if (entries.get(section.id)?.render === section.render) entries.delete(section.id); };
},
list() { return [...entries.values()]; },
get(id) { return entries.get(id); },
};
}
const registry = createRegistry();
let dispose = () => {};
const icon = paths => `<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${paths || '<circle cx="12" cy="12" r="7"/>'}</svg>`;
function mount(host, context = {}) {
dispose();
const document = host.ownerDocument, win = document.defaultView, sections = registry.list(), panels = new Map(), rows = new Map(), cleanups = [];
let remembered = ''; try { remembered = win.sessionStorage.getItem('ytpSettingsSection') || ''; } catch {}
let active = registry.get(remembered) ? remembered : sections[0]?.id;
host.classList.add('settings-browser');
const toolbar = document.createElement('div'); toolbar.className = 'settings-search';
const input = document.createElement('input'); input.type = 'search'; input.placeholder = 'Search settings'; input.setAttribute('aria-label', 'Search settings and controls'); toolbar.append(input);
const grid = document.createElement('div'); grid.className = 'settings-grid';
const navigation = document.createElement('nav'); navigation.className = 'settings-overview'; navigation.setAttribute('aria-label', 'Settings sections');
const detail = document.createElement('div'); detail.className = 'settings-detail';
const back = document.createElement('button'); back.type = 'button'; back.className = 'settings-back'; back.textContent = '← All settings'; detail.append(back);
const results = document.createElement('div'); results.className = 'settings-results'; results.hidden = true;
const empty = document.createElement('p'); empty.className = 'settings-empty'; empty.textContent = 'No settings match your search.'; empty.hidden = true;
for (const cluster of [...clusters, ...new Set(sections.map(s => s.cluster).filter(c => !clusters.includes(c)))]) {
const group = sections.filter(s => s.cluster === cluster); if (!group.length) continue;
const heading = document.createElement('h3'); heading.textContent = cluster; navigation.append(heading);
for (const section of group) {
const button = document.createElement('button'); button.type = 'button'; button.className = 'settings-section-row'; button.dataset.section = section.id;
button.innerHTML = icon(section.icon);
const text = document.createElement('span'), title = document.createElement('b'), summary = document.createElement('small'); title.textContent = section.title; text.append(title, summary); button.append(text);
button.setAttribute('aria-controls', 'settings-section-' + section.id); navigation.append(button); rows.set(section.id, { button, summary });
const panel = document.createElement('section'); panel.id = 'settings-section-' + section.id; panel.className = 'settings-section'; panel.setAttribute('aria-label', section.title); panels.set(section.id, panel); detail.append(panel);
const cleanup = section.render(panel, context); if (typeof cleanup === 'function') cleanups.push(cleanup);
button.addEventListener('click', () => open(section.id));
}
}
grid.append(navigation, detail); host.replaceChildren(toolbar, results, empty, grid);
function summaries() { for (const section of sections) { try { rows.get(section.id).summary.textContent = section.summary(context) || ''; } catch {} } }
function show(id, inDetail, focusId) {
if (registry.get(id)) active = id;
host.dataset.detail = inDetail ? '1' : '0';
for (const [key, panel] of panels) panel.hidden = key !== active;
for (const [key, row] of rows) row.button.setAttribute('aria-current', key === active ? 'true' : 'false');
if (inDetail) {
try { win.sessionStorage.setItem('ytpSettingsSection', active); } catch {}
const target = focusId ? document.getElementById(focusId) : panels.get(active)?.querySelector('input,select,button,textarea,summary');
if (target) { const fold = target.closest('details'); if (fold) fold.open = true; target.focus({ preventScroll: true }); target.scrollIntoView({ block: 'center', behavior: 'instant' }); }
}
}
function open(id, focusId) {
if (!panels.has(id)) return;
input.value = ''; filter();
win.history.pushState({ ...win.history.state, ytpSettingsSection: id }, '', win.location.href);
show(id, true, focusId);
}
function filter() {
const query = input.value.trim(); results.replaceChildren(); results.hidden = !query; grid.hidden = !!query;
if (!query) { empty.hidden = true; return; }
const entries = [];
for (const section of sections) {
entries.push({ title: section.title, description: section.description || '', section: section.id });
for (const el of panels.get(section.id).querySelectorAll('.set-row, .set-actions button, [data-settings-search]')) {
const control = el.matches('button,input,select') ? el : el.querySelector('input,select,button,textarea,summary');
if (control && !control.id) control.id = `settings-control-${section.id}-${entries.length}`;
const title = (el.querySelector('span')?.childNodes[0]?.textContent || el.textContent).trim();
entries.push({ title, description: `${section.title} ${el.textContent}`, section: section.id, control: control?.id });
}
}
const matches = search(entries, query); empty.hidden = matches.length > 0;
for (const item of matches.slice(0, 40)) {
const button = document.createElement('button'); button.type = 'button'; button.className = 'settings-section-row';
const text = document.createElement('span'); text.textContent = item.title; const subtitle = document.createElement('small'); subtitle.textContent = registry.get(item.section).title; text.append(subtitle); button.append(text);
button.addEventListener('click', () => open(item.section, item.control)); results.append(button);
}
}
back.addEventListener('click', () => { if (win.history.state?.ytpSettingsSection) win.history.back(); else show(active, false); });
const pop = event => { if (!host.isConnected) return; show(event.state?.ytpSettingsSection || active, !!event.state?.ytpSettingsSection); if (!event.state?.ytpSettingsSection) rows.get(active)?.button.focus({ preventScroll: true }); };
win.addEventListener('popstate', pop); input.addEventListener('input', filter);
host.addEventListener('change', summaries); host.addEventListener('input', summaries);
const observer = new win.MutationObserver(summaries); observer.observe(detail, { childList: true, subtree: true, characterData: true });
const state = win.history.state?.ytpSettingsSection;
if (!state) win.history.replaceState({ ...win.history.state, ytpSettingsSection: null }, '', win.location.href);
show(state || active, !!state); summaries();
dispose = () => { observer.disconnect(); win.removeEventListener('popstate', pop); cleanups.forEach(fn => fn()); };
return { open, refresh: summaries, dispose };
}
const api = { ...registry, mount, search, normalize, createRegistry, clusters };
if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.SettingsSections = api;
})(typeof globalThis !== 'undefined' ? globalThis : this);

View File

@@ -0,0 +1,18 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const Settings = require('./settings-sections');
test('registry supports extension sections, replacement and safe removal', () => {
const registry = Settings.createRegistry(), render = () => {};
const remove = registry.register({ id: 'pedals', title: 'MIDI pedals', render });
assert.equal(registry.get('pedals').cluster, 'Advanced & about');
registry.register({ id: 'pedals', title: 'Pedalboard', render: () => {} }); remove();
assert.equal(registry.get('pedals').title, 'Pedalboard');
assert.throws(() => registry.register({ id: '../bad', title: 'Invalid', render }));
});
test('settings search matches labels and help text across sections without case sensitivity', () => {
const rows = [{ title: 'Playback', description: 'Level volume between songs' }, { title: 'Cache size limit', description: 'Auto-evict oldest files' }];
assert.equal(Settings.search(rows, 'VOLUME songs')[0].title, 'Playback');
assert.equal(Settings.search(rows, 'oldest limit')[0].title, 'Cache size limit');
assert.equal(Settings.search(rows, 'unavailable').length, 0);
assert.equal(Settings.search(rows, '').length, 2);
});

View File

@@ -82,6 +82,8 @@ const SHELL = [
'/p2p-core.js', '/p2p-core.js',
'/p2p-transfer.js', '/p2p-transfer.js',
'/p2p-recv-worker.js', '/p2p-recv-worker.js',
'/settings-sections.js',
'/settings-sections.css',
'/app.js', '/app.js',
'/manifest.webmanifest', '/manifest.webmanifest',
'/icons/icon-192.png', '/icons/icon-192.png',

View File

@@ -0,0 +1,2 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({ ...require('./playwright.classic.config'), testMatch: /settings-sections\.spec\.js/ });

View File

@@ -0,0 +1,24 @@
const { test, expect } = require('@playwright/test');
const { openClassic } = require('./helpers/classic-fixture');
for (const layout of ['classic', 'glass-stage']) for (const width of [390, 1440]) {
test(`${layout} settings navigation at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 }); await openClassic(page);
await page.evaluate(layout => { data.settings.layout = layout; applyAppearance(); view = { type: 'settings' }; render(); }, layout);
await expect(page.locator('.settings-section-row').first()).toBeVisible();
await expect(page.locator('#setQuality')).toHaveCount(1);
if (width === 390) await expect(page.locator('#setQuality')).toBeHidden();
await page.locator('.settings-search input').fill('default quality');
await page.locator('.settings-results button').first().click();
await expect(page.locator('#setQuality')).toBeVisible();
await page.locator('#setQuality').selectOption('720p');
expect(await page.evaluate(() => data.settings.quality)).toBe('720p');
if (width === 390) { await page.goBack(); await expect(page.locator('.settings-overview')).toBeVisible(); await expect(page.locator('#setQuality')).toBeHidden(); }
await page.locator('.settings-section-row[data-section="appearance"]').click();
await expect(page.locator('#setTheme')).toBeVisible();
await page.locator('#setTheme').selectOption('light');
expect(await page.evaluate(() => data.settings.theme)).toBe('light');
await expect(page.locator('#setQuality')).toHaveValue('720p');
expect(await page.evaluate(() => sessionStorage.getItem('ytpSettingsSection'))).toBe('appearance');
const overflow = await page.locator('.settings-browser').evaluate(el => el.scrollWidth > el.clientWidth + 1); expect(overflow).toBe(false);
});
}