fix(workspace): open a source row from the keyboard (#1885)

A Sources row opened only on a pointer click: the row div was not
focusable and had no key handler, so a keyboard user could Tab to its
actions but never open the playlist.

Following app-content-card, the row's title and meta block becomes the
keyboard activation element: role="button", tabindex="0", named by the
source title, aria-current on the active source, aria-disabled while
busy. Enter and Space open the source (Space prevents the page scroll).
The drag handle, health indicator and actions stay its siblings, so no
control is nested inside a role="button" and their keys never bubble
into it. A click anywhere on the row still opens it via the row.

The row draws the ring with the shared focus-ring mixin, inset, because
the list's scroller would cut a ring drawn outside the full-width row.
playlist-shared-ui now declares its ui-styles stylesheet dependency.

Tests: unit spec for Enter/Space, bubbling from an action button, busy
and selected states; Electron E2E tabs to a source row, checks the ring
and opens it with Enter (fails on the previous component).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-11 08:15:04 +02:00
1 parent 4281194cb4
commit ea7efdbd9b
8 files changed
+349 -1

No files matched your search

+1
View File
@@ -5,6 +5,7 @@
"prefix": "lib",
"projectType": "library",
"tags": ["scope:playlist", "domain:m3u", "type:ui"],
"implicitDependencies": ["ui-styles"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
@@ -82,7 +82,20 @@
}
</div>
</div>
<div class="playlist-content">
<!-- The keyboard activation element is its own element so the drag
handle and the actions stay siblings: an interactive control nested
inside a role="button" is an invalid accessibility structure. A
pointer click anywhere on the row still opens it via the row. -->
<div
class="playlist-content"
role="button"
tabindex="0"
[attr.aria-label]="item.title || item.filename"
[attr.aria-current]="isSelected() || null"
[attr.aria-disabled]="isBusy() || null"
(keydown.enter)="onActivationKey($event)"
(keydown.space)="onActivationKey($event)"
>
<div class="playlist-title">
{{ item.title || item.filename }}
</div>
@@ -1,3 +1,5 @@
@use '../../../../../../../ui/styles/focus-ring';
:host {
display: block;
width: 100%;
@@ -75,6 +77,14 @@
flex-direction: column;
justify-content: center;
gap: 2px;
// The row draws this element's focus ring.
outline: none;
}
// The keyboard ring belongs to the whole row, the unit that opens. It sits
// inside the row: the list's scroller would cut one drawn outside it.
.playlist-item:has(> .playlist-content:focus-visible) {
@include focus-ring.focus-ring-declarations($offset: -2px);
}
.playlist-title {
@@ -91,6 +91,120 @@ describe('PlaylistItemComponent', () => {
expect(emitSpy).not.toHaveBeenCalled();
});
describe('keyboard activation', () => {
const row = () => fixture.nativeElement as HTMLElement;
const activation = () =>
row().querySelector('[role="button"]') as HTMLElement;
const keydown = (target: HTMLElement, key: string) => {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
});
target.dispatchEvent(event);
return event;
};
it('is a Tab stop named after the source that Enter and Space open', () => {
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
const surface = activation();
expect(surface).not.toBeNull();
expect(surface.getAttribute('tabindex')).toBe('0');
expect(surface.getAttribute('aria-label')).toBe('Playlist');
const enter = keydown(surface, 'Enter');
const space = keydown(surface, ' ');
expect(emitSpy).toHaveBeenNthCalledWith(1, '1');
expect(emitSpy).toHaveBeenNthCalledWith(2, '1');
expect(enter.defaultPrevented).toBe(true);
// Space must not scroll the page.
expect(space.defaultPrevented).toBe(true);
});
it('keeps the drag handle and actions outside the activation element so their keys never open the source', () => {
fixture.componentRef.setInput('isDraggable', true);
fixture.detectChanges();
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
const editSpy = jest.spyOn(component.editPlaylistClicked, 'emit');
const controls = Array.from(
row().querySelectorAll<HTMLElement>(
'.action-buttons button, .drag-icon'
)
);
expect(activation()).not.toBeNull();
expect(controls.length).toBeGreaterThan(1);
// No interactive control nested inside a role="button".
for (const control of controls) {
expect(control.closest('[role="button"]')).toBeNull();
}
const editButton = row().querySelector(
'.edit-btn'
) as HTMLButtonElement;
keydown(editButton, 'Enter');
const space = keydown(editButton, ' ');
editButton.click();
// The button's own activation edits; the row must neither open
// the source nor swallow the Space the button relies on.
expect(emitSpy).not.toHaveBeenCalled();
expect(space.defaultPrevented).toBe(false);
expect(editSpy).toHaveBeenCalledTimes(1);
});
it('opens once for a pointer click anywhere on the row', () => {
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
activation().click();
(row().querySelector('.playlist-item') as HTMLElement).click();
(row().querySelector('.playlist-icon') as HTMLElement).click();
expect(emitSpy).toHaveBeenCalledTimes(3);
});
it('announces the selected source and ignores keys while busy', () => {
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
expect(activation().hasAttribute('aria-current')).toBe(false);
expect(activation().hasAttribute('aria-disabled')).toBe(false);
fixture.componentRef.setInput('isSelected', true);
fixture.componentRef.setInput('isRefreshing', true);
fixture.detectChanges();
expect(activation().getAttribute('aria-current')).toBe('true');
expect(activation().getAttribute('aria-disabled')).toBe('true');
// Still a Tab stop, so focus is not lost when a refresh starts.
expect(activation().getAttribute('tabindex')).toBe('0');
const space = keydown(activation(), ' ');
keydown(activation(), 'Enter');
expect(emitSpy).not.toHaveBeenCalled();
expect(space.defaultPrevented).toBe(true);
});
it('falls back to the file name for the accessible name', () => {
fixture.destroy();
fixture = TestBed.createComponent(PlaylistItemComponent);
fixture.componentInstance.item = {
title: '',
filename: 'channels.m3u',
_id: 'file-source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
};
fixture.detectChanges();
expect(activation().getAttribute('aria-label')).toBe(
'channels.m3u'
);
});
});
it('renders a refresh action for file-backed M3U playlists', () => {
fixture.destroy();
runtime.supportsPlaylistRefresh = true;
@@ -159,4 +159,15 @@ export class PlaylistItemComponent implements OnInit {
this.playlistClicked.emit(this.item._id);
}
/**
* Enter/Space open the source like a click on the row; Space also
* prevents the page scroll. The drag handle and the actions are siblings
* of the activation element, never descendants, so their keys cannot
* reach this handler.
*/
onActivationKey(event: Event): void {
event.preventDefault();
this.onPlaylistClick();
}
}