fix(catalog): short sort chip label and radio sort menus (#1881)

* fix(catalog): short sort chip label and radio sort menus

The catalog sort chip now shows one short label per mode at every width
("Newest", "A-Z", "Top rated") and keeps the full "Sort: ..." text in its
aria-label, so it no longer truncates in long translations. The rating
chip follows the same rule and puts its clear icon after the value. The
1120px container query that swapped full and compact labels is gone.

Single-choice mat-menus get a shared appMenuItemRadio /
appMenuItemRadioCheck pair: rows are menuitemradio with aria-checked, and
every row reserves a leading check slot that is visible only when
checked. Material projects every mat-icon ahead of the label, so the old
check rendered only on the chosen row shifted that row's label. Applied
to the catalog refine menu (sort and rating groups), Xtream live
channels, M3U all channels and groups, unified collection favorites,
workspace categories and workspace sources.

Adds WORKSPACE.SORT_CHIP keys in all locales and drops the unused
WORKSPACE.FILTER_RATING key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(catalog): read the full sort to screen readers from hidden text

A plain div cannot carry an accessible name, so screen readers could skip
the chip's aria-label and read only the short "Newest". The full
"Sort: ..." text now sits in a visually hidden span and the short label
is aria-hidden; the polite live region announces the full text on change.
The spec checks the text outside aria-hidden, and the E2E checks the
chip's accessibility tree in en, de, ru and hu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

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-10 23:20:42 +02:00
1 parent 039238b7bc
commit ea515280e3
50 files changed
+1177 -249

No files matched your search

+1
View File
@@ -35,6 +35,7 @@ export * from './lib/window-controls/window-controls.component';
export * from './lib/channel-scroll-focus/channel-scroll-focus.directive';
export * from './lib/category-lock-menu/category-lock-menu.component';
export * from './lib/menu-item-radio/menu-item-radio.directive';
export * from './lib/detail-ui/cast-crew.util';
export * from './lib/detail-ui/cast-crew-row.component';
@@ -28,26 +28,32 @@
</div>
<mat-menu #channelSortMenu="matMenu">
<button mat-menu-item (click)="setAllChannelsSortMode('server')">
<button
mat-menu-item
[appMenuItemRadio]="allChannelsSortMode() === 'server'"
(click)="setAllChannelsSortMode('server')"
>
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<mat-icon>drag_indicator</mat-icon>
<span>{{ 'CHANNELS.SORT_PLAYLIST_ORDER' | translate }}</span>
@if (allChannelsSortMode() === 'server') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setAllChannelsSortMode('name-asc')">
<button
mat-menu-item
[appMenuItemRadio]="allChannelsSortMode() === 'name-asc'"
(click)="setAllChannelsSortMode('name-asc')"
>
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<mat-icon>sort_by_alpha</mat-icon>
<span>{{ 'WORKSPACE.SORT_NAME_ASC' | translate }}</span>
@if (allChannelsSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setAllChannelsSortMode('name-desc')">
<button
mat-menu-item
[appMenuItemRadio]="allChannelsSortMode() === 'name-desc'"
(click)="setAllChannelsSortMode('name-desc')"
>
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<mat-icon>sort_by_alpha</mat-icon>
<span>{{ 'WORKSPACE.SORT_NAME_DESC' | translate }}</span>
@if (allChannelsSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
</header>
@@ -38,6 +38,10 @@ import { buildChannelEpgMetadataMap } from '../epg-enrichment.util';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
import {
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
} from '../../menu-item-radio/menu-item-radio.directive';
const ALL_CHANNELS_SORT_STORAGE_KEY = 'm3u-all-channels-sort-mode';
@@ -55,6 +59,8 @@ export type { ChannelEpgMetadata } from '../epg-enrichment.util';
MatIconModule,
MatMenuModule,
MatTooltipModule,
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
ScrollingModule,
TranslatePipe,
],
@@ -248,39 +248,42 @@
<mat-menu #channelSortMenu="matMenu">
<button
mat-menu-item
[appMenuItemRadio]="
groupChannelSortMode() === 'server'
"
(click)="setGroupChannelSortMode('server')"
>
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<mat-icon>drag_indicator</mat-icon>
<span>{{
'CHANNELS.SORT_PLAYLIST_ORDER' | translate
}}</span>
@if (groupChannelSortMode() === 'server') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
[appMenuItemRadio]="
groupChannelSortMode() === 'name-asc'
"
(click)="setGroupChannelSortMode('name-asc')"
>
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<mat-icon>sort_by_alpha</mat-icon>
<span>{{
'WORKSPACE.SORT_NAME_ASC' | translate
}}</span>
@if (groupChannelSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
[appMenuItemRadio]="
groupChannelSortMode() === 'name-desc'
"
(click)="setGroupChannelSortMode('name-desc')"
>
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<mat-icon>sort_by_alpha</mat-icon>
<span>{{
'WORKSPACE.SORT_NAME_DESC' | translate
}}</span>
@if (groupChannelSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
</header>
@@ -41,6 +41,10 @@ import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
import {
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
} from '../../menu-item-radio/menu-item-radio.directive';
import { CategoryLockMenuComponent } from '../../category-lock-menu/category-lock-menu.component';
import { ResizableDirective } from '../../resizable/resizable.directive';
import {
@@ -78,6 +82,8 @@ interface FilteredGroupView {
MatIconModule,
MatMenuModule,
MatTooltipModule,
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
ResizableDirective,
ScrollingModule,
TitleCasePipe,
@@ -0,0 +1,123 @@
import { Component, signal, viewChild } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import {
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
} from './menu-item-radio.directive';
type Mode = 'server' | 'name-asc' | 'name-desc';
@Component({
imports: [
MatIcon,
MatMenuModule,
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
],
template: `
<button type="button" [matMenuTriggerFor]="menu">Sort</button>
<mat-menu #menu="matMenu">
@for (option of modes; track option) {
<button
mat-menu-item
[appMenuItemRadio]="mode() === option"
(click)="mode.set(option)"
>
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<mat-icon>sort_by_alpha</mat-icon>
<span>{{ option }}</span>
</button>
}
</mat-menu>
`,
})
class HostComponent {
readonly trigger = viewChild.required(MatMenuTrigger);
readonly modes: Mode[] = ['server', 'name-asc', 'name-desc'];
readonly mode = signal<Mode>('name-asc');
}
describe('MenuItemRadioDirective', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent, NoopAnimationsModule],
}).compileComponents();
});
afterEach(() => {
document
.querySelectorAll('.cdk-overlay-container')
.forEach((container) => container.remove());
});
async function openMenu() {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
fixture.componentInstance.trigger().openMenu();
await fixture.whenStable();
fixture.detectChanges();
const rows = () =>
Array.from(
document.querySelectorAll<HTMLButtonElement>(
'.cdk-overlay-container [mat-menu-item]'
)
);
return { fixture, rows };
}
function checkOf(row: HTMLElement): HTMLElement {
return row.querySelector('.app-menu-item-radio-check') as HTMLElement;
}
it('announces every row as a menuitemradio with its checked state', async () => {
const { rows } = await openMenu();
expect(rows().map((row) => row.getAttribute('role'))).toEqual([
'menuitemradio',
'menuitemradio',
'menuitemradio',
]);
expect(rows().map((row) => row.getAttribute('aria-checked'))).toEqual([
'false',
'true',
'false',
]);
});
it('reserves the check slot on every row ahead of the other icons and shows it only on the checked one', async () => {
const { rows } = await openMenu();
for (const row of rows()) {
// Material projects the icons ahead of the label; the check slot
// leads, so every label starts after the same two icons.
expect(row.firstElementChild).toBe(checkOf(row));
expect(checkOf(row).getAttribute('aria-hidden')).toBe('true');
}
expect(rows().map((row) => checkOf(row).style.visibility)).toEqual([
'hidden',
'',
'hidden',
]);
});
it('moves the check and aria-checked to the newly chosen row', async () => {
const { fixture, rows } = await openMenu();
fixture.componentInstance.mode.set('server');
fixture.detectChanges();
expect(rows().map((row) => row.getAttribute('aria-checked'))).toEqual([
'true',
'false',
'false',
]);
expect(rows().map((row) => checkOf(row).style.visibility)).toEqual([
'',
'hidden',
'hidden',
]);
});
});
@@ -0,0 +1,52 @@
import { Directive, inject, input } from '@angular/core';
import { MatMenuItem } from '@angular/material/menu';
/**
* One choice of a single-choice `mat-menu` group, such as a sort order or a
* rating threshold: a `menuitemradio` whose `aria-checked` follows the bound
* state.
*
* Every row also renders `<mat-icon appMenuItemRadioCheck>check</mat-icon>`
* as its first icon. Material projects all of a row's icons ahead of its
* label, so a check rendered only on the chosen row pushed that row's label
* to the right; a check slot on every row, hidden while unchecked, keeps
* all labels on one left edge.
*
* ```html
* <button mat-menu-item [appMenuItemRadio]="mode() === 'name-asc'">
* <mat-icon appMenuItemRadioCheck>check</mat-icon>
* <span>{{ 'WORKSPACE.SORT_NAME_ASC' | translate }}</span>
* </button>
* ```
*/
@Directive({
// Injecting MatMenuItem restricts it to `mat-menu-item` hosts.
selector: '[appMenuItemRadio]',
host: {
'[attr.aria-checked]': 'checked()',
},
})
export class MenuItemRadioDirective {
readonly checked = input.required<boolean>({ alias: 'appMenuItemRadio' });
constructor() {
// The menu item owns the `role` host binding; set its input rather
// than binding the attribute a second time.
inject(MatMenuItem).role = 'menuitemradio';
}
}
/**
* The leading check slot of a {@link MenuItemRadioDirective} row. It keeps
* its width on every row and is visible only on the checked one.
*/
@Directive({
selector: 'mat-icon[appMenuItemRadioCheck]',
host: {
class: 'app-menu-item-radio-check',
'[style.visibility]': 'radio.checked() ? null : "hidden"',
},
})
export class MenuItemRadioCheckDirective {
protected readonly radio = inject(MenuItemRadioDirective);
}