Skip to content

Commit be560ce

Browse files
fix(ui): prevent text selection on all UI chrome
Add select-none to root containers of sidebar, footer, albums, artists, library, queue, settings, modals, and drop overlay views. Sidebar search input conditionally allows selection only when it contains text. Now-playing intentionally left selectable for lyrics copy. Add E2E tests asserting user-select: none on all protected views. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent da5b2b3 commit be560ce

10 files changed

Lines changed: 165 additions & 9 deletions

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { test, expect } from '@playwright/test';
2+
import { waitForAlpine } from './fixtures/helpers.js';
3+
import { createLibraryState, setupLibraryMocks } from './fixtures/mock-library.js';
4+
import { createPlaylistState, setupPlaylistMocks } from './fixtures/mock-playlists.js';
5+
6+
/**
7+
* Text Selection Tests
8+
*
9+
* Verifies that UI chrome (sidebar, footer, albums, artists, settings, etc.)
10+
* prevents text selection, while content areas like lyrics remain selectable.
11+
*/
12+
13+
async function setupMocks(page) {
14+
const libraryState = createLibraryState();
15+
await setupLibraryMocks(page, libraryState);
16+
17+
const playlistState = createPlaylistState();
18+
await setupPlaylistMocks(page, playlistState);
19+
20+
await page.route(/\/api\/lastfm\/settings/, async (route) => {
21+
await route.fulfill({
22+
status: 200,
23+
contentType: 'application/json',
24+
body: JSON.stringify({
25+
enabled: false,
26+
username: null,
27+
authenticated: false,
28+
configured: false,
29+
scrobble_threshold: 50,
30+
}),
31+
});
32+
});
33+
}
34+
35+
/**
36+
* Assert that an element has user-select: none computed style
37+
*/
38+
async function getUserSelect(locator) {
39+
return await locator.evaluate((el) => {
40+
const style = window.getComputedStyle(el);
41+
return style.userSelect || style.webkitUserSelect;
42+
});
43+
}
44+
45+
async function expectNoTextSelection(page, selector, description) {
46+
const userSelect = await getUserSelect(page.locator(selector).first());
47+
expect(userSelect, `${description} should have user-select: none`).toBe(
48+
'none',
49+
);
50+
}
51+
52+
test.describe('Text Selection Prevention', () => {
53+
test.beforeEach(async ({ page }) => {
54+
await setupMocks(page);
55+
await page.goto('/');
56+
await waitForAlpine(page);
57+
await page.waitForSelector('[data-track-id]', { state: 'visible' });
58+
});
59+
60+
test('sidebar prevents text selection', async ({ page }) => {
61+
await expectNoTextSelection(
62+
page,
63+
'aside[x-data="sidebar"]',
64+
'Sidebar',
65+
);
66+
});
67+
68+
test('footer prevents text selection', async ({ page }) => {
69+
await expectNoTextSelection(page, 'footer', 'Footer');
70+
});
71+
72+
test('library view prevents text selection', async ({ page }) => {
73+
await expectNoTextSelection(
74+
page,
75+
'[x-data="libraryBrowser"]',
76+
'Library view',
77+
);
78+
});
79+
80+
test('albums view prevents text selection', async ({ page }) => {
81+
await page.evaluate(() =>
82+
window.Alpine.store('ui').setView('albums')
83+
);
84+
await page.waitForSelector('[data-testid="albums-view"]', {
85+
state: 'visible',
86+
});
87+
await expectNoTextSelection(
88+
page,
89+
'[data-testid="albums-view"]',
90+
'Albums view',
91+
);
92+
});
93+
94+
test('artists view prevents text selection', async ({ page }) => {
95+
await page.evaluate(() =>
96+
window.Alpine.store('ui').setView('artists')
97+
);
98+
await page.waitForSelector('[data-testid="artists-view"]', {
99+
state: 'visible',
100+
});
101+
await expectNoTextSelection(
102+
page,
103+
'[data-testid="artists-view"]',
104+
'Artists view',
105+
);
106+
});
107+
108+
test('settings view prevents text selection', async ({ page }) => {
109+
await page.click('[data-testid="sidebar-settings"]');
110+
await page.waitForSelector('[data-testid="settings-view"]', {
111+
state: 'visible',
112+
});
113+
await expectNoTextSelection(
114+
page,
115+
'[data-testid="settings-view"]',
116+
'Settings view',
117+
);
118+
});
119+
120+
test('queue view prevents text selection', async ({ page }) => {
121+
await page.evaluate(() =>
122+
window.Alpine.store('ui').setView('queue')
123+
);
124+
await page.waitForTimeout(200);
125+
await expectNoTextSelection(
126+
page,
127+
'[data-testid="queue-view"]',
128+
'Queue view',
129+
);
130+
});
131+
132+
test('search input prevents selection when empty', async ({ page }) => {
133+
const searchInput = page.locator('[data-testid="sidebar-search"]');
134+
if (await searchInput.isVisible()) {
135+
const userSelect = await getUserSelect(searchInput);
136+
expect(
137+
userSelect,
138+
'Empty search input should have user-select: none',
139+
).toBe('none');
140+
}
141+
});
142+
143+
test('search input allows selection when has text', async ({ page }) => {
144+
const searchInput = page.locator('[data-testid="sidebar-search"]');
145+
if (await searchInput.isVisible()) {
146+
await searchInput.fill('test');
147+
await page.waitForTimeout(100);
148+
const userSelect = await getUserSelect(searchInput);
149+
expect(
150+
userSelect,
151+
'Search input with text should have user-select: text',
152+
).toBe('text');
153+
}
154+
});
155+
});

app/frontend/views/albums.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
x-show="$store.ui.view === 'albums'"
44
x-cloak
55
x-data="albumsBrowser"
6-
class="h-full flex flex-col min-h-0"
6+
class="h-full flex flex-col min-h-0 select-none"
77
data-testid="albums-view"
88
>
99
<!-- Album Grid View -->

app/frontend/views/artists.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
x-show="$store.ui.view === 'artists'"
33
x-cloak
44
x-data="artistsBrowser"
5-
class="flex h-full"
5+
class="flex h-full select-none"
66
data-testid="artists-view"
77
>
88
<!-- Left panel: Artist list -->

app/frontend/views/drop-overlay.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
x-transition:leave="transition ease-in duration-150"
88
x-transition:leave-start="opacity-100"
99
x-transition:leave-end="opacity-0"
10-
class="absolute inset-0 z-50 bg-primary/20 border-4 border-dashed border-primary rounded-lg flex items-center justify-center pointer-events-none"
10+
class="absolute inset-0 z-50 bg-primary/20 border-4 border-dashed border-primary rounded-lg flex items-center justify-center pointer-events-none select-none"
1111
>
1212
<div class="text-center">
1313
<svg class="w-16 h-16 mx-auto mb-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">

app/frontend/views/footer.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<!-- Player controls footer -->
22
<footer
33
x-data="playerControls"
4-
class="border-t border-border bg-card shrink-0 h-[65px]"
4+
class="border-t border-border bg-card shrink-0 h-[65px] select-none"
55
>
66
<div class="flex items-center gap-4 px-4 h-full">
77
<!-- Left: Transport controls -->

app/frontend/views/library.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
x-cloak
55
x-data="libraryBrowser"
66
@keydown.window="$store.ui.view === 'library' && handleKeydown($event)"
7-
class="h-full flex flex-col min-h-0"
7+
class="h-full flex flex-col min-h-0 select-none"
88
>
99
<!-- Track table - single scroll container for both vertical and horizontal -->
1010
<div class="flex-1 min-h-0 w-full overflow-auto" x-ref="scrollContainer">

app/frontend/views/modals.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
x-show="isOpen"
55
x-cloak
66
data-testid="metadata-modal"
7-
class="fixed inset-0 z-50 flex items-center justify-center"
7+
class="fixed inset-0 z-50 flex items-center justify-center select-none"
88
@keydown.window="if (isOpen) handleKeydown($event)"
99
>
1010
<div class="absolute inset-0 bg-black/50" @click="close()"></div>

app/frontend/views/queue.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<!-- Queue View -->
2-
<div x-show="$store.ui.view === 'queue'" x-cloak class="h-full flex flex-col p-4" x-data="queueView">
2+
<div x-show="$store.ui.view === 'queue'" x-cloak class="h-full flex flex-col p-4 select-none" x-data="queueView" data-testid="queue-view">
33
<div class="flex items-center justify-between mb-4">
44
<h2 class="text-xl font-semibold">Queue</h2>
55
<div class="flex gap-2">

app/frontend/views/settings.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<div
33
x-show="$store.ui.view === 'settings'"
44
x-cloak
5-
class="h-full flex"
5+
class="h-full flex select-none"
66
x-data="settingsView"
77
data-testid="settings-view"
88
>

app/frontend/views/sidebar.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
x-data="sidebar"
33
@dragover="console.log('[Sidebar/aside] dragover event', $event.target)"
44
@drop="console.log('[Sidebar/aside] drop event', $event.target)"
5-
class="border-r border-border bg-muted/30 flex flex-col shrink-0 overflow-hidden transition-[width] duration-200"
5+
class="border-r border-border bg-muted/30 flex flex-col shrink-0 overflow-hidden transition-[width] duration-200 sidebar-no-select"
66
:class="isCollapsed ? 'w-[70px]' : 'w-52'"
77
>
88
<!-- Search (Apple Music style pill) -->
@@ -14,6 +14,7 @@
1414
<input
1515
type="text"
1616
class="w-full text-xs py-1.5 pl-8 pr-7 rounded-full bg-muted/80 border-0 focus:ring-1 focus:ring-primary/50 focus:outline-none text-foreground placeholder:text-foreground/50"
17+
:class="$store.library.searchQuery ? 'select-text' : 'select-none'"
1718
placeholder="Search"
1819
x-model="$store.library.searchQuery"
1920
@input.debounce.300ms="$store.library.search($store.library.searchQuery)"

0 commit comments

Comments
 (0)