diff --git a/.changes/ui-kit-menus.md b/.changes/ui-kit-menus.md new file mode 100644 index 000000000..5ff505998 --- /dev/null +++ b/.changes/ui-kit-menus.md @@ -0,0 +1,10 @@ +--- +type: fix +area: ui +--- + +Every menu now shares one compact look, with the same row height, borders and +spacing, and long menus stay on screen and scroll. Remove actions in the +download and dashboard menus are marked in red after a separator. The season +and programme-guide pickers show a check on the chosen entry, and screen +readers announce them as single-choice lists. 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 80f0ac019..d4d647790 100644 --- a/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts +++ b/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts @@ -353,6 +353,12 @@ test.describe('Electron catalog sort menus', () => { const trigger = page.locator( 'app-category-content-view .refine-action' ); + // Keyboard only: park the pointer away from where the menu opens. + // Chromium hovers whatever lands under a resting pointer after a + // layout change, and Material opens a submenu on hover. With 36px + // rows the rating row sat under the CI runners' resting cursor, the + // thresholds opened on their own, and ArrowUp moved inside them. + await page.mouse.move(0, 0); await trigger.focus(); await page.keyboard.press('Enter'); const menu = refineMenuPanel(page); diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index cf96f696c..9f2d6dd3e 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -254,9 +254,10 @@ body.dark-theme { app-region: no-drag; } -// UI-kit menu panel: ``. The panel renders in the -// overlay container, outside every component view, so the rule is global. -.mat-mdc-menu-panel.app-menu { +// UI-kit menu panel for every `mat-menu`. The panel renders in the overlay +// container, outside every component view, so the rule is global; the pane +// prefix outranks Material's single-class panel rules, which load later. +.cdk-overlay-pane .mat-mdc-menu-panel { @include controls.menu-panel; } diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index c8442ca79..66a6c1394 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -64,10 +64,10 @@ the subsystem's document. **Consequences.** - Material upgrades can change behaviour. Seen in v22: the menu panel has no - max-height; a submenu is offset by the parent's first direct - `mat-menu-item`, so a trigger that follows `role="group"` rows is wrapped - in `
`; overlay panes are `position: static` inside the - bounding box. + max-height (the kit panel adds one); a submenu is offset by the parent's + first direct `mat-menu-item`, so a trigger that follows `role="group"` rows + is wrapped in `
`; overlay panes are `position: static` + inside the bounding box. - Keep E2E coverage on menus and dialogs. ## Canonical References @@ -294,18 +294,20 @@ selection blue on its own tint is 3.3:1) and the selection colour in dark. ## Single-Choice Menus And Status Chips -A `` gets the kit panel from one global rule in -`styles.scss` (`menu-panel` in `libs/ui/styles/_app-controls.scss`): a -bordered 10px panel with 6px padding, 36px rows at 13px with 16px icons, the -checked row on the list-selection tokens, a destructive row marked -`app-menu-item--danger` after a divider, and a shortcut hint in a trailing -`kbd.app-menu-item__kbd`. The panel renders in the overlay container, outside -every component view, so a component never copies the mixin into its own -stylesheet. +Every `mat-menu` gets the kit panel from one global rule in `styles.scss` +(`menu-panel` in `libs/ui/styles/_app-controls.scss`): a bordered 10px panel +with 6px padding, 240–360px wide, 36px rows at 13px with 16px icons, the +checked row on the list-selection tokens, and at most 480px tall (less in a +short window), so a long list scrolls inside the panel. A destructive row +carries `app-menu-item--danger` and sits after a ``; a shortcut +hint is a trailing `kbd.app-menu-item__kbd`. The panel renders in the overlay +container, outside every component view, so a component neither copies the +mixin nor styles its own panel; a section caption inside a menu uses the +kit's 11px uppercase label, as the catalog refine menu does. -A `mat-menu` that picks one value, such as a sort order or a rating -threshold, uses `appMenuItemRadio` and `appMenuItemRadioCheck` from -`@iptvnator/ui/components`: +A `mat-menu` that picks one value, such as a sort order, a rating threshold, +a season or a guide scope, uses `appMenuItemRadio` and +`appMenuItemRadioCheck` from `@iptvnator/ui/components`: ```html
- + @if (canRefresh()) { + @if (canRemove(row)) { + + diff --git a/libs/portal/downloads/feature/src/lib/recording-library.component.ts b/libs/portal/downloads/feature/src/lib/recording-library.component.ts index 1c2004e10..c93376775 100644 --- a/libs/portal/downloads/feature/src/lib/recording-library.component.ts +++ b/libs/portal/downloads/feature/src/lib/recording-library.component.ts @@ -6,6 +6,7 @@ import { output, } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; +import { MatDividerModule } from '@angular/material/divider'; import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { TranslatePipe } from '@ngx-translate/core'; @@ -32,6 +33,7 @@ import { imports: [ DatePipe, MatButtonModule, + MatDividerModule, MatIcon, MatMenuModule, TranslatePipe, diff --git a/libs/portal/downloads/feature/src/lib/recording-queue.component.html b/libs/portal/downloads/feature/src/lib/recording-queue.component.html index 275850944..e0d1444e7 100644 --- a/libs/portal/downloads/feature/src/lib/recording-queue.component.html +++ b/libs/portal/downloads/feature/src/lib/recording-queue.component.html @@ -161,7 +161,9 @@ [disabled]="isPending(row.item.id)" (click)="emitAction('remove', row.item)" > - {{ 'DOWNLOADS.REMOVE_FROM_MANAGER' | translate }} + {{ + 'DOWNLOADS.REMOVE_FROM_MANAGER' | translate + }} } @@ -195,8 +197,12 @@ } @if (row.item.status !== 'recording') { + @if (row.attentionReason === null) { + + } - + + @for (scope of scopes(); track scope.id) { + @let checked = scope.id === scopeId(); } 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 e39129efb..8af72cee3 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 @@ -271,14 +271,3 @@ color: $text-tertiary; white-space: nowrap; } - -// CDK menus render outside the component, so the scope menu needs a piercing -// rule rather than component-scoped styling. -::ng-deep .guide-toolbar__scope-menu { - max-height: 60vh; - - .is-selected { - color: var(--app-selection-color); - font-weight: 600; - } -} 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 new file mode 100644 index 000000000..0687c1f47 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.spec.ts @@ -0,0 +1,93 @@ +import { TestBed } from '@angular/core/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject, of } from 'rxjs'; +import { EpgGuideScope } from './epg-guide-source'; +import { EpgGuideToolbarComponent } from './epg-guide-toolbar.component'; + +const SCOPES: EpgGuideScope[] = [ + { id: 'all', label: 'All channels', kind: 'all' }, + { id: 'favorites', label: 'Favorites', kind: 'favorites' }, + { id: 'group:news', label: 'News', kind: 'group' }, +]; + +describe('EpgGuideToolbarComponent scope menu', () => { + function render(scopeId: string) { + TestBed.configureTestingModule({ + imports: [EpgGuideToolbarComponent], + providers: [ + { + provide: TranslateService, + useValue: { + currentLang: 'en', + defaultLang: 'en', + onLangChange: new BehaviorSubject(null), + onTranslationChange: new BehaviorSubject(null), + onDefaultLangChange: new BehaviorSubject(null), + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + }, + }, + ], + }); + const fixture = TestBed.createComponent(EpgGuideToolbarComponent); + fixture.componentRef.setInput('dayMs', Date.UTC(2026, 9, 14)); + fixture.componentRef.setInput('scopes', SCOPES); + fixture.componentRef.setInput('scopeId', scopeId); + fixture.detectChanges(); + const trigger = fixture.nativeElement.querySelector( + '.guide-toolbar__scope' + ) as HTMLButtonElement; + trigger.click(); + fixture.detectChanges(); + const rows = Array.from( + document.querySelectorAll( + '.cdk-overlay-container [mat-menu-item]' + ) + ); + return { fixture, rows }; + } + + it('marks the chosen scope as the checked single-choice row', () => { + const { rows } = render('favorites'); + + expect(rows.map((row) => row.getAttribute('role'))).toEqual([ + 'menuitemradio', + 'menuitemradio', + 'menuitemradio', + ]); + expect(rows.map((row) => row.getAttribute('aria-checked'))).toEqual([ + 'false', + 'true', + 'false', + ]); + expect(rows.map((row) => row.textContent?.trim())).toEqual([ + 'check All channels', + 'check Favorites', + 'check News', + ]); + }); + + it('shows the check on the chosen row only, keeping every label on one edge', () => { + const { rows } = render('group:news'); + + const checks = rows.map( + (row) => + (row.querySelector('.app-menu-item-radio-check') as HTMLElement) + .style.visibility + ); + expect(checks).toEqual(['hidden', 'hidden', '']); + }); + + it('reports the picked scope', () => { + const { fixture, rows } = render('all'); + const picked: string[] = []; + fixture.componentInstance.scopeChange.subscribe((id) => + picked.push(id) + ); + + rows[2].click(); + + expect(picked).toEqual(['group:news']); + }); +}); diff --git a/libs/ui/styles/_app-controls.scss b/libs/ui/styles/_app-controls.scss index adedbcf2f..2ae6c17a0 100644 --- a/libs/ui/styles/_app-controls.scss +++ b/libs/ui/styles/_app-controls.scss @@ -194,10 +194,11 @@ $_sizes: ( color: var(--app-body-color); } -/// A `mat-menu` panel; apply to the class given to ``. +/// The `mat-menu` panel; `styles.scss` applies it to every menu. /// Items: 36px, 13px, 16px icons in the body colour; destructive items -/// carry `app-menu-item--danger` and sit after a divider; a keyboard hint -/// is a trailing `kbd.app-menu-item__kbd`. +/// carry `app-menu-item--danger` and sit after a ``; a +/// keyboard hint is a trailing `kbd.app-menu-item__kbd`. A long list stays +/// on screen and scrolls inside the panel. @mixin menu-panel { @include mat.menu-overrides( ( @@ -229,6 +230,8 @@ $_sizes: ( ) ); min-width: 240px; + max-width: min(360px, calc(100vw - 24px)); + max-height: min(480px, calc(100vh - 32px)); border: 1px solid var(--app-line-strong); box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index 0836792a6..503c8e181 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -233,11 +233,18 @@ card.episodeBadge }} } - @if (card.remainingLabel; as remaining) { - {{ - remaining.key - | translate: remaining.params - }} + @if ( + card.remainingLabel; + as remaining + ) { + {{ + remaining.key + | translate + : remaining.params + }} } @if (card.subtitle) { {{ @@ -252,7 +259,8 @@ " [attr.data-test-id]=" testId() - ? testId() + '-card-expiry' + ? testId() + + '-card-expiry' : null " >{{ expiry.label }} - + @for (action of card.actions ?? []; track action.id) { + @if (action.separatorBefore) { + + } - +