mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
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:
1 parent
aecb9acb8e
commit
aabca1fae1
14 files changed
+257
-77
No files matched your search
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: ui
|
||||
---
|
||||
|
||||
The catalog Refine button, the season picker and the guide's channel filter
|
||||
now match the Sources sort, with a chevron showing they open a menu. Screen
|
||||
readers hear the guide filter's choice and the favorites sort order. The
|
||||
minimum-rating chip keeps its accent colour after any navigation.
|
||||
@@ -31,6 +31,9 @@ import { applyTheme, expectTextContrast } from './theme-contrast';
|
||||
//
|
||||
// The refine menu keeps its rating thresholds in a submenu, so it opens
|
||||
// whole below its button in a short window.
|
||||
//
|
||||
// Refine and the rating chip are Material buttons: their look comes from
|
||||
// button tokens, so it holds whichever stylesheet the page added last.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const locales = ['en', 'de', 'ru', 'hu'] as const;
|
||||
@@ -396,6 +399,87 @@ test.describe('Electron catalog sort menus', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the Refine picker and the rating chip's look whichever stylesheet the page added last", async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
|
||||
try {
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await openWorkspaceSection(page, 'Movies');
|
||||
await expect(
|
||||
page.locator('app-category-content-view mat-card').first()
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
const menu = await openRefineMenu(page);
|
||||
const thresholds = await openRatingSubmenu(page);
|
||||
await thresholds
|
||||
.getByRole('menuitemradio', { name: /\b7\b/ })
|
||||
.click();
|
||||
await expect(menu).toBeHidden();
|
||||
|
||||
const chip = page.locator(
|
||||
'app-category-content-view .rating-refinement-chip'
|
||||
);
|
||||
const refine = page.locator(
|
||||
'app-category-content-view .refine-action'
|
||||
);
|
||||
const look = async () => ({
|
||||
chip: await chip.evaluate((element) => {
|
||||
const probe = document.createElement('span');
|
||||
probe.style.color = 'var(--mat-sys-primary)';
|
||||
element.parentElement?.append(probe);
|
||||
const primary = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
isPrimary: style.color === primary,
|
||||
padding: `${style.paddingLeft} ${style.paddingRight}`,
|
||||
};
|
||||
}),
|
||||
refine: await refine.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
height: Math.round(
|
||||
element.getBoundingClientRect().height
|
||||
),
|
||||
radius: style.borderRadius,
|
||||
background: style.backgroundColor,
|
||||
};
|
||||
}),
|
||||
});
|
||||
const expected = {
|
||||
chip: { isPrimary: true, padding: '10px 10px' },
|
||||
refine: { height: 32, radius: '8px' },
|
||||
};
|
||||
|
||||
// Angular removes a component's stylesheet with its last instance
|
||||
// and appends it again with the next one, so either Material's
|
||||
// button sheet or the catalog view's may come last.
|
||||
const moveLast = (needle: string) =>
|
||||
page.evaluate((text) => {
|
||||
const sheets = [
|
||||
...document.head.querySelectorAll('style'),
|
||||
].filter((sheet) => sheet.textContent?.includes(text));
|
||||
for (const sheet of sheets) document.head.append(sheet);
|
||||
return sheets.length;
|
||||
}, needle);
|
||||
|
||||
const initial = await look();
|
||||
expect(initial).toMatchObject(expected);
|
||||
expect(await moveLast('.mat-mdc-button:not(:disabled)')).toBe(1);
|
||||
expect(await look(), 'Material button sheet last').toEqual(initial);
|
||||
expect(await moveLast('rating-refinement-chip')).toBe(1);
|
||||
expect(await look(), 'catalog view sheet last').toEqual(initial);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('shows aligned radio rows with a legible check in the live, category and source sort menus, in light and dark themes', async ({
|
||||
dataDir,
|
||||
request,
|
||||
|
||||
@@ -154,6 +154,15 @@ tokens, so the retired `--mdc-*` names compile but do nothing;
|
||||
a spec loads as raw CSS cannot use Sass modules; it declares the `--mat-*`
|
||||
token directly and says why.
|
||||
|
||||
Change what a Material button draws (label colour, padding) through its
|
||||
tokens, not plain properties on its host. Material's own rules, such as
|
||||
`.mat-mdc-button:not(:disabled)` for the colour, match a component's
|
||||
`.class[_ngcontent-…]` selector in specificity, so the stylesheet added last
|
||||
wins. Angular removes a component's stylesheet with its last instance and
|
||||
appends it again with the next one, so that order changes with navigation.
|
||||
`catalog-sort-menus.e2e.ts` moves each sheet last and checks that the Refine
|
||||
picker and the rating chip keep their look.
|
||||
|
||||
### Material off the startup path
|
||||
|
||||
`renderer.initialBytes` ([performance journeys](performance-journeys.md#rendererinitialbytes))
|
||||
@@ -1040,8 +1049,9 @@ and drive the season container's presenters.
|
||||
|
||||
Every section below the hero (Episodes, Cast & crew, Similar) uses
|
||||
`app-detail-section-header`: an 18px/600 title, a muted 12.5px tabular
|
||||
counter, a lead slot (the season pills or dropdown, a 30px pill) and an end
|
||||
slot (the list/grid toggle, rail arrows). Sections sit 56px apart, with 16px
|
||||
counter, a lead slot (the 30px season pills, or past four seasons the
|
||||
season dropdown, a small secondary button with a chevron) and an end slot
|
||||
(the list/grid toggle, rail arrows). Sections sit 56px apart, with 16px
|
||||
between a header and its content and 56px after the last one. The Episodes
|
||||
counter reads "N episodes · M watched". The list/grid switch is a segmented
|
||||
track whose checked segment is a neutral raised fill with heading ink; the
|
||||
@@ -1235,8 +1245,15 @@ ui:form-field-defaults:validate` (in CI) reports a missing provider.
|
||||
A `mat-select` belongs inside a form. Its option panel is styled once in
|
||||
`m3-theme.scss` to match the kit menu (a bordered 10px panel, 36px rows at
|
||||
13px, the chosen row on the list-selection tokens). A picker in a toolbar is
|
||||
not a select: it is a secondary button with a chevron that opens an
|
||||
`app-menu`.
|
||||
not a select: it is a `mat-flat-button` with the `secondary-button` mixin and
|
||||
a trailing `expand_more` chevron (`iconPositionEnd`, `trigger-chevron`) that
|
||||
opens a `mat-menu`, as the Sources sort, the catalog Refine button, the
|
||||
programme guide's scope and the season dropdown do. Its label shows the
|
||||
current value where there is one; when the visible label is only the value,
|
||||
the accessible name adds what it picks ("Channels shown: Favorites"). An
|
||||
active picker (refinements applied) takes the selected-row tint. A panel
|
||||
header's icon actions use `ghost-icon-button`, as the category panel's
|
||||
search and sort do.
|
||||
|
||||
The add-source forms (M3U URL, Xtream, Stalker) and the edit dialog share one
|
||||
vocabulary, so a field reads the same wherever it appears:
|
||||
|
||||
+7
-1
@@ -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()) {
|
||||
|
||||
+40
-15
@@ -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;
|
||||
|
||||
+4
-1
@@ -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>
|
||||
|
||||
+29
@@ -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[] = [];
|
||||
|
||||
+13
-17
@@ -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),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user