Add searchable playlist dropdown and fix button text contrast

PlaylistModal changes:
- Replace native select with custom searchable dropdown
- Show max 8 playlists at a time with scroll indicator
- Add search/autocomplete functionality
- Support keyboard navigation (arrows, enter, escape)

Button contrast fix:
- Add --btn-primary-text CSS variable per theme
- Use dark text on light accent colors (Warm, Nord, Dracula, etc.)
- Use light text on dark accent colors (Adwaita, default)
This commit is contained in:
vicrodh committed 2026-01-27 04:46:10 -06:00
1 parent 561f7487d0
commit c190fe79b0
2 files changed
+272 -13

No files matched your search

+14 -1
View File
@@ -15,6 +15,7 @@
--accent-primary: #4285F4;
--accent-hover: #5a9bf4;
--accent-active: #3275e4;
--btn-primary-text: #ffffff;
--danger: #ef4444;
--danger-bg: rgba(239, 68, 68, 0.1);
@@ -82,6 +83,8 @@
--text-muted: #666666;
--text-disabled: #999999;
--btn-primary-text: #ffffff;
--border-subtle: #e0e0e0;
--border-strong: #cccccc;
@@ -139,6 +142,7 @@
--accent-primary: #d4a574;
--accent-hover: #e0b888;
--accent-active: #c49460;
--btn-primary-text: #1a1814;
--border-subtle: #2e2820;
--border-strong: #3d362a;
@@ -159,6 +163,7 @@
--accent-primary: #88c0d0;
--accent-hover: #81a1c1;
--accent-active: #5e81ac;
--btn-primary-text: #2e3440;
--border-subtle: #434c5e;
--border-strong: #4c566a;
@@ -179,6 +184,7 @@
--accent-primary: #bd93f9;
--accent-hover: #ff79c6;
--accent-active: #8be9fd;
--btn-primary-text: #282a36;
--border-subtle: #44475a;
--border-strong: #6272a4;
@@ -199,6 +205,7 @@
--accent-primary: #7aa2f7;
--accent-hover: #bb9af7;
--accent-active: #2ac3de;
--btn-primary-text: #1a1b26;
--border-subtle: #292e42;
--border-strong: #414868;
@@ -219,6 +226,7 @@
--accent-primary: #89b4fa;
--accent-hover: #74c7ec;
--accent-active: #b4befe;
--btn-primary-text: #11111b;
--border-subtle: #1e1e2e;
--border-strong: #313244;
@@ -239,6 +247,7 @@
--accent-primary: #907aa9;
--accent-hover: #286983;
--accent-active: #d7827e;
--btn-primary-text: #faf4ed;
--border-subtle: #f2e9e1;
--border-strong: #dfdad9;
@@ -259,6 +268,7 @@
--accent-primary: #3daee9;
--accent-hover: #9b59b6;
--accent-active: #1d99f3;
--btn-primary-text: #141618;
--border-subtle: #292c30;
--border-strong: #31363b;
@@ -279,6 +289,7 @@
--accent-primary: #3daee9;
--accent-hover: #9b59b6;
--accent-active: #2980b9;
--btn-primary-text: #ffffff;
--border-subtle: #dee0e2;
--border-strong: #c8cdd3;
@@ -299,6 +310,7 @@
--accent-primary: #3584e4;
--accent-hover: #1c71d8;
--accent-active: #1a5fb4;
--btn-primary-text: #ffffff;
--border-subtle: #28282c;
--border-strong: #2e2e32;
@@ -319,6 +331,7 @@
--accent-primary: #3584e4;
--accent-hover: #1c71d8;
--accent-active: #1a5fb4;
--btn-primary-text: #ffffff;
--border-subtle: #f3f3f5;
--border-strong: #deddda;
@@ -494,7 +507,7 @@ select {
.btn-primary {
background: var(--accent-primary);
color: var(--text-primary);
color: var(--btn-primary-text);
}
.btn-primary:hover:not(:disabled) {
+258 -12
View File
@@ -55,6 +55,13 @@
let loading = $state(false);
let error = $state<string | null>(null);
// Searchable playlist dropdown state
let playlistSearchQuery = $state('');
let isPlaylistDropdownOpen = $state(false);
let highlightedIndex = $state(-1);
let dropdownRef = $state<HTMLDivElement | null>(null);
let inputRef = $state<HTMLInputElement | null>(null);
// Folders state
let folders = $state<PlaylistFolder[]>([]);
@@ -88,6 +95,85 @@
return pl.tracks_count + localCount;
}
// Filtered playlists based on search query
const filteredPlaylists = $derived(
playlistSearchQuery.trim()
? userPlaylists.filter(pl =>
pl.name.toLowerCase().includes(playlistSearchQuery.toLowerCase())
)
: userPlaylists
);
// Get display text for selected playlist
const selectedPlaylistDisplay = $derived(() => {
if (selectedPlaylistId === null) return '';
if (selectedPlaylistId === CREATE_NEW_PLAYLIST) return '+ Create new playlist';
const pl = userPlaylists.find(p => p.id === selectedPlaylistId);
return pl ? `${pl.name} (${getTotalTrackCount(pl)} tracks)` : '';
});
// Handle dropdown item click
function selectPlaylist(id: number | null) {
selectedPlaylistId = id;
playlistSearchQuery = '';
isPlaylistDropdownOpen = false;
highlightedIndex = -1;
}
// Handle dropdown keyboard navigation
function handleDropdownKeydown(e: KeyboardEvent) {
if (!isPlaylistDropdownOpen) {
if (e.key === 'ArrowDown' || e.key === 'Enter') {
isPlaylistDropdownOpen = true;
highlightedIndex = 0;
e.preventDefault();
}
return;
}
const totalItems = filteredPlaylists.length + 1; // +1 for "Create new" option
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
highlightedIndex = (highlightedIndex + 1) % totalItems;
break;
case 'ArrowUp':
e.preventDefault();
highlightedIndex = highlightedIndex <= 0 ? totalItems - 1 : highlightedIndex - 1;
break;
case 'Enter':
e.preventDefault();
if (highlightedIndex === 0) {
selectPlaylist(CREATE_NEW_PLAYLIST);
} else if (highlightedIndex > 0 && highlightedIndex <= filteredPlaylists.length) {
selectPlaylist(filteredPlaylists[highlightedIndex - 1].id);
}
break;
case 'Escape':
e.preventDefault();
isPlaylistDropdownOpen = false;
highlightedIndex = -1;
break;
}
}
// Close dropdown when clicking outside
function handleClickOutside(e: MouseEvent) {
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
isPlaylistDropdownOpen = false;
highlightedIndex = -1;
}
}
// Register click outside listener when dropdown opens
$effect(() => {
if (isPlaylistDropdownOpen) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
// Subscribe to offline state changes
$effect(() => {
const unsubscribe = subscribeOffline(() => {
@@ -128,6 +214,9 @@
} else if (mode === 'addTrack') {
selectedPlaylistId = null;
folderId = null;
playlistSearchQuery = '';
isPlaylistDropdownOpen = false;
highlightedIndex = -1;
loadLocalTrackCounts();
}
}
@@ -452,18 +541,61 @@
</div>
<div class="form-group">
<label for="playlist-select">Choose playlist</label>
<select
id="playlist-select"
bind:value={selectedPlaylistId}
disabled={loading}
>
<option value={null}>Select a playlist...</option>
<option value={CREATE_NEW_PLAYLIST}>+ Create new playlist</option>
{#each userPlaylists as pl (pl.id)}
<option value={pl.id}>{pl.name} ({getTotalTrackCount(pl)} tracks)</option>
{/each}
</select>
<label for="playlist-search">Choose playlist</label>
<div class="playlist-dropdown" bind:this={dropdownRef}>
<input
type="text"
id="playlist-search"
bind:this={inputRef}
bind:value={playlistSearchQuery}
placeholder={selectedPlaylistId ? selectedPlaylistDisplay() : 'Search playlists...'}
disabled={loading}
onfocus={() => isPlaylistDropdownOpen = true}
onkeydown={handleDropdownKeydown}
autocomplete="off"
/>
{#if selectedPlaylistId !== null && !isPlaylistDropdownOpen}
<button
type="button"
class="clear-selection"
onclick={() => selectPlaylist(null)}
disabled={loading}
>
<X size={14} />
</button>
{/if}
{#if isPlaylistDropdownOpen}
<div class="playlist-dropdown-list">
<button
type="button"
class="playlist-dropdown-item create-new"
class:highlighted={highlightedIndex === 0}
onclick={() => selectPlaylist(CREATE_NEW_PLAYLIST)}
>
+ Create new playlist
</button>
{#each filteredPlaylists.slice(0, 8) as pl, i (pl.id)}
<button
type="button"
class="playlist-dropdown-item"
class:highlighted={highlightedIndex === i + 1}
onclick={() => selectPlaylist(pl.id)}
>
<span class="playlist-name">{pl.name}</span>
<span class="playlist-count">{getTotalTrackCount(pl)} tracks</span>
</button>
{/each}
{#if filteredPlaylists.length === 0 && playlistSearchQuery.trim()}
<div class="no-results">No playlists found</div>
{/if}
{#if filteredPlaylists.length > 8}
<div class="more-results">
{filteredPlaylists.length - 8} more playlist{filteredPlaylists.length - 8 !== 1 ? 's' : ''}...
</div>
{/if}
</div>
{/if}
</div>
</div>
{#if selectedPlaylistId === CREATE_NEW_PLAYLIST}
@@ -715,6 +847,120 @@
padding: 10px;
}
/* Searchable playlist dropdown */
.playlist-dropdown {
position: relative;
}
.playlist-dropdown input {
width: 100%;
padding: 10px 36px 10px 12px;
background: var(--bg-secondary);
border: 1px solid var(--bg-tertiary);
border-radius: 8px;
font-size: 14px;
color: var(--text-primary);
transition: border-color 150ms ease;
}
.playlist-dropdown input:focus {
outline: none;
border-color: var(--accent-primary);
}
.playlist-dropdown input::placeholder {
color: var(--text-muted);
}
.clear-selection {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--bg-tertiary);
color: var(--text-muted);
cursor: pointer;
transition: all 150ms ease;
}
.clear-selection:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.playlist-dropdown-list {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 320px;
overflow-y: auto;
background: var(--bg-secondary);
border: 1px solid var(--bg-tertiary);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
z-index: 100;
}
.playlist-dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 10px 12px;
background: none;
border: none;
font-size: 14px;
color: var(--text-primary);
cursor: pointer;
text-align: left;
transition: background-color 150ms ease;
}
.playlist-dropdown-item:hover,
.playlist-dropdown-item.highlighted {
background: var(--bg-hover);
}
.playlist-dropdown-item.create-new {
color: var(--accent-primary);
font-weight: 500;
border-bottom: 1px solid var(--bg-tertiary);
}
.playlist-dropdown-item .playlist-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 8px;
}
.playlist-dropdown-item .playlist-count {
font-size: 12px;
color: var(--text-muted);
flex-shrink: 0;
}
.no-results,
.more-results {
padding: 10px 12px;
font-size: 13px;
color: var(--text-muted);
text-align: center;
}
.more-results {
border-top: 1px solid var(--bg-tertiary);
font-style: italic;
}
.form-group input[type="text"]:focus,
.form-group textarea:focus,
.form-group select:focus {