114 lines
8.8 KiB
JavaScript
114 lines
8.8 KiB
JavaScript
/* 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);
|
|
if (!section.lazyGroup) {
|
|
const cleanup = section.render(panel, context); if (typeof cleanup === 'function') cleanups.push(cleanup);
|
|
}
|
|
button.addEventListener('click', () => open(section.id));
|
|
if(section.lazyGroup)root.Lazy.intent(button,section.lazyGroup);
|
|
}
|
|
}
|
|
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;
|
|
const section = sections.find(section => section.id === active);
|
|
const panel = panels.get(active);
|
|
if ((inDetail || win.matchMedia?.('(min-width: 861px)').matches) && section?.lazyGroup && !panel.dataset.rendered) {
|
|
panel.dataset.rendered = '1';
|
|
const cleanup = section.render(panel, context); if (typeof cleanup === 'function') cleanups.push(cleanup);
|
|
}
|
|
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);
|