228 lines
5.7 KiB
JavaScript
228 lines
5.7 KiB
JavaScript
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 = (storageKey) => {
|
|
if (!storageKey) {
|
|
return [];
|
|
}
|
|
try {
|
|
const raw = window.localStorage.getItem(storageKey);
|
|
if (!raw) {
|
|
return [];
|
|
}
|
|
const parsed = JSON.parse(raw);
|
|
return normalizeKeys(parsed);
|
|
} catch (error) {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const writeStoredKeys = (storageKey, keys) => {
|
|
if (!storageKey) {
|
|
return;
|
|
}
|
|
try {
|
|
window.localStorage.setItem(storageKey, 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 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(storageKey, 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(storageKey);
|
|
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(storageKey),
|
|
writeOpenKeys,
|
|
};
|
|
}
|