style(ui): toolbar pickers as kit secondary buttons with a chevron (#1949)

The catalog Refine button, the season dropdown and the programme guide's
scope trigger become the kit's toolbar picker, as the Sources sort already
is: a small secondary flat button with a trailing expand_more chevron that
opens a mat-menu. An active Refine takes the selected-row tint. The
category panel header's icon actions use the ghost icon button.

The guide scope's accessible name now includes the scope it shows, and the
favorites sort button names the current order.

The catalog rating chip drew in primary with 10px padding or in the body
colour with Material's 16px, depending on which stylesheet the page had
added last: Angular removes a component's sheet with its last instance, and
Material's colour and icon-padding rules tie with the component's in
specificity. The chip now sets button tokens as well, and an Electron E2E
moves each sheet last and checks both controls keep their look.

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-15 22:31:02 +02:00
1 parent aecb9acb8e
commit aabca1fae1
14 files changed
+257 -77

No files matched your search

@@ -65,7 +65,7 @@
</div>
<button
type="button"
mat-button
mat-flat-button
class="refine-action"
[class.refine-action-active]="
activeRefinementCount() > 0
@@ -83,6 +83,12 @@
activeRefinementCount()
}}</span>
}
<mat-icon
class="refine-action__chevron"
iconPositionEnd
>
expand_more
</mat-icon>
</button>
<mat-menu #refineMenu="matMenu" class="refine-menu">
@if (canSortContent()) {
@@ -1,4 +1,5 @@
@use '@angular/material' as mat;
@use '../../../../../../ui/styles/app-controls' as controls;
@use '../../../../../../ui/styles/panel-header' as panel;
:host {
@@ -67,8 +68,7 @@
app-region: no-drag;
}
.refinement-chip,
.refine-action {
.refinement-chip {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -84,20 +84,18 @@
)
);
mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
}
}
.refinement-chip {
max-width: 220px;
padding: 0 10px;
background: var(--mat-sys-surface-container);
border: 1px solid color-mix(in srgb, var(--app-on-surface) 6%, transparent);
font-size: 0.78rem;
font-weight: 600;
mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
}
}
.refinement-chip-label {
@@ -115,22 +113,49 @@
user-select: none;
}
.rating-refinement-chip,
.refine-action.refine-action-active {
// A Material button host: Material's own colour and icon-padding rules tie
// with the plain properties above, and Angular removes and re-adds a
// component's stylesheet with its last instance, so the later sheet would
// decide. The colour and the tokens agree whichever sheet comes last.
.rating-refinement-chip {
color: var(--mat-sys-primary);
@include mat.button-overrides(
(
text-label-text-color: var(--mat-sys-primary),
text-state-layer-color: var(--mat-sys-primary),
text-horizontal-padding: 10px,
text-with-icon-horizontal-padding: 10px,
)
);
}
// The kit's toolbar picker: a secondary button whose chevron says it opens
// a menu. With refinements applied it takes the selected-row tint; the
// count badge says how many.
.refine-action {
grid-area: refine;
justify-self: end;
padding: 0 12px;
background: transparent;
border: 1px solid color-mix(in srgb, var(--app-on-surface) 8%, transparent);
flex-shrink: 0;
app-region: no-drag;
@include controls.secondary-button(sm);
&.refine-action-active {
@include mat.button-overrides(
(
filled-container-color: var(--app-list-selected-bg),
filled-label-text-color: var(--app-list-selected-fg),
)
);
}
}
.refine-action__chevron {
@include controls.trigger-chevron;
}
.refine-count {
display: inline-flex;
margin-inline-start: 6px;
align-items: center;
justify-content: center;
min-width: 18px;
@@ -58,7 +58,10 @@
('WORKSPACE.SORT_LABEL' | translate) +
(favSortLabelKey() | translate)
"
[attr.aria-label]="'WORKSPACE.SORT_LABEL' | translate"
[attr.aria-label]="
('WORKSPACE.SORT_LABEL' | translate) +
(favSortLabelKey() | translate)
"
>
<mat-icon>sort_by_alpha</mat-icon>
</button>
@@ -361,6 +361,35 @@ describe('UnifiedCollectionPageComponent', () => {
}
);
it('names the favorites sort button with the current order', async () => {
const favorite = {
uid: 'm3u::playlist-1::https://example.com/one.m3u8',
name: 'Favorite One',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'playlist-1',
playlistName: 'Playlist One',
streamUrl: 'https://example.com/one.m3u8',
} satisfies UnifiedCollectionItem;
favoritesData.getFavorites.mockResolvedValueOnce([favorite]);
fixture.componentRef.setInput('mode', 'favorites');
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.isLoading.set(false);
fixture.componentInstance.selectedContentType.set('live');
fixture.componentInstance.allItems.set([favorite]);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector(
'.favorites-sort-button'
) as HTMLButtonElement;
// No translations are loaded, so the keys are the rendered text.
expect(button.getAttribute('aria-label')).toBe(
`WORKSPACE.SORT_LABEL${fixture.componentInstance.favSortLabelKey()}`
);
});
it('uses compact loading rows when the runtime has no EPG support', () => {
fixture.detectChanges();
@@ -1,18 +1,19 @@
<div class="season-tabs" [attr.role]="useDropdown() ? null : 'tablist'">
@if (useDropdown()) {
<button
mat-flat-button
class="season-tabs__dropdown"
data-testid="season-dropdown"
[matMenuTriggerFor]="seasonMenu"
type="button"
>
<span>
{{
'PORTALS.SEASON_TAB'
| translate: { number: selectedSeason() ?? '—' }
}}
</span>
<mat-icon>expand_more</mat-icon>
{{
'PORTALS.SEASON_TAB'
| translate: { number: selectedSeason() ?? '—' }
}}
<mat-icon class="season-tabs__chevron" iconPositionEnd>
expand_more
</mat-icon>
</button>
<mat-menu #seasonMenu="matMenu" class="season-tabs-menu">
@for (seasonKey of seasonKeys(); track seasonKey) {
@@ -1,3 +1,5 @@
@use '../../../../styles/app-controls' as controls;
:host {
display: block;
min-width: 0;
@@ -9,7 +11,9 @@
flex-wrap: wrap;
gap: 4px;
// Season pills and the dropdown share the section header's 30px pill.
// Season pills share the section header's 30px row; past four seasons
// the kit's toolbar picker (a small secondary button with a chevron)
// opens the season menu instead.
&__pill {
display: inline-flex;
align-items: center;
@@ -45,29 +49,11 @@
}
&__dropdown {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 10px 0 12px;
border: 1px solid
color-mix(in srgb, var(--app-on-surface) 14%, transparent);
border-radius: 999px;
box-sizing: border-box;
background: var(--app-widget-bg);
color: var(--text-primary, #fff);
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
@include controls.secondary-button(sm);
}
> mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
line-height: 16px;
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
}
&__chevron {
@include controls.trigger-chevron;
}
&__done {
@@ -5,6 +5,7 @@ import {
input,
output,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { TranslateModule } from '@ngx-translate/core';
@@ -36,6 +37,7 @@ export type SeasonCountLoadState = 'loading' | 'unloaded';
styleUrls: ['./season-tabs.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatButtonModule,
MatIcon,
MatMenuModule,
MenuItemRadioCheckDirective,
@@ -33,13 +33,20 @@
{{ 'EPG.GUIDE.NOW' | translate }}
</button>
<!-- The name carries the value: "Channels shown: Favorites". -->
<button
type="button"
mat-flat-button
class="guide-toolbar__scope"
[matMenuTriggerFor]="scopeMenu"
[attr.aria-label]="'EPG.GUIDE.SCOPE' | translate"
[attr.aria-label]="
('EPG.GUIDE.SCOPE' | translate) + ': ' + scopeLabel()
"
>
{{ scopeLabel() }} <mat-icon>arrow_drop_down</mat-icon>
{{ scopeLabel() }}
<mat-icon class="guide-toolbar__scope-chevron" iconPositionEnd>
expand_more
</mat-icon>
</button>
<!-- Single-choice rows, as `appMenuItemRadio` renders them in
@iptvnator/ui/components, which depends on this library. -->
@@ -1,5 +1,6 @@
@use '../epg-theme' as *;
@use '../../../../styles/focus-ring';
@use '../../../../styles/app-controls' as controls;
:host {
display: block;
@@ -70,8 +71,7 @@
}
}
.guide-toolbar__now,
.guide-toolbar__scope {
.guide-toolbar__now {
display: inline-flex;
align-items: center;
gap: 6px;
@@ -109,11 +109,14 @@
}
}
.guide-toolbar__scope mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
margin-right: -6px;
// The kit's toolbar picker: a small secondary button with a chevron.
.guide-toolbar__scope {
flex-shrink: 0;
@include controls.secondary-button(sm);
}
.guide-toolbar__scope-chevron {
@include controls.trigger-chevron;
}
.guide-toolbar__toggle {
@@ -79,6 +79,18 @@ describe('EpgGuideToolbarComponent scope menu', () => {
expect(checks).toEqual(['hidden', 'hidden', '']);
});
it('names the trigger with the scope it shows', () => {
const { fixture } = render('favorites');
const trigger = fixture.nativeElement.querySelector(
'.guide-toolbar__scope'
) as HTMLButtonElement;
expect(trigger.getAttribute('aria-label')).toBe(
'EPG.GUIDE.SCOPE: Favorites'
);
expect(trigger.textContent).toContain('Favorites');
});
it('reports the picked scope', () => {
const { fixture, rows } = render('all');
const picked: string[] = [];
@@ -1,3 +1,5 @@
@use '@angular/material' as mat;
@use '../../../../../../ui/styles/app-controls' as controls;
@use '../../../../../../ui/styles/focus-ring';
:host {
@@ -44,26 +46,20 @@
flex-shrink: 0;
}
// The kit's panel-header action: a square ghost icon button, kept at the
// header's 28px so the list below does not move. A switched-on action
// (search open, a custom category order) takes the selected-row tint.
.context-header__action {
width: 28px;
height: 28px;
line-height: 28px;
flex-shrink: 0;
padding: 0;
@include controls.ghost-icon-button(28px, 18px);
&[disabled] {
opacity: 0.48;
}
&--active mat-icon {
color: var(--mat-sys-primary);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
&--active {
background: var(--app-list-selected-bg);
@include mat.icon-button-overrides(
(
icon-color: var(--app-list-selected-fg),
)
);
}
}