mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
style(ui): the kit panel for every menu, shared danger rows, radio pickers (#1945)
* style(ui): the kit panel for every menu, shared danger rows, radio pickers Every mat-menu now gets the kit panel from one global rule instead of the opt-in `.app-menu` class: a bordered 10px panel, 36px rows at 13px with 16px icons, the checked row on the list-selection tokens. The mixin caps the panel at 360px wide and 480px tall (less in a short window), so long lists such as seasons and guide scopes scroll inside it; Material 22 sets no height limit. Destructive rows in the downloads lists and the dashboard rail card menu use `app-menu-item--danger` after a mat-divider. The rail menu's dead `panelClass` and private danger/separator rules are gone. The season picker's rows are appMenuItemRadio with the check slot, and its outer tablist role applies only in chip mode. The guide scope rows are menuitemradio with aria-checked and the same check icon, spelled out because ui-epg sits below ui-components. Both lose their private panel styles. The refine menu captions and the downloads source header use the kit's section label. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(ui): replace the stale no-height-cap menu guidance The Single-Choice Menus section still said Material sets no max-height on a menu, so a tall menu is clipped. The kit panel now caps every menu at 480px and scrolls; the bullet keeps the advice to open a menu of actions whole and move a secondary group into a submenu. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): park the pointer before the keyboard-only refine menu steps On the Linux and Windows runners the refine menu test found the rating row inactive after ArrowUp, with its submenu already open. With the kit's 36px rows that row sits under the runners' resting cursor; Chromium hovers whatever lands under a resting pointer after layout, Material opens a submenu on hover, and ArrowUp then moved inside the thresholds. Resting a pointer on the row locally reproduces the same failure. The test now moves the pointer to the corner before it drives the menu from the keyboard. 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:
1 parent
8098ea8bb8
commit
ea1ac41fc9
28 files changed
+271
-142
No files matched your search
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -254,9 +254,10 @@ body.dark-theme {
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
// UI-kit menu panel: `<mat-menu class="app-menu">`. 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 `<div role="none">`; 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 `<div role="none">`; 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 `<mat-menu class="app-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, 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 `<mat-divider>`; 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
|
||||
<button mat-menu-item [appMenuItemRadio]="mode() === option.mode">
|
||||
@@ -323,14 +325,19 @@ threshold, uses `appMenuItemRadio` and `appMenuItemRadioCheck` from
|
||||
therefore shifted that row's label. In a button, put a trailing icon after
|
||||
the label with `iconPositionEnd`.
|
||||
- A semantic icon, if any, comes after the check slot.
|
||||
- `@iptvnator/ui/epg` sits below `@iptvnator/ui/components` in the
|
||||
dependency graph, so the guide toolbar's scope menu spells the same markup
|
||||
out: `role="menuitemradio"` on the item, `aria-checked`, and an
|
||||
`app-menu-item-radio-check` icon hidden while unchecked.
|
||||
- A menu with several groups, such as the catalog refine menu, wraps each
|
||||
group in `role="group"`. The group is named by its section title, and
|
||||
`role="separator"` sits between groups.
|
||||
- Keep a menu short enough to open whole below its trigger in a 600px-tall
|
||||
window. Material 22 sets no max-height on a menu panel: a taller menu is
|
||||
pushed up over its own trigger, then clipped. Move a secondary group into
|
||||
a submenu whose row shows the active value, as the refine menu does with
|
||||
its rating thresholds.
|
||||
- Keep a menu of actions or groups short enough to open whole below its
|
||||
trigger in a 600px-tall window. The kit panel caps every menu at 480px
|
||||
(less in a short window) and scrolls past that, which suits a long list
|
||||
of peers such as seasons or guide scopes, but a scrolled-off action is
|
||||
easy to miss. Move a secondary group into a submenu whose row shows the
|
||||
active value, as the refine menu does with its rating thresholds.
|
||||
- Material offsets a submenu by the `offsetTop` of the parent menu's first
|
||||
direct `mat-menu-item`. When the earlier rows sit inside a `role="group"`,
|
||||
that first direct item is the submenu trigger itself, and the submenu
|
||||
|
||||
@@ -182,7 +182,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-menu #menu="matMenu" class="app-menu" xPosition="before">
|
||||
<mat-menu #menu="matMenu" xPosition="before">
|
||||
<ng-template matMenuContent>
|
||||
@if (canRefresh()) {
|
||||
<button
|
||||
|
||||
+8
-7
@@ -144,20 +144,21 @@
|
||||
background: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
// The kit's menu section label and divider, aligned with its 10px rows.
|
||||
.refine-menu-section-title {
|
||||
padding: 8px 16px 4px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
padding: 8px 10px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
|
||||
color: var(--app-muted-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.refine-menu-divider {
|
||||
height: 1px;
|
||||
margin: 6px 12px;
|
||||
background: color-mix(in srgb, var(--app-on-surface) 8%, transparent);
|
||||
margin: 4px 0;
|
||||
background: var(--app-separator);
|
||||
}
|
||||
|
||||
// The label span fills the row, so the active threshold sits at its end,
|
||||
|
||||
@@ -164,9 +164,11 @@
|
||||
{{ 'DOWNLOADS.COPY_URL' | translate }}
|
||||
</span>
|
||||
</button>
|
||||
<mat-divider />
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
class="app-menu-item--danger"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.REMOVE_FROM_MANAGER'
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
@@ -28,6 +29,7 @@ import { DownloadSourceMenuHeaderComponent } from './download-source-menu-header
|
||||
imports: [
|
||||
DatePipe,
|
||||
MatButtonModule,
|
||||
MatDividerModule,
|
||||
MatIconModule,
|
||||
MatMenuModule,
|
||||
MatTooltipModule,
|
||||
|
||||
@@ -33,7 +33,10 @@
|
||||
'DOWNLOADS.ARIA.OPEN'
|
||||
| translate: { title: row.item.title }
|
||||
"
|
||||
[disabled]="isPending(row.item.id) || row.item.contentType === 'catchup'"
|
||||
[disabled]="
|
||||
isPending(row.item.id) ||
|
||||
row.item.contentType === 'catchup'
|
||||
"
|
||||
(click)="requestOpen(row.item)"
|
||||
>
|
||||
@if (hasPoster(row.item)) {
|
||||
@@ -74,7 +77,10 @@
|
||||
'DOWNLOADS.ARIA.OPEN'
|
||||
| translate: { title: row.item.title }
|
||||
"
|
||||
[disabled]="isPending(row.item.id) || row.item.contentType === 'catchup'"
|
||||
[disabled]="
|
||||
isPending(row.item.id) ||
|
||||
row.item.contentType === 'catchup'
|
||||
"
|
||||
(click)="requestOpen(row.item)"
|
||||
>
|
||||
{{ row.item.title }}
|
||||
@@ -281,9 +287,11 @@
|
||||
</span>
|
||||
</button>
|
||||
@if (canRemove(row)) {
|
||||
<mat-divider />
|
||||
<button
|
||||
type="button"
|
||||
mat-menu-item
|
||||
class="app-menu-item--danger"
|
||||
data-test-action="remove"
|
||||
[disabled]="isPending(row.item.id)"
|
||||
[attr.aria-label]="
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
signal,
|
||||
} 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 { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
@@ -66,6 +67,7 @@ export function formatDownloadBytes(bytes: number | undefined): string {
|
||||
selector: 'app-download-queue',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatDividerModule,
|
||||
MatIcon,
|
||||
MatMenuModule,
|
||||
MatProgressBarModule,
|
||||
|
||||
@@ -20,22 +20,26 @@ import { TranslatePipe } from '@ngx-translate/core';
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Lines up with the kit menu rows (10px inline padding) and uses the
|
||||
// kit's section label for the caption.
|
||||
.download-source-menu-header {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
max-width: 280px;
|
||||
padding: 10px 16px 8px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
padding: 6px 10px 8px;
|
||||
|
||||
span {
|
||||
font: var(--mat-sys-label-small);
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--app-muted-color);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
strong {
|
||||
overflow: hidden;
|
||||
color: var(--mat-sys-on-surface);
|
||||
font: var(--mat-sys-label-large);
|
||||
color: var(--app-heading-color);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
<mat-menu #detailMenu="matMenu" xPosition="before">
|
||||
<button
|
||||
mat-menu-item
|
||||
class="app-menu-item--danger"
|
||||
data-testid="recording-remove"
|
||||
[disabled]="isActive()"
|
||||
(click)="runAction('remove')"
|
||||
|
||||
@@ -66,7 +66,11 @@
|
||||
>
|
||||
{{ durationLabel(row) }}
|
||||
@if (row.interrupted) {
|
||||
· {{ 'DOWNLOADS.STATUS.INTERRUPTED' | translate }}
|
||||
·
|
||||
{{
|
||||
'DOWNLOADS.STATUS.INTERRUPTED'
|
||||
| translate
|
||||
}}
|
||||
}
|
||||
</span>
|
||||
} @else if (row.interrupted) {
|
||||
@@ -107,7 +111,9 @@
|
||||
(click)="emitAction('reveal', row.item)"
|
||||
>
|
||||
<mat-icon>folder_open</mat-icon>
|
||||
<span>{{ 'DOWNLOADS.REVEAL' | translate }}</span>
|
||||
<span>{{
|
||||
'DOWNLOADS.REVEAL' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
@@ -119,14 +125,19 @@
|
||||
{{ 'DOWNLOADS.OPEN_DETAILS' | translate }}
|
||||
</span>
|
||||
</button>
|
||||
<mat-divider />
|
||||
<button
|
||||
mat-menu-item
|
||||
class="app-menu-item--danger"
|
||||
data-test-action="remove-recording"
|
||||
(click)="emitAction('remove', row.item)"
|
||||
>
|
||||
<mat-icon>remove_circle_outline</mat-icon>
|
||||
<span>
|
||||
{{ 'DOWNLOADS.REMOVE_FROM_MANAGER' | translate }}
|
||||
{{
|
||||
'DOWNLOADS.REMOVE_FROM_MANAGER'
|
||||
| translate
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -161,7 +161,9 @@
|
||||
[disabled]="isPending(row.item.id)"
|
||||
(click)="emitAction('remove', row.item)"
|
||||
>
|
||||
{{ 'DOWNLOADS.REMOVE_FROM_MANAGER' | translate }}
|
||||
{{
|
||||
'DOWNLOADS.REMOVE_FROM_MANAGER' | translate
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -195,8 +197,12 @@
|
||||
</button>
|
||||
}
|
||||
@if (row.item.status !== 'recording') {
|
||||
@if (row.attentionReason === null) {
|
||||
<mat-divider />
|
||||
}
|
||||
<button
|
||||
mat-menu-item
|
||||
class="app-menu-item--danger"
|
||||
data-test-action="menu-remove-recording"
|
||||
(click)="emitAction('remove', row.item)"
|
||||
>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
untracked,
|
||||
} 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 { MatTooltip } from '@angular/material/tooltip';
|
||||
@@ -41,6 +42,7 @@ interface RecordingQueueSection {
|
||||
imports: [
|
||||
DatePipe,
|
||||
MatButtonModule,
|
||||
MatDividerModule,
|
||||
MatIcon,
|
||||
MatMenuModule,
|
||||
MatTooltip,
|
||||
|
||||
@@ -198,12 +198,8 @@ describe('SeasonContainerComponent season picker', () => {
|
||||
expect(countOf(items[0])).toBe('3 episodes · 2 watched');
|
||||
// Nothing watched: the second half is left out, not "0 watched".
|
||||
expect(countOf(items[1])).toBe('3 episodes');
|
||||
expect(
|
||||
items[0].classList.contains('season-tabs__menu-item--selected')
|
||||
).toBe(true);
|
||||
expect(
|
||||
items[1].classList.contains('season-tabs__menu-item--selected')
|
||||
).toBe(false);
|
||||
expect(items[0].getAttribute('aria-checked') === 'true').toBe(true);
|
||||
expect(items[1].getAttribute('aria-checked') === 'true').toBe(false);
|
||||
expect(seasonImages()).toHaveLength(0);
|
||||
|
||||
items[4].click();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="season-tabs" role="tablist">
|
||||
<div class="season-tabs" [attr.role]="useDropdown() ? null : 'tablist'">
|
||||
@if (useDropdown()) {
|
||||
<button
|
||||
class="season-tabs__dropdown"
|
||||
@@ -22,11 +22,10 @@
|
||||
mat-menu-item
|
||||
class="season-tabs__menu-item"
|
||||
data-testid="season-menu-item"
|
||||
[class.season-tabs__menu-item--selected]="
|
||||
seasonKey === selectedSeason()
|
||||
"
|
||||
[appMenuItemRadio]="seasonKey === selectedSeason()"
|
||||
(click)="selectSeason(seasonKey)"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
<span class="season-tabs__menu-row">
|
||||
<span class="season-tabs__menu-name">
|
||||
{{
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
@@ -106,46 +104,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
// The season menu: two columns per row, the season name and its
|
||||
// "N episodes · M watched" count; the selected season gets a selection tint.
|
||||
// The panel lives in the overlay container, outside this host.
|
||||
::ng-deep .season-tabs-menu.mat-mdc-menu-panel {
|
||||
@include mat.menu-overrides(
|
||||
(
|
||||
container-color: var(--mat-sys-surface-container-high),
|
||||
)
|
||||
);
|
||||
min-width: 280px;
|
||||
max-width: min(360px, calc(100vw - 24px));
|
||||
border: 1px solid color-mix(in srgb, var(--app-on-surface) 10%, transparent);
|
||||
border-radius: 10px;
|
||||
|
||||
.mat-mdc-menu-content {
|
||||
padding: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
// The season menu takes the global kit panel; its rows carry two columns,
|
||||
// the season name and its "N episodes · M watched" count. The chosen row is a
|
||||
// `menuitemradio` and gets the kit's selected tint.
|
||||
.season-tabs__menu-item {
|
||||
min-height: 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
|
||||
// Material wraps the projected text in a span that must take the row.
|
||||
::ng-deep .mat-mdc-menu-item-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-color) 10%,
|
||||
transparent
|
||||
);
|
||||
|
||||
.season-tabs__menu-count {
|
||||
color: var(--app-selection-color);
|
||||
}
|
||||
&[aria-checked='true'] .season-tabs__menu-count {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +130,7 @@
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--app-heading-color);
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
MenuItemRadioCheckDirective,
|
||||
MenuItemRadioDirective,
|
||||
} from '../menu-item-radio/menu-item-radio.directive';
|
||||
|
||||
/** Up to this many seasons the picker is a row of chips, beyond it a menu. */
|
||||
const MAX_SEASON_CHIPS = 4;
|
||||
@@ -31,7 +35,13 @@ export type SeasonCountLoadState = 'loading' | 'unloaded';
|
||||
templateUrl: './season-tabs.component.html',
|
||||
styleUrls: ['./season-tabs.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MatIcon, MatMenuModule, TranslateModule],
|
||||
imports: [
|
||||
MatIcon,
|
||||
MatMenuModule,
|
||||
MenuItemRadioCheckDirective,
|
||||
MenuItemRadioDirective,
|
||||
TranslateModule,
|
||||
],
|
||||
})
|
||||
export class SeasonTabsComponent {
|
||||
/** Season keys, already sorted in display order. */
|
||||
|
||||
@@ -41,14 +41,25 @@
|
||||
>
|
||||
{{ scopeLabel() }} <mat-icon>arrow_drop_down</mat-icon>
|
||||
</button>
|
||||
<mat-menu #scopeMenu="matMenu" class="guide-toolbar__scope-menu">
|
||||
<!-- Single-choice rows, as `appMenuItemRadio` renders them in
|
||||
@iptvnator/ui/components, which depends on this library. -->
|
||||
<mat-menu #scopeMenu="matMenu">
|
||||
@for (scope of scopes(); track scope.id) {
|
||||
@let checked = scope.id === scopeId();
|
||||
<button
|
||||
mat-menu-item
|
||||
[class.is-selected]="scope.id === scopeId()"
|
||||
role="menuitemradio"
|
||||
[attr.aria-checked]="checked"
|
||||
(click)="scopeChange.emit(scope.id)"
|
||||
>
|
||||
{{ scope.label }}
|
||||
<mat-icon
|
||||
class="app-menu-item-radio-check"
|
||||
aria-hidden="true"
|
||||
[style.visibility]="checked ? null : 'hidden'"
|
||||
>
|
||||
check
|
||||
</mat-icon>
|
||||
<span>{{ scope.label }}</span>
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<HTMLElement>(
|
||||
'.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']);
|
||||
});
|
||||
});
|
||||
@@ -194,10 +194,11 @@ $_sizes: (
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
|
||||
/// A `mat-menu` panel; apply to the class given to `<mat-menu class>`.
|
||||
/// 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 `<mat-divider>`; 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);
|
||||
|
||||
|
||||
@@ -233,11 +233,18 @@
|
||||
card.episodeBadge
|
||||
}}</span>
|
||||
}
|
||||
@if (card.remainingLabel; as remaining) {
|
||||
<span class="rail__card-remaining">{{
|
||||
remaining.key
|
||||
| translate: remaining.params
|
||||
}}</span>
|
||||
@if (
|
||||
card.remainingLabel;
|
||||
as remaining
|
||||
) {
|
||||
<span
|
||||
class="rail__card-remaining"
|
||||
>{{
|
||||
remaining.key
|
||||
| translate
|
||||
: remaining.params
|
||||
}}</span
|
||||
>
|
||||
}
|
||||
@if (card.subtitle) {
|
||||
<span class="rail__card-subtitle">{{
|
||||
@@ -252,7 +259,8 @@
|
||||
"
|
||||
[attr.data-test-id]="
|
||||
testId()
|
||||
? testId() + '-card-expiry'
|
||||
? testId() +
|
||||
'-card-expiry'
|
||||
: null
|
||||
"
|
||||
>{{ expiry.label }}</span
|
||||
@@ -284,21 +292,17 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-menu
|
||||
#cardActionMenu="matMenu"
|
||||
panelClass="dashboard-rail-action-menu"
|
||||
xPosition="before"
|
||||
>
|
||||
<mat-menu #cardActionMenu="matMenu" xPosition="before">
|
||||
<ng-template matMenuContent let-card="card">
|
||||
@for (action of card.actions ?? []; track action.id) {
|
||||
@if (action.separatorBefore) {
|
||||
<mat-divider />
|
||||
}
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[disabled]="action.disabled"
|
||||
[class.rail__menu-action--danger]="action.destructive"
|
||||
[class.rail__menu-action--separated]="
|
||||
action.separatorBefore
|
||||
"
|
||||
[class.app-menu-item--danger]="action.destructive"
|
||||
(click)="selectAction(card, action, $event)"
|
||||
>
|
||||
<mat-icon>{{ action.icon }}</mat-icon>
|
||||
|
||||
@@ -276,7 +276,11 @@
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
@include mat.icon-button-overrides((state-layer-size: 30px));
|
||||
@include mat.icon-button-overrides(
|
||||
(
|
||||
state-layer-size: 30px,
|
||||
)
|
||||
);
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
@@ -292,19 +296,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.rail__menu-action--separated {
|
||||
border-top: 1px solid var(--app-separator);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.rail__menu-action--danger {
|
||||
color: var(--mat-sys-error);
|
||||
|
||||
mat-icon {
|
||||
color: var(--mat-sys-error);
|
||||
}
|
||||
}
|
||||
|
||||
.rail__meta {
|
||||
@include geometry.meta-stack;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
viewChildren,
|
||||
} 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 { RouterLink } from '@angular/router';
|
||||
@@ -123,6 +124,7 @@ export interface DashboardRailActionSelection {
|
||||
selector: 'lib-dashboard-rail',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatDividerModule,
|
||||
MatIcon,
|
||||
MatMenuModule,
|
||||
RouterLink,
|
||||
|
||||
+1
-5
@@ -44,11 +44,7 @@
|
||||
>expand_more</mat-icon
|
||||
>
|
||||
</button>
|
||||
<mat-menu
|
||||
#sortMenu="matMenu"
|
||||
class="app-menu"
|
||||
xPosition="before"
|
||||
>
|
||||
<mat-menu #sortMenu="matMenu" xPosition="before">
|
||||
<div
|
||||
role="group"
|
||||
[attr.aria-label]="
|
||||
|
||||
Reference in new issue
Block a user