diff --git a/.changes/ui-kit-toolbar-pickers.md b/.changes/ui-kit-toolbar-pickers.md new file mode 100644 index 000000000..9d43868b9 --- /dev/null +++ b/.changes/ui-kit-toolbar-pickers.md @@ -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. diff --git a/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts b/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts index d4d647790..6e12a8be3 100644 --- a/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts +++ b/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts @@ -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, diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index a628088a6..f17f94521 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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: diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html index 5f2511981..8206493bd 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html @@ -65,7 +65,7 @@ @if (canSortContent()) { diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss index 745ae09d1..aa7df3e54 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss @@ -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; diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html index e0d16d321..efbd34fab 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html @@ -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) + " > sort_by_alpha diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index 94c17550f..18019a6e7 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -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(); diff --git a/libs/ui/components/src/lib/season-container/season-tabs.component.html b/libs/ui/components/src/lib/season-container/season-tabs.component.html index 582786ccc..50cefccfc 100644 --- a/libs/ui/components/src/lib/season-container/season-tabs.component.html +++ b/libs/ui/components/src/lib/season-container/season-tabs.component.html @@ -1,18 +1,19 @@
@if (useDropdown()) { @for (seasonKey of seasonKeys(); track seasonKey) { diff --git a/libs/ui/components/src/lib/season-container/season-tabs.component.scss b/libs/ui/components/src/lib/season-container/season-tabs.component.scss index 8b252debb..c347f520f 100644 --- a/libs/ui/components/src/lib/season-container/season-tabs.component.scss +++ b/libs/ui/components/src/lib/season-container/season-tabs.component.scss @@ -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 { diff --git a/libs/ui/components/src/lib/season-container/season-tabs.component.ts b/libs/ui/components/src/lib/season-container/season-tabs.component.ts index 2b33db7b7..eaf793efc 100644 --- a/libs/ui/components/src/lib/season-container/season-tabs.component.ts +++ b/libs/ui/components/src/lib/season-container/season-tabs.component.ts @@ -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, diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.html b/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.html index f7668873a..ed9454fd0 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.html +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.html @@ -33,13 +33,20 @@ {{ 'EPG.GUIDE.NOW' | translate }} + diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.scss b/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.scss index 8af72cee3..824266254 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.scss +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.scss @@ -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 { diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.spec.ts index 0687c1f47..6cf79004c 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.spec.ts @@ -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[] = []; diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.scss b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.scss index 2d2c38d0c..ae6f61e0e 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.scss @@ -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), + ) + ); } }