refactor(ui): improve accessibility and security in "In Your Library" section

Replace h2 toggle with semantic button and aria-expanded attribute,
switch from style.display to hidden attribute for visibility control,
use DOM methods instead of innerHTML for source labels and modal
options to prevent XSS, improve artist matching with ID-based lookup,
and clean up event listeners before re-attaching to prevent leaks.
This commit is contained in:
Xenuel 2026-03-16 23:46:25 +01:00
parent 52d5166363
commit 43f816ad25
4 changed files with 59 additions and 28 deletions

View file

@ -3297,13 +3297,15 @@
<div class="track-list" id="artist-detail-tracks"></div>
</section>
<section class="content-section" id="artist-section-in-library" style="display: none">
<h2 class="section-title in-library-toggle" id="in-library-toggle" style="cursor: pointer; display: flex; align-items: center; gap: 0.5rem; user-select: none;">
In Your Library
<svg id="in-library-chevron" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transition: transform 0.25s ease; flex-shrink: 0;">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
<h2 class="section-title">
<button id="in-library-toggle" aria-expanded="false" aria-controls="artist-detail-in-library" style="background: none; border: none; color: inherit; font: inherit; cursor: pointer; display: flex; align-items: center; gap: 0.5rem; padding: 0; user-select: none;">
In Your Library
<svg id="in-library-chevron" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transition: transform 0.25s ease; flex-shrink: 0;">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</h2>
<div class="track-list" id="artist-detail-in-library" style="display: none;"></div>
<div class="track-list" id="artist-detail-in-library" hidden></div>
</section>
<section class="content-section">
<h2 class="section-title">Albums</h2>

View file

@ -585,6 +585,7 @@ export class MusicDatabase {
// TRIGGER SYNC
this._dispatchPlaylistSync('create', playlist);
window.dispatchEvent(new CustomEvent('playlist-tracks-changed'));
return playlist;
}
@ -657,6 +658,7 @@ export class MusicDatabase {
// TRIGGER SYNC (but for deleting)
this._dispatchPlaylistSync('delete', { id: playlistId });
window.dispatchEvent(new CustomEvent('playlist-tracks-changed'));
}
async getPlaylist(playlistId) {

View file

@ -3953,11 +3953,13 @@ export class UIRenderer {
if (inLibrarySection) inLibrarySection.style.display = 'none';
if (inLibraryContainer) {
inLibraryContainer.innerHTML = '';
inLibraryContainer.style.display = 'none';
inLibraryContainer.hidden = true;
}
// Reset chevron state
// Reset chevron and toggle state
const chevronEl = document.getElementById('in-library-chevron');
if (chevronEl) chevronEl.style.transform = 'rotate(0deg)';
const toggleBtn = document.getElementById('in-library-toggle');
if (toggleBtn) toggleBtn.setAttribute('aria-expanded', 'false');
try {
const artist = await this.api.getArtist(artistId, provider);
@ -4199,12 +4201,16 @@ export class UIRenderer {
const isTrackByArtist = (track) => {
if (track.artists && Array.isArray(track.artists)) {
return track.artists.some(
(a) => a && a.name && a.name.toLowerCase() === artistNameLower
(a) => a && ((artist.id && a.id === artist.id) || (a.name && a.name.toLowerCase() === artistNameLower))
);
}
if (track.artist) {
const artistStr = typeof track.artist === 'string' ? track.artist : track.artist.name;
if (artistStr && artistStr.toLowerCase() === artistNameLower) return true;
if (typeof track.artist === 'object') {
if (artist.id && track.artist.id === artist.id) return true;
if (track.artist.name && track.artist.name.toLowerCase() === artistNameLower) return true;
} else if (typeof track.artist === 'string') {
if (track.artist.toLowerCase() === artistNameLower) return true;
}
}
return false;
};
@ -4285,18 +4291,27 @@ export class UIRenderer {
const sourceSpan = document.createElement('span');
sourceSpan.className = 'library-source';
const labelSpan = document.createElement('span');
labelSpan.className = 'library-source-label';
labelSpan.textContent = '· Source:\u00a0';
const linkSpan = document.createElement('span');
linkSpan.className = 'library-source-link';
sourceSpan.style.cursor = 'pointer';
sourceSpan.appendChild(labelSpan);
sourceSpan.appendChild(linkSpan);
if (sources.length === 1) {
const srcLabel = sources[0].label.length > 15 ? sources[0].label.slice(0, 15) + '…' : sources[0].label;
sourceSpan.innerHTML = `<span class="library-source-label">· Source:&nbsp;</span><span class="library-source-link">${srcLabel}</span>`;
sourceSpan.style.cursor = 'pointer';
linkSpan.textContent = srcLabel;
sourceSpan.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
navigate(sources[0].href);
});
} else {
sourceSpan.innerHTML = `<span class="library-source-label">· Source:&nbsp;</span><span class="library-source-link">Multiple Playlists</span>`;
sourceSpan.style.cursor = 'pointer';
linkSpan.textContent = 'Multiple Playlists';
sourceSpan.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
@ -4306,11 +4321,16 @@ export class UIRenderer {
const cancelBtn = document.getElementById('goto-playlist-cancel');
const overlay = modal.querySelector('.modal-overlay');
list.innerHTML = sources.map((s) =>
`<div class="modal-option" data-href="${s.href}">
<span>${s.label}</span>
</div>`
).join('');
list.innerHTML = '';
sources.forEach((s) => {
const option = document.createElement('div');
option.className = 'modal-option';
option.dataset.href = s.href;
const span = document.createElement('span');
span.textContent = s.label;
option.appendChild(span);
list.appendChild(option);
});
const closeModal = () => {
modal.classList.remove('active');
@ -4357,7 +4377,7 @@ export class UIRenderer {
// Initial load
refreshInLibrary().then(() => {
inLibraryContainer.style.display = 'none';
inLibraryContainer.hidden = true;
});
// Setup chevron toggle (once)
@ -4365,8 +4385,9 @@ export class UIRenderer {
const chevron = document.getElementById('in-library-chevron');
if (toggle) {
toggle.onclick = () => {
const isOpen = inLibraryContainer.style.display !== 'none';
inLibraryContainer.style.display = isOpen ? 'none' : '';
const isOpen = !inLibraryContainer.hidden;
inLibraryContainer.hidden = isOpen;
toggle.setAttribute('aria-expanded', String(!isOpen));
if (chevron) {
chevron.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(90deg)';
}
@ -4379,15 +4400,17 @@ export class UIRenderer {
clearTimeout(refreshTimeout);
refreshTimeout = setTimeout(() => refreshInLibrary(), 300);
};
window.addEventListener('favorites-changed', debouncedRefresh);
window.addEventListener('playlist-tracks-changed', debouncedRefresh);
// Cleanup listeners when navigating away
// Cleanup previous listeners before attaching new ones
const cleanupOnNav = () => {
window.removeEventListener('favorites-changed', debouncedRefresh);
window.removeEventListener('playlist-tracks-changed', debouncedRefresh);
window.removeEventListener('popstate', cleanupOnNav);
};
cleanupOnNav();
window.addEventListener('favorites-changed', debouncedRefresh);
window.addEventListener('playlist-tracks-changed', debouncedRefresh);
window.addEventListener('popstate', cleanupOnNav, { once: true });
}

View file

@ -1994,6 +1994,10 @@ input[type='search']::-webkit-search-cancel-button {
gap: 2px var(--spacing-xl);
}
#artist-detail-in-library[hidden] {
display: none;
}
.library-source {
color: var(--muted-foreground);
font-size: inherit;
@ -2365,8 +2369,8 @@ input[type='search']::-webkit-search-cancel-button {
align-items: center;
gap: 1rem;
flex-wrap: wrap;
overflow-wrap: break-word;
word-break: break-word;
overflow-wrap: anywhere;
word-break: normal;
min-width: 0;
}