/** * Persists open/closed state of details elements to localStorage by key. */ import { createUiStorage } from './app-ui-storage.js'; const toElement = (value) => { if (value instanceof HTMLElement) { return value; } if (typeof value === 'string') { return document.querySelector(value); } return null; }; const normalizeKeys = (value) => Array.from(new Set( (Array.isArray(value) ? value : []) .map((key) => String(key || '').trim()) .filter(Boolean) )); const readStoredKeys = (storage, scopedStorageKey) => { if (!scopedStorageKey) { return []; } try { const raw = storage.getItem(scopedStorageKey); if (!raw) { return []; } const parsed = JSON.parse(raw); return normalizeKeys(parsed); } catch (error) { return []; } }; const writeStoredKeys = (storage, scopedStorageKey, keys) => { if (!scopedStorageKey) { return; } try { storage.setItem(scopedStorageKey, JSON.stringify(normalizeKeys(keys))); } catch (error) { // ignore storage errors } }; const resolveEnsureKeys = (value) => { if (typeof value === 'function') { return normalizeKeys(value()); } return normalizeKeys(value); }; export function initPersistedDetailsGroup(options = {}) { const root = toElement(options.root || options.group || null); if (!root) { return null; } const storageKey = String(options.storageKey || '').trim(); if (storageKey === '') { return null; } const storage = createUiStorage({ namespace: options.storageNamespace || 'app-ui', version: options.storageVersion || 'v1', scope: options.storageScope || 'details-open', }); const scopedStorageKey = storage.buildKey('state', storageKey); if (!scopedStorageKey) { return null; } const detailsSelector = String(options.detailsSelector || 'details[data-details-key]').trim(); const alwaysOpenSelector = String(options.alwaysOpenSelector || 'details[data-details-always-open]').trim(); const ensureOpenKeysSource = options.ensureOpenKeys ?? []; const managedDetails = Array.from(root.querySelectorAll(detailsSelector)) .filter((details) => details instanceof HTMLDetailsElement); const alwaysOpenDetails = Array.from(root.querySelectorAll(alwaysOpenSelector)) .filter((details) => details instanceof HTMLDetailsElement); if (!managedDetails.length && !alwaysOpenDetails.length) { return null; } const detailsByKey = new Map(); managedDetails.forEach((details) => { const key = String(details.dataset.detailsKey || '').trim(); if (key !== '' && !detailsByKey.has(key)) { detailsByKey.set(key, details); } }); let isInternalSync = false; const withInternalSync = (callback) => { isInternalSync = true; try { callback(); } finally { isInternalSync = false; } }; const applyAlwaysOpen = () => { withInternalSync(() => { alwaysOpenDetails.forEach((details) => { details.open = true; }); }); }; const collectOpenKeys = () => managedDetails .filter((details) => details.open) .map((details) => String(details.dataset.detailsKey || '').trim()) .filter(Boolean); const mergeOpenKeys = (keys) => { const merged = new Set(normalizeKeys(keys)); resolveEnsureKeys(ensureOpenKeysSource).forEach((key) => merged.add(key)); alwaysOpenDetails.forEach((details) => { const key = String(details.dataset.detailsKey || '').trim(); if (key !== '') { merged.add(key); } }); return Array.from(merged); }; const writeOpenKeys = (keys) => { writeStoredKeys(storage, scopedStorageKey, mergeOpenKeys(keys)); }; const persistCurrentState = () => { writeOpenKeys(collectOpenKeys()); }; const ensureOpen = (keys) => { const normalizedKeys = normalizeKeys(keys); if (!normalizedKeys.length) { return; } withInternalSync(() => { normalizedKeys.forEach((key) => { const details = detailsByKey.get(key); if (details) { details.open = true; } }); }); }; const apply = () => { const storedKeys = readStoredKeys(storage, scopedStorageKey); const ensureKeys = resolveEnsureKeys(ensureOpenKeysSource); if (storedKeys.length > 0) { const nextOpenSet = new Set(mergeOpenKeys(storedKeys)); withInternalSync(() => { managedDetails.forEach((details) => { const key = String(details.dataset.detailsKey || '').trim(); if (key !== '') { details.open = nextOpenSet.has(key); } }); }); } else if (ensureKeys.length > 0) { ensureOpen(ensureKeys); } applyAlwaysOpen(); persistCurrentState(); }; const onManagedToggle = (event) => { if (isInternalSync) { return; } const target = event.currentTarget instanceof HTMLDetailsElement ? event.currentTarget : null; if (!target) { return; } const key = String(target.dataset.detailsKey || '').trim(); if (key !== '' && resolveEnsureKeys(ensureOpenKeysSource).includes(key) && !target.open) { withInternalSync(() => { target.open = true; }); } applyAlwaysOpen(); persistCurrentState(); }; const onAlwaysOpenToggle = (event) => { if (isInternalSync) { return; } const target = event.currentTarget instanceof HTMLDetailsElement ? event.currentTarget : null; if (!target) { return; } if (!target.open) { withInternalSync(() => { target.open = true; }); } persistCurrentState(); }; managedDetails.forEach((details) => { details.addEventListener('toggle', onManagedToggle); }); alwaysOpenDetails .filter((details, index, list) => list.indexOf(details) === index) .forEach((details) => { details.addEventListener('toggle', onAlwaysOpenToggle); }); apply(); return { apply, destroy: () => { managedDetails.forEach((details) => { details.removeEventListener('toggle', onManagedToggle); }); alwaysOpenDetails .filter((details, index, list) => list.indexOf(details) === index) .forEach((details) => { details.removeEventListener('toggle', onAlwaysOpenToggle); }); }, readOpenKeys: () => readStoredKeys(storage, scopedStorageKey), writeOpenKeys, }; }