mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
fix(dashboard): keep the hero height stable across slides (#1855)
* fix(dashboard): keep the hero height stable across slides The hero laid out only the shown slide, so every automatic rotation between slides of different heights resized the banner and moved every rail below it by 7px, every 8 s on an idle dashboard (a layout-shift score of 0.005 per rotation cycle). Every slide's content now sits in the same grid cell at the bottom of the banner. The hero is therefore as tall as its tallest slide whichever one is shown, and it still grows for an unusually full slide. Inactive slides are inert and visibility: hidden, and carry no test hooks. Enter on the hero follows the shown slide's primary action. The rotation dots keep one fixed width. The active pill is the same 18px bar with its clip-path opened, so a slide change no longer moves the neighbouring dots or the pill itself. The legibility E2E now records which nodes shift. It allows under 0.001 in all, with nothing inside the hero moving. It measures 0 on this branch and fails on master (0.0054). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(dashboard): guard the hero rotation at the narrow width too The rotation guard ran only at the wide width, before any slide was enriched. It now runs a second unattended rotation after the contrast pass, at the narrow width, where slides wrap the most and every slide carries a rating and a two-line overview. On master's hero that second rotation also fails, because its dots move. measureBackdropTextContrast clipped its screenshot to the range of the element's line boxes. A title cut by the 2-line clamp has line boxes below the visible box, so the probe measured the pill row there and scored about 1.0. The Xtream mock gives the recently-added slide a different title per run, so this failed whenever a long one came up. The clip is now the line boxes intersected with the element's own box. 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
7bd7ff5ac7
commit
f6ad98255e
8 files changed
+241
-65
No files matched your search
@@ -58,18 +58,24 @@
|
||||
<span class="hero__scrim"></span>
|
||||
</div>
|
||||
|
||||
@for (slide of [active]; track slide.id) {
|
||||
<!-- Every slide is laid out, in one grid cell, so the hero is as
|
||||
tall as its tallest slide and a rotation never resizes it (or
|
||||
moves the rails below). Only the active slide is shown; the
|
||||
others are inert and carry no test hooks. -->
|
||||
@for (slide of slides(); track slide.id) {
|
||||
@let current = $index === activeIndex();
|
||||
<div
|
||||
class="hero__content"
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-test-id="dashboard-hero-slide"
|
||||
[attr.data-test-id]="current ? 'dashboard-hero-slide' : null"
|
||||
[attr.inert]="current ? null : ''"
|
||||
[attr.data-hero-kind]="slide.kind"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.DASHBOARD.HERO_SLIDE_POSITION'
|
||||
| translate
|
||||
: {
|
||||
index: activeIndex() + 1,
|
||||
index: $index + 1,
|
||||
count: slides().length,
|
||||
}
|
||||
"
|
||||
@@ -86,7 +92,12 @@
|
||||
|
||||
<h2 class="hero__title">{{ slide.title }}</h2>
|
||||
|
||||
<p class="hero__meta" data-test-id="dashboard-hero-badges">
|
||||
<p
|
||||
class="hero__meta"
|
||||
[attr.data-test-id]="
|
||||
current ? 'dashboard-hero-badges' : null
|
||||
"
|
||||
>
|
||||
@if (slide.episodeBadge) {
|
||||
<app-meta-chip class="hero__pill">{{
|
||||
slide.episodeBadge
|
||||
@@ -127,7 +138,9 @@
|
||||
@if (slide.programmeTitle) {
|
||||
<p
|
||||
class="hero__programme"
|
||||
data-test-id="dashboard-hero-live-epg"
|
||||
[attr.data-test-id]="
|
||||
current ? 'dashboard-hero-live-epg' : null
|
||||
"
|
||||
>
|
||||
{{ slide.programmeTitle }}
|
||||
</p>
|
||||
@@ -162,7 +175,7 @@
|
||||
@let primary = slide.primaryAction;
|
||||
<a
|
||||
class="hero__button hero__button--primary"
|
||||
[attr.data-test-id]="primary.testId"
|
||||
[attr.data-test-id]="current ? primary.testId : null"
|
||||
[routerLink]="primary.link"
|
||||
[state]="primary.state ?? {}"
|
||||
>
|
||||
@@ -182,7 +195,9 @@
|
||||
@if (slide.secondaryAction; as secondary) {
|
||||
<a
|
||||
class="hero__button hero__button--secondary"
|
||||
[attr.data-test-id]="secondary.testId"
|
||||
[attr.data-test-id]="
|
||||
current ? secondary.testId : null
|
||||
"
|
||||
[routerLink]="secondary.link"
|
||||
[state]="secondary.state ?? {}"
|
||||
>
|
||||
|
||||
@@ -47,12 +47,15 @@
|
||||
);
|
||||
|
||||
position: relative;
|
||||
// A floor, not a fixed height: the slide sits in normal flow at the
|
||||
// bottom, so an unusually full slide (two-line title, programme,
|
||||
// synopsis, wrapped pills) grows the banner instead of losing its top.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
// A floor, not a fixed height: every slide sits in normal flow in the
|
||||
// same grid cell at the bottom, so an unusually full slide (two-line
|
||||
// title, programme, synopsis, wrapped pills) grows the banner instead of
|
||||
// losing its top. The banner is as tall as its tallest slide whichever
|
||||
// one is shown: with only the shown slide in flow, each rotation between
|
||||
// slides of different heights resized it and moved every rail below.
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: end;
|
||||
box-sizing: border-box;
|
||||
min-height: clamp(320px, 42vh, 520px);
|
||||
// Flush with the content area's top and sides; the rails page's own
|
||||
@@ -223,6 +226,7 @@
|
||||
|
||||
// ── Slide content ──────────────────────────────────────────────────────────
|
||||
.hero__content {
|
||||
grid-area: 1 / 1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
@@ -237,6 +241,15 @@
|
||||
animation: hero-content-in 420ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||||
}
|
||||
|
||||
// The slides not on screen still size the hero but are never shown or
|
||||
// reached. Dropping their animation replays the entrance each time a slide
|
||||
// becomes the active one. The attribute outranks the narrow layout's
|
||||
// entrance rule, which would otherwise keep it applied.
|
||||
.hero__content[inert] {
|
||||
visibility: hidden;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes hero-content-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -524,12 +537,15 @@
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
// 6px dots inside a 24px hit area (WCAG 2.5.8 target size), 2px apart; the
|
||||
// active one stretches into a pill whose fill runs for one rotation interval.
|
||||
// 6px dots in 26 × 24px hit areas (WCAG 2.5.8 target size), 2px apart; the
|
||||
// active one opens into an 18px pill whose fill runs for one rotation
|
||||
// interval. Every dot keeps its footprint and the pill is the same bar
|
||||
// unclipped: a wider active button, and a pill that grew in width, moved
|
||||
// the dots on every rotation (layout shifts on an idle dashboard).
|
||||
.hero__dot {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
width: 26px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -539,7 +555,7 @@
|
||||
&::before {
|
||||
content: '';
|
||||
grid-area: 1 / 1;
|
||||
width: 6px;
|
||||
width: 18px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(
|
||||
@@ -547,8 +563,10 @@
|
||||
var(--app-on-surface, #e6e1e5) 32%,
|
||||
transparent
|
||||
);
|
||||
// The middle 6px of the bar: a round dot.
|
||||
clip-path: inset(0 6px round 3px);
|
||||
transition:
|
||||
width 0.25s ease,
|
||||
clip-path 0.25s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -566,25 +584,20 @@
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&--active {
|
||||
width: 32px;
|
||||
|
||||
&::before {
|
||||
width: 18px;
|
||||
}
|
||||
&--active::before {
|
||||
clip-path: inset(0 round 3px);
|
||||
}
|
||||
}
|
||||
|
||||
// Over the pill, and shown on the active dot only.
|
||||
.hero__dot-fill {
|
||||
grid-area: 1 / 1;
|
||||
justify-self: start;
|
||||
// Aligns with the 18px pill centred in the 32px button.
|
||||
margin-left: 7px;
|
||||
width: 0;
|
||||
width: 18px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
|
||||
// The fill is a full-width bar that slides in from the left under the
|
||||
// pill's rounded clip. Only `transform` animates, so the compositor runs
|
||||
@@ -600,7 +613,7 @@
|
||||
}
|
||||
|
||||
.hero__dot--active & {
|
||||
width: 18px;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.hero--rotating .hero__dot--active &::before {
|
||||
|
||||
@@ -254,6 +254,62 @@ describe('DashboardHeroComponent', () => {
|
||||
).toBe('/workspace/a');
|
||||
});
|
||||
|
||||
it('lays out every slide but shows and exposes only the active one', () => {
|
||||
// The hero is as tall as its tallest slide only while every slide
|
||||
// stays in flow; re-creating one slide per change resized it.
|
||||
render();
|
||||
const contents = () =>
|
||||
Array.from(host().querySelectorAll<HTMLElement>('.hero__content'));
|
||||
const inert = () => contents().map((el) => el.hasAttribute('inert'));
|
||||
const [first, second] = contents();
|
||||
expect(inert()).toEqual([false, true, true]);
|
||||
const hooks = (testId: string) =>
|
||||
Array.from(host().querySelectorAll(`[data-test-id=${testId}]`));
|
||||
for (const testId of [
|
||||
'dashboard-hero-slide',
|
||||
'dashboard-hero-badges',
|
||||
'dashboard-hero-primary-action',
|
||||
]) {
|
||||
expect(hooks(testId)).toHaveLength(1);
|
||||
expect(first.contains(hooks(testId)[0])).toBe(true);
|
||||
}
|
||||
|
||||
finishActiveDot();
|
||||
|
||||
expect(contents()).toHaveLength(3);
|
||||
expect(contents()[0]).toBe(first);
|
||||
expect(contents()[1]).toBe(second);
|
||||
expect(inert()).toEqual([true, false, true]);
|
||||
expect(second.getAttribute('data-test-id')).toBe(
|
||||
'dashboard-hero-slide'
|
||||
);
|
||||
expect(first.hasAttribute('data-test-id')).toBe(false);
|
||||
});
|
||||
|
||||
it('follows Enter to the primary action of the slide on screen', () => {
|
||||
render();
|
||||
const section = host().querySelector(
|
||||
'[data-test-id=dashboard-hero]'
|
||||
) as HTMLElement;
|
||||
const followed: (string | null)[] = [];
|
||||
host()
|
||||
.querySelectorAll<HTMLElement>('.hero__button--primary')
|
||||
.forEach((button) =>
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
followed.push(button.getAttribute('href'));
|
||||
})
|
||||
);
|
||||
|
||||
dots()[1].click();
|
||||
fixture.detectChanges();
|
||||
section.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
|
||||
);
|
||||
|
||||
expect(followed).toEqual(['/workspace/b']);
|
||||
});
|
||||
|
||||
it('titles each slide with an h2 and leaves the h1 to the page', () => {
|
||||
render();
|
||||
|
||||
@@ -262,8 +318,9 @@ describe('DashboardHeroComponent', () => {
|
||||
});
|
||||
|
||||
it('announces slide changes through one live region that outlives the slides', () => {
|
||||
// A live region inserted together with its text is not announced,
|
||||
// so it must not belong to the slide that the @for re-creates.
|
||||
// A live region inserted together with its text is not announced (a
|
||||
// slide can arrive late) and the slides not on screen are inert, so
|
||||
// it must not belong to a slide.
|
||||
render();
|
||||
const announcement = () =>
|
||||
host().querySelector<HTMLElement>(
|
||||
|
||||
@@ -30,6 +30,8 @@ const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||
*
|
||||
* The active slide is tracked by id, so a slide that arrives late (the live
|
||||
* slide waits for its EPG answer) never yanks the user off the current one.
|
||||
* Every slide is laid out and only the active one is shown, so the hero is
|
||||
* as tall as its tallest slide and a rotation never resizes it.
|
||||
*
|
||||
* The hero is a focusable region: ←/→ switch slides, Enter follows the
|
||||
* primary action. Rotation also pauses while the document is hidden.
|
||||
@@ -194,8 +196,11 @@ export class DashboardHeroComponent {
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
// Every slide is in the DOM; the inactive ones are inert.
|
||||
(event.currentTarget as HTMLElement)
|
||||
.querySelector<HTMLElement>('.hero__button--primary')
|
||||
.querySelector<HTMLElement>(
|
||||
'.hero__content:not([inert]) .hero__button--primary'
|
||||
)
|
||||
?.click();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user