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

@@ -0,0 +1,69 @@
import { PortalCatalogSortMode } from '@iptvnator/portal/shared/util';
/** A content sort choice of the refine menu and its active-sort chip. */
export interface CatalogSortOption {
readonly mode: PortalCatalogSortMode;
/** The menu row, and after "Sort: " the chip's screen-reader text. */
readonly labelKey: string;
/**
* The chip's visible text: short in every locale, so the chip shows it
* whole at any pane width.
*/
readonly chipLabelKey: string;
/** Offered only by providers that sort by rating. */
readonly byRating: boolean;
}
export const CATALOG_SORT_OPTIONS: readonly CatalogSortOption[] = [
{
mode: 'date-desc',
labelKey: 'WORKSPACE.SORT_DATE_DESC',
chipLabelKey: 'WORKSPACE.SORT_CHIP.NEWEST',
byRating: false,
},
{
mode: 'date-asc',
labelKey: 'WORKSPACE.SORT_DATE_ASC',
chipLabelKey: 'WORKSPACE.SORT_CHIP.OLDEST',
byRating: false,
},
{
mode: 'name-asc',
labelKey: 'WORKSPACE.SORT_NAME_ASC',
chipLabelKey: 'WORKSPACE.SORT_NAME_ASC',
byRating: false,
},
{
mode: 'name-desc',
labelKey: 'WORKSPACE.SORT_NAME_DESC',
chipLabelKey: 'WORKSPACE.SORT_NAME_DESC',
byRating: false,
},
{
mode: 'rating-desc',
labelKey: 'WORKSPACE.SORT_TOP_RATED',
chipLabelKey: 'WORKSPACE.SORT_CHIP.TOP_RATED',
byRating: true,
},
{
mode: 'rating-asc',
labelKey: 'WORKSPACE.SORT_LOWEST_RATED',
chipLabelKey: 'WORKSPACE.SORT_CHIP.LOWEST_RATED',
byRating: true,
},
];
const UNKNOWN_SORT_LABEL_KEY = 'WORKSPACE.SORT_CUSTOM';
/** Label keys of the active sort; a mode outside the menu reads as custom. */
export function catalogSortLabels(mode: PortalCatalogSortMode | null): {
labelKey: string;
chipLabelKey: string;
} {
return (
CATALOG_SORT_OPTIONS.find((option) => option.mode === mode) ?? {
labelKey: UNKNOWN_SORT_LABEL_KEY,
chipLabelKey: UNKNOWN_SORT_LABEL_KEY,
}
);
}
@@ -24,25 +24,24 @@
@if (hasRefineControls()) {
<div class="active-refinements" aria-live="polite">
@if (canSortContent()) {
<div
class="refinement-chip sort-refinement-chip"
[attr.aria-label]="
('WORKSPACE.SORT_LABEL' | translate) +
(activeSortLabelKey() | translate)
"
>
@let sortLabels = activeSortLabels();
<!-- A plain div cannot carry an accessible name:
screen readers get the full text from the
hidden span instead of the short label. -->
<div class="refinement-chip sort-refinement-chip">
<mat-icon>sort</mat-icon>
<span
class="refinement-chip-label refinement-chip-label-full"
class="refinement-chip-label"
aria-hidden="true"
>
{{ 'WORKSPACE.SORT_LABEL' | translate
}}{{ activeSortLabelKey() | translate }}
{{ sortLabels.chipLabelKey | translate }}
</span>
<span
class="refinement-chip-label refinement-chip-label-compact"
<span class="visually-hidden"
>{{ 'WORKSPACE.SORT_LABEL' | translate
}}{{
sortLabels.labelKey | translate
}}</span
>
{{ activeSortLabelKey() | translate }}
</span>
</div>
}
@if (minRating(); as rating) {
@@ -57,18 +56,10 @@
"
>
<mat-icon>star</mat-icon>
<span
class="refinement-chip-label refinement-chip-label-full"
>
{{ 'WORKSPACE.FILTER_RATING' | translate }}:
<span class="refinement-chip-label">
{{ rating }}.0+
</span>
<span
class="refinement-chip-label refinement-chip-label-compact"
>
{{ rating }}.0+
</span>
<mat-icon>close</mat-icon>
<mat-icon iconPositionEnd>close</mat-icon>
</button>
}
</div>
@@ -95,116 +86,102 @@
</button>
<mat-menu #refineMenu="matMenu" class="refine-menu">
@if (canSortContent()) {
<div class="refine-menu-section-title">
{{
<div
role="group"
[attr.aria-label]="
'WORKSPACE.REFINE_SORT_SECTION' | translate
}}
</div>
<button
mat-menu-item
(click)="setContentSortMode('date-desc')"
"
>
<span>{{
'WORKSPACE.SORT_DATE_DESC' | translate
}}</span>
@if (contentSortMode() === 'date-desc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('date-asc')"
>
<span>{{
'WORKSPACE.SORT_DATE_ASC' | translate
}}</span>
@if (contentSortMode() === 'date-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('name-asc')"
>
<span>{{
'WORKSPACE.SORT_NAME_ASC' | translate
}}</span>
@if (contentSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('name-desc')"
>
<span>{{
'WORKSPACE.SORT_NAME_DESC' | translate
}}</span>
@if (contentSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
@if (supportsRatingSort()) {
<button
mat-menu-item
(click)="setContentSortMode('rating-desc')"
<div
class="refine-menu-section-title"
aria-hidden="true"
>
<span>{{
'WORKSPACE.SORT_TOP_RATED' | translate
}}</span>
@if (contentSortMode() === 'rating-desc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('rating-asc')"
>
<span>{{
'WORKSPACE.SORT_LOWEST_RATED'
{{
'WORKSPACE.REFINE_SORT_SECTION'
| translate
}}</span>
@if (contentSortMode() === 'rating-asc') {
<mat-icon>check</mat-icon>
}
</button>
}
}}
</div>
@for (
option of sortOptions();
track option.mode
) {
<button
mat-menu-item
[appMenuItemRadio]="
contentSortMode() === option.mode
"
(click)="
setContentSortMode(option.mode)
"
>
<mat-icon appMenuItemRadioCheck
>check</mat-icon
>
<span>{{
option.labelKey | translate
}}</span>
</button>
}
</div>
}
@if (canFilterByRating()) {
@if (canSortContent()) {
<div class="refine-menu-divider"></div>
<div
class="refine-menu-divider"
role="separator"
></div>
}
<div class="refine-menu-section-title">
{{
<div
role="group"
[attr.aria-label]="
'WORKSPACE.REFINE_RATING_SECTION'
| translate
}}
</div>
<button mat-menu-item (click)="setMinRating(null)">
<span>{{
'WORKSPACE.FILTER_RATING_ANY' | translate
}}</span>
@if (minRating() === null) {
<mat-icon>check</mat-icon>
}
</button>
@for (
threshold of ratingThresholds;
track threshold
) {
"
>
<div
class="refine-menu-section-title"
aria-hidden="true"
>
{{
'WORKSPACE.REFINE_RATING_SECTION'
| translate
}}
</div>
<button
mat-menu-item
(click)="setMinRating(threshold)"
[appMenuItemRadio]="minRating() === null"
(click)="setMinRating(null)"
>
<mat-icon appMenuItemRadioCheck
>check</mat-icon
>
<span>{{
'WORKSPACE.FILTER_RATING_MIN'
| translate: { value: threshold }
'WORKSPACE.FILTER_RATING_ANY'
| translate
}}</span>
@if (minRating() === threshold) {
<mat-icon>check</mat-icon>
}
</button>
}
@for (
threshold of ratingThresholds;
track threshold
) {
<button
mat-menu-item
[appMenuItemRadio]="
minRating() === threshold
"
(click)="setMinRating(threshold)"
>
<mat-icon appMenuItemRadioCheck
>check</mat-icon
>
<span>{{
'WORKSPACE.FILTER_RATING_MIN'
| translate
: { value: threshold }
}}</span>
</button>
}
</div>
}
</mat-menu>
}
@@ -51,6 +51,8 @@
}
// ─── Refine controls — show current sort/filter state compactly ──────────────
// A chip shows its icon and a short value at every width; screen readers
// get the full "Sort: …" text from a visually hidden span.
.active-refinements {
grid-area: refinements;
justify-self: end;
@@ -105,21 +107,9 @@
white-space: nowrap;
}
.refinement-chip-label-compact {
display: none;
}
@container category-content (max-width: 1120px) {
.refinement-chip-label-full {
display: none;
}
.refinement-chip-label-compact {
display: inline;
}
}
.sort-refinement-chip {
// Contains the absolutely positioned visually hidden text.
position: relative;
color: var(--mat-sys-on-surface-variant);
cursor: default;
user-select: none;
@@ -219,7 +209,6 @@
.refine-action {
justify-self: end;
}
}
// ─── Grid scroll area ─────────────────────────────────────────────────────────
@@ -17,6 +17,10 @@ import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { EMPTY, ReplaySubject, of } from 'rxjs';
import { InfiniteScrollDirective } from '@iptvnator/portal/shared/ui';
import {
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
} from '@iptvnator/ui/components';
import {
PORTAL_CATALOG_DETAIL_COMPONENT,
PORTAL_CATALOG_FACADE,
@@ -206,6 +210,8 @@ describe('CategoryContentViewComponent', () => {
MatButtonModule,
MatMenuModule,
MatTooltip,
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
TranslatePipe,
],
},
@@ -335,7 +341,11 @@ describe('CategoryContentViewComponent', () => {
'.rating-refinement-chip'
) as HTMLButtonElement | null;
expect(sortChip?.textContent).toContain('WORKSPACE.SORT_TOP_RATED');
expect(
sortChip
?.querySelector('.refinement-chip-label')
?.textContent?.trim()
).toBe('WORKSPACE.SORT_CHIP.TOP_RATED');
expect(ratingChip?.textContent).toContain('8');
ratingChip?.click();
@@ -359,35 +369,147 @@ describe('CategoryContentViewComponent', () => {
).toBeNull();
});
it('renders full and compact refinement chip labels for responsive layouts', () => {
it.each([
['date-desc', 'WORKSPACE.SORT_CHIP.NEWEST', 'WORKSPACE.SORT_DATE_DESC'],
['date-asc', 'WORKSPACE.SORT_CHIP.OLDEST', 'WORKSPACE.SORT_DATE_ASC'],
['name-asc', 'WORKSPACE.SORT_NAME_ASC', 'WORKSPACE.SORT_NAME_ASC'],
['name-desc', 'WORKSPACE.SORT_NAME_DESC', 'WORKSPACE.SORT_NAME_DESC'],
[
'rating-desc',
'WORKSPACE.SORT_CHIP.TOP_RATED',
'WORKSPACE.SORT_TOP_RATED',
],
[
'rating-asc',
'WORKSPACE.SORT_CHIP.LOWEST_RATED',
'WORKSPACE.SORT_LOWEST_RATED',
],
] as const)(
'shows only the short %s label in the sort chip and reads the full label to screen readers',
(mode, chipKey, menuKey) => {
contentSortMode.set(mode);
categoryItemCount.set(12);
fixture.detectChanges();
const sortChip = fixture.nativeElement.querySelector(
'.sort-refinement-chip'
) as HTMLElement;
const labels = sortChip.querySelectorAll('.refinement-chip-label');
expect(labels).toHaveLength(1);
expect(labels[0].textContent?.trim()).toBe(chipKey);
// A generic div cannot be named, so the full text must be content
// that screen readers read: everything outside aria-hidden.
expect(sortChip.hasAttribute('aria-label')).toBe(false);
expect(screenReaderText(sortChip)).toBe(
`WORKSPACE.SORT_LABEL${menuKey}`
);
}
);
it('shows the rating threshold alone in the rating chip and names the clear action', () => {
contentSortMode.set('name-asc');
minRating.set(9);
categoryItemCount.set(12);
fixture.detectChanges();
const sortChip = fixture.nativeElement.querySelector(
'.sort-refinement-chip'
) as HTMLElement | null;
const ratingChip = fixture.nativeElement.querySelector(
'.rating-refinement-chip'
) as HTMLElement | null;
) as HTMLElement;
const labels = ratingChip.querySelectorAll('.refinement-chip-label');
expect(labels).toHaveLength(1);
expect(labels[0].textContent?.trim()).toBe('9.0+');
expect(ratingChip.getAttribute('aria-label')).toBe(
'WORKSPACE.REFINE_CLEAR_RATING'
);
// The button projects its icons around the label: star first, the
// clear cross after the value rather than between star and value.
const order = Array.from(
ratingChip.querySelectorAll('mat-icon, .refinement-chip-label'),
(element) => element.textContent?.trim()
);
expect(order).toEqual(['star', '9.0+', 'close']);
});
it('offers sort and rating choices as radio groups whose aria-checked follows the active refinement', () => {
contentSortMode.set('name-desc');
minRating.set(8);
categoryItemCount.set(12);
fixture.detectChanges();
(
fixture.nativeElement.querySelector(
'.refine-action'
) as HTMLButtonElement
).click();
fixture.detectChanges();
const groups = Array.from(
document.querySelectorAll<HTMLElement>(
'.refine-menu [role="group"]'
)
);
const checkedLabels = (group: HTMLElement) =>
Array.from(
group.querySelectorAll('[role="menuitemradio"]'),
(row) =>
`${row.getAttribute('aria-checked')} ${row
.querySelector('.mat-mdc-menu-item-text')
?.textContent?.trim()}`
);
expect(groups.map((group) => group.getAttribute('aria-label'))).toEqual(
['WORKSPACE.REFINE_SORT_SECTION', 'WORKSPACE.REFINE_RATING_SECTION']
);
expect(checkedLabels(groups[0])).toEqual([
'false WORKSPACE.SORT_DATE_DESC',
'false WORKSPACE.SORT_DATE_ASC',
'false WORKSPACE.SORT_NAME_ASC',
'true WORKSPACE.SORT_NAME_DESC',
'false WORKSPACE.SORT_TOP_RATED',
'false WORKSPACE.SORT_LOWEST_RATED',
]);
expect(checkedLabels(groups[1])).toEqual([
'false WORKSPACE.FILTER_RATING_ANY',
'false WORKSPACE.FILTER_RATING_MIN',
'true WORKSPACE.FILTER_RATING_MIN',
'false WORKSPACE.FILTER_RATING_MIN',
'false WORKSPACE.FILTER_RATING_MIN',
'false WORKSPACE.FILTER_RATING_MIN',
]);
expect(
document.querySelectorAll('.refine-menu [mat-menu-item]')
).toHaveLength(
document.querySelectorAll('.refine-menu [role="menuitemradio"]')
.length
);
});
it('drops the rating sort rows when the provider cannot sort by rating', () => {
catalog.supportsRatingSort = false;
contentSortMode.set('date-desc');
categoryItemCount.set(12);
fixture.detectChanges();
(
fixture.nativeElement.querySelector(
'.refine-action'
) as HTMLButtonElement
).click();
fixture.detectChanges();
expect(
sortChip?.querySelector('.refinement-chip-label-full')?.textContent
).toContain('WORKSPACE.SORT_LABEL');
expect(
sortChip?.querySelector('.refinement-chip-label-compact')
?.textContent
).toContain('WORKSPACE.SORT_NAME_ASC');
expect(
ratingChip?.querySelector('.refinement-chip-label-full')
?.textContent
).toContain('WORKSPACE.FILTER_RATING');
expect(
ratingChip?.querySelector('.refinement-chip-label-compact')
?.textContent
).toContain('9.0+');
Array.from(
document.querySelectorAll('.refine-menu [mat-menu-item]'),
(row) => row.getAttribute('aria-checked')
)
).toEqual(['true', 'false', 'false', 'false']);
expect(document.body.textContent).not.toContain(
'WORKSPACE.SORT_TOP_RATED'
);
});
it('preserves query params when navigating from an item to Xtream details', () => {
@@ -713,3 +835,18 @@ describe('CategoryContentViewComponent', () => {
});
});
});
/** Text a screen reader reads from `element`: content outside aria-hidden. */
function screenReaderText(element: Element): string {
return Array.from(element.childNodes, (node): string => {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent ?? '';
}
return node instanceof Element &&
node.getAttribute('aria-hidden') !== 'true'
? screenReaderText(node)
: '';
})
.join('')
.trim();
}
@@ -25,6 +25,10 @@ import {
InfiniteScrollDirective,
PlaylistErrorViewComponent,
} from '@iptvnator/portal/shared/ui';
import {
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
} from '@iptvnator/ui/components';
import {
clearNavigationStateKeys,
consumeStalkerReturnMarker,
@@ -39,6 +43,10 @@ import {
PROVIDER_ONLY_DETAIL_PRESENTATION_STATE_KEY,
PortalCatalogSortMode,
} from '@iptvnator/portal/shared/util';
import {
CATALOG_SORT_OPTIONS,
catalogSortLabels,
} from './catalog-sort-options';
interface CategoryContentItem {
id?: number | string;
@@ -63,6 +71,8 @@ interface CategoryContentItem {
MatIcon,
MatMenuModule,
MatTooltip,
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
NgComponentOutlet,
PlaylistErrorViewComponent,
TranslatePipe,
@@ -133,24 +143,14 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
readonly activeRefinementCount = computed(() =>
this.minRating() !== null ? 1 : 0
);
readonly activeSortLabelKey = computed(() => {
switch (this.contentSortMode()) {
case 'date-desc':
return 'WORKSPACE.SORT_DATE_DESC';
case 'date-asc':
return 'WORKSPACE.SORT_DATE_ASC';
case 'name-asc':
return 'WORKSPACE.SORT_NAME_ASC';
case 'name-desc':
return 'WORKSPACE.SORT_NAME_DESC';
case 'rating-desc':
return 'WORKSPACE.SORT_TOP_RATED';
case 'rating-asc':
return 'WORKSPACE.SORT_LOWEST_RATED';
default:
return 'WORKSPACE.SORT_CUSTOM';
}
});
readonly sortOptions = computed(() =>
CATALOG_SORT_OPTIONS.filter(
(option) => !option.byRating || this.supportsRatingSort()
)
);
readonly activeSortLabels = computed(() =>
catalogSortLabels(this.contentSortMode())
);
readonly searchTerm = toSignal(
this.activatedRoute.queryParamMap.pipe(map((p) => p.get('q') ?? '')),
{ initialValue: '' }
@@ -64,12 +64,14 @@
</button>
<mat-menu #favoritesSortMenu="matMenu">
@for (option of favSortOptions; track option.mode) {
<button mat-menu-item (click)="setFavSortMode(option.mode)">
<button
mat-menu-item
[appMenuItemRadio]="favSortMode() === option.mode"
(click)="setFavSortMode(option.mode)"
>
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<mat-icon>{{ option.icon }}</mat-icon>
<span>{{ option.translationKey | translate }}</span>
@if (favSortMode() === option.mode) {
<mat-icon class="sort-check">check</mat-icon>
}
</button>
}
</mat-menu>
@@ -20,7 +20,11 @@ import { MatProgressBar } from '@angular/material/progress-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { ChannelListSkeletonComponent } from '@iptvnator/ui/components';
import {
ChannelListSkeletonComponent,
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
} from '@iptvnator/ui/components';
import {
clearNavigationStateKeys,
CollectionContentType,
@@ -81,6 +85,8 @@ import { UnifiedCollectionDetailDirective } from './unified-collection-detail.di
MatMenuModule,
MatProgressBar,
MatTooltip,
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
TranslatePipe,
UnifiedGridTabComponent,
UnifiedLiveTabComponent,
@@ -111,30 +111,27 @@
<mat-menu #channelSortMenu="matMenu">
<button
mat-menu-item
[appMenuItemRadio]="liveChannelSortMode() === 'server'"
(click)="setLiveChannelSortMode('server')"
>
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<span>{{ 'CHANNELS.SORT_SERVER_ORDER' | translate }}</span>
@if (liveChannelSortMode() === 'server') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
[appMenuItemRadio]="liveChannelSortMode() === 'name-asc'"
(click)="setLiveChannelSortMode('name-asc')"
>
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<span>{{ 'WORKSPACE.SORT_NAME_ASC' | translate }}</span>
@if (liveChannelSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
[appMenuItemRadio]="liveChannelSortMode() === 'name-desc'"
(click)="setLiveChannelSortMode('name-desc')"
>
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<span>{{ 'WORKSPACE.SORT_NAME_DESC' | translate }}</span>
@if (liveChannelSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
</div>
@@ -28,7 +28,11 @@ import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { ResizableDirective } from '@iptvnator/ui/components';
import {
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
ResizableDirective,
} from '@iptvnator/ui/components';
import {
GridListComponent,
InfiniteScrollDirective,
@@ -135,6 +139,8 @@ const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
MatMenuModule,
MatProgressSpinnerModule,
MatTooltipModule,
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
NgTemplateOutlet,
GridListComponent,
InfiniteScrollDirective,
+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);
}
@@ -45,17 +45,16 @@
) {
<button
mat-menu-item
[appMenuItemRadio]="
categorySortMode() === option.mode
"
(click)="setCategorySortMode(option.mode)"
>
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<mat-icon>{{ option.icon }}</mat-icon>
<span>{{
option.translationKey | translate
}}</span>
@if (categorySortMode() === option.mode) {
<mat-icon class="context-sort-check"
>check</mat-icon
>
}
</button>
}
</mat-menu>
@@ -82,10 +82,6 @@
text-transform: uppercase;
}
.context-sort-check {
margin-left: auto;
}
.context-header p {
margin: 4px 0 0;
font-size: 0.78rem;
@@ -39,7 +39,11 @@ import {
toParentalLockStalkerCategoryType,
toParentalLockXtreamCategoryType,
} from '@iptvnator/shared/interfaces';
import { CategoryLockMenuComponent } from '@iptvnator/ui/components';
import {
CategoryLockMenuComponent,
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
} from '@iptvnator/ui/components';
import {
WorkspaceCategoryViewItem,
WorkspaceContextCategoryViewComponent,
@@ -81,6 +85,8 @@ interface WorkspaceCategoryLike {
MatIcon,
MatMenuModule,
MatTooltip,
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
TranslatePipe,
WorkspaceContextCategoryViewComponent,
WorkspaceContextErrorViewComponent,
@@ -35,14 +35,12 @@
<button
mat-menu-item
type="button"
[class.active-option]="isSortActive(option)"
[appMenuItemRadio]="isSortActive(option)"
(click)="setSortOption(option)"
>
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<mat-icon>{{ option.icon }}</mat-icon>
<span>{{ option.translationKey | translate }}</span>
@if (isSortActive(option)) {
<mat-icon class="sort-menu__check">check</mat-icon>
}
</button>
}
</mat-menu>
@@ -23,6 +23,10 @@ import {
selectAllPlaylistsMeta,
} from '@iptvnator/m3u-state';
import { SortBy, SortOrder, SortService } from '@iptvnator/services';
import {
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
} from '@iptvnator/ui/components';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
import { WorkspaceSourcesComponent } from './workspace-sources.component';
@@ -138,6 +142,8 @@ describe('WorkspaceSourcesComponent', () => {
MatButtonModule,
MatIconModule,
MatMenuModule,
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
MockRecentPlaylistsComponent,
TranslatePipe,
],
@@ -223,4 +229,37 @@ describe('WorkspaceSourcesComponent', () => {
expect(content.classList.contains('app-scrollbar')).toBe(true);
});
it('offers the source sorts as radio rows with the active one checked', async () => {
fixture.detectChanges();
await fixture.whenStable();
(
fixture.nativeElement.querySelector(
'.sort-trigger'
) as HTMLButtonElement
).click();
fixture.detectChanges();
const rows = Array.from(
document.querySelectorAll<HTMLElement>(
'.cdk-overlay-container [mat-menu-item]'
)
);
expect(rows.map((row) => row.getAttribute('role'))).toEqual(
Array(5).fill('menuitemradio')
);
expect(rows.map((row) => row.getAttribute('aria-checked'))).toEqual([
'true',
'false',
'false',
'false',
'false',
]);
for (const row of rows) {
expect(row.firstElementChild?.classList).toContain(
'app-menu-item-radio-check'
);
}
});
});
@@ -15,6 +15,10 @@ import {
isLiveExternalPlayerSession,
} from '@iptvnator/portal/shared/util';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
} from '@iptvnator/ui/components';
import {
foldSearchText,
sourceHealthType,
@@ -58,6 +62,8 @@ interface SortOption {
MatButtonModule,
MatIconModule,
MatMenuModule,
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
RecentPlaylistsComponent,
TranslatePipe,
],