fix(portals): make facet chips keyboard-operable and reject zero years

- The chips were plain spans with a click handler, so the whole feature
  was mouse-only. Actionable chips are <button> now (focusable, Enter and
  Space activate); a year chip that cannot be discovered by stays an
  informational span rather than becoming a disabled button. The button
  chrome is neutralized so they render identically to the chips beside
  them, with a visible focus ring.

- `0000-00-00`, the placeholder providers ship for "no date", read as a
  four-digit year: the chip offered it, and the request then dropped the
  filter because 0 is falsy, so the page answered with unfiltered popular
  titles. `isTmdbYearFacet()` now gates both the chip and the route
  params, so a deep link cannot reach a state the chips refuse to offer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-16 08:45:31 +02:00
1 parent 065437b378
commit 8a7a7ba8b0
11 files changed
+164 -60

No files matched your search

@@ -102,6 +102,17 @@ describe('createDiscoverFacetNavigation', () => {
expect(navigate).not.toHaveBeenCalled();
});
it('refuses the zero-date placeholder providers ship for "no date"', () => {
const discover = create(xtreamMovie);
// '0000-00-00' reads as a four-digit year but filters by nothing
expect(discover.canOpenYear(603, '0000-00-00')).toBe(false);
expect(discover.canOpenYear(603, '0000')).toBe(false);
discover.openYear(603, '0000-00-00');
expect(navigate).not.toHaveBeenCalled();
});
it('does not navigate while the host cannot resolve its playlist', () => {
const discover = create(() => null);
@@ -4,6 +4,7 @@ import {
TmdbCountryFacet,
TmdbGenreFacet,
TmdbMediaType,
isTmdbYearFacet,
} from '@iptvnator/shared/interfaces';
import { discoverLink } from './discover-link.util';
@@ -31,11 +32,13 @@ export interface DiscoverFacetNavigation {
* Year stated by a provider date field, whatever shape it arrives in —
* `1976`, `1999-03-31` and `31-03-1999` all resolve. Reads the first
* four-digit run rather than a fixed slice, which the day-first form
* would otherwise turn into `NaN`.
* would otherwise turn into `NaN`, and rejects the `0000-00-00`
* placeholder rather than offering a chip that filters by nothing.
*/
function facetYear(releaseDate: string | null | undefined): number | null {
const match = releaseDate?.match(/\d{4}/);
return match ? Number(match[0]) : null;
const year = match ? Number(match[0]) : null;
return year !== null && isTmdbYearFacet(year) ? year : null;
}
/**
@@ -16,30 +16,39 @@
}}</span>
}
@if (serial.info.releasedate) {
<span
class="details__tag"
[class.details__tag--clickable]="
discover.canOpenYear(
serial.info.tmdb_id,
serial.info.releasedate
)
"
(click)="
discover.openYear(
serial.info.tmdb_id,
serial.info.releasedate
)
"
>{{ serial.info.releasedate }}</span
>
@if (
discover.canOpenYear(
serial.info.tmdb_id,
serial.info.releasedate
)
) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="
discover.openYear(
serial.info.tmdb_id,
serial.info.releasedate
)
"
>
{{ serial.info.releasedate }}
</button>
} @else {
<span class="details__tag">{{
serial.info.releasedate
}}</span>
}
}
@if (serial.info.tmdb_genres?.length) {
@for (genre of serial.info.tmdb_genres; track genre.id) {
<span
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openGenre(genre)"
>{{ genre.name }}</span
>
{{ genre.name }}
</button>
}
} @else if (serial.info.genre) {
<span class="details__tag">{{ serial.info.genre }}</span>
@@ -59,11 +68,13 @@
country of serial.info.tmdb_countries;
track country.code
) {
<span
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openCountry(country)"
>{{ country.name }}</span
>
{{ country.name }}
</button>
}
}
</ng-template>
@@ -17,22 +17,31 @@
}}</span>
}
@if (info.releaseDate) {
<span
class="details__tag"
[class.details__tag--clickable]="
discover.canOpenYear(info.tmdb_id, info.releaseDate)
"
(click)="discover.openYear(info.tmdb_id, info.releaseDate)"
>{{ info.releaseDate | slice: 0 : 4 }}</span
>
@if (discover.canOpenYear(info.tmdb_id, info.releaseDate)) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="
discover.openYear(info.tmdb_id, info.releaseDate)
"
>
{{ info.releaseDate | slice: 0 : 4 }}
</button>
} @else {
<span class="details__tag">{{
info.releaseDate | slice: 0 : 4
}}</span>
}
}
@if (info.tmdb_genres?.length) {
@for (genre of info.tmdb_genres; track genre.id) {
<span
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openGenre(genre)"
>{{ genre.name }}</span
>
{{ genre.name }}
</button>
}
} @else if (info.genre) {
<span class="details__tag">{{ info.genre }}</span>
@@ -49,11 +58,13 @@
}
@if (info.tmdb_countries?.length) {
@for (country of info.tmdb_countries; track country.code) {
<span
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openCountry(country)"
>{{ country.name }}</span
>
{{ country.name }}
</button>
}
}
</ng-template>
@@ -295,22 +295,31 @@
>
<ng-template appDetailTags>
@if (info.releasedate) {
<span
class="details__tag"
[class.details__tag--clickable]="
discover.canOpenYear(info.tmdb_id, info.releasedate)
"
(click)="discover.openYear(info.tmdb_id, info.releasedate)"
>{{ info.releasedate | slice: 0 : 4 }}</span
>
@if (discover.canOpenYear(info.tmdb_id, info.releasedate)) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="
discover.openYear(info.tmdb_id, info.releasedate)
"
>
{{ info.releasedate | slice: 0 : 4 }}
</button>
} @else {
<span class="details__tag">{{
info.releasedate | slice: 0 : 4
}}</span>
}
}
@if (info.tmdb_genres?.length) {
@for (genre of info.tmdb_genres; track genre.id) {
<span
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openGenre(genre)"
>{{ genre.name }}</span
>
{{ genre.name }}
</button>
}
} @else if (info.genre) {
<span class="details__tag">{{ info.genre }}</span>
@@ -325,11 +334,13 @@
}
@if (info.tmdb_countries?.length) {
@for (country of info.tmdb_countries; track country.code) {
<span
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openCountry(country)"
>{{ country.name }}</span
>
{{ country.name }}
</button>
}
} @else if (info.country) {
<span class="details__tag">{{ info.country }}</span>
@@ -119,6 +119,18 @@ export interface TmdbCountryFacet {
name: string;
}
/**
* Whether a number is a year the Discover page can actually filter by.
*
* Providers ship `0000-00-00` as their "no date" placeholder, which reads
* as a four-digit year and would otherwise produce a `0000` chip that
* filters by nothing. Shared by the chip side and the route-param side so
* a deep link cannot smuggle in what a chip refuses to offer.
*/
export function isTmdbYearFacet(year: number): boolean {
return Number.isInteger(year) && year >= 1000 && year <= 9999;
}
/**
* One cached TMDB lookup. Two kinds of rows share the table, discriminated
* by the `lookupKey` prefix:
@@ -320,8 +320,15 @@
border-color: rgba(255, 215, 0, 0.3);
}
// Rendered as a <button> so the facet is keyboard-operable; the
// UA's own font and appearance have to be neutralized to keep it
// visually identical to the informational chips beside it.
&--clickable {
appearance: none;
cursor: pointer;
font-family: inherit;
letter-spacing: inherit;
line-height: inherit;
transition:
border-color 0.15s ease,
background 0.15s ease;
@@ -330,6 +337,11 @@
background: var(--tag-border);
border-color: var(--text-secondary, rgba(255, 255, 255, 0.5));
}
&:focus-visible {
outline: 2px solid var(--text-primary, #fff);
outline-offset: 2px;
}
}
}
@@ -83,8 +83,15 @@
border-color: rgba(255, 215, 0, 0.3);
}
// Rendered as a <button> so the facet is keyboard-operable; the UA's
// own font and appearance have to be neutralized to keep it visually
// identical to the informational chips beside it.
&--clickable {
appearance: none;
cursor: pointer;
font-family: inherit;
letter-spacing: inherit;
line-height: inherit;
transition:
border-color 0.15s ease,
background 0.15s ease;
@@ -93,6 +100,11 @@
background: var(--tag-border, rgba(255, 255, 255, 0.2));
border-color: var(--text-secondary, rgba(255, 255, 255, 0.5));
}
&:focus-visible {
outline: 2px solid var(--text-primary, #fff);
outline-offset: 2px;
}
}
}
@@ -12,22 +12,27 @@
<!-- Tags: Year, Genre, Duration, Rating -->
<ng-template appDetailTags>
@if (meta.year) {
<span
class="details__tag"
[class.details__tag--clickable]="
discover.canOpenYear(meta.tmdbId, meta.year)
"
(click)="discover.openYear(meta.tmdbId, meta.year)"
>{{ meta.year }}</span
>
@if (discover.canOpenYear(meta.tmdbId, meta.year)) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openYear(meta.tmdbId, meta.year)"
>
{{ meta.year }}
</button>
} @else {
<span class="details__tag">{{ meta.year }}</span>
}
}
@if (meta.tmdbGenres?.length) {
@for (genre of meta.tmdbGenres; track genre.id) {
<span
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openGenre(genre)"
>{{ genre.name }}</span
>
{{ genre.name }}
</button>
}
} @else if (meta.genre) {
<span class="details__tag">{{ meta.genre }}</span>
@@ -42,11 +47,13 @@
}
@if (meta.tmdbCountries?.length) {
@for (country of meta.tmdbCountries; track country.code) {
<span
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openCountry(country)"
>{{ country.name }}</span
>
{{ country.name }}
</button>
}
} @else if (meta.country) {
<span class="details__tag">{{ meta.country }}</span>
@@ -38,6 +38,15 @@ describe('parseDiscoverParams', () => {
expect(parseDiscoverParams({ year: '1990' }).year).toBe(1990);
});
it('rejects a zero year a deep link could smuggle in', () => {
// The chips refuse '0000'; the route must refuse it too, or the
// request drops the filter and returns unfiltered popular titles
expect(parseDiscoverParams({ year: '0000' }).year).toBeNull();
expect(hasDiscoverFacet(parseDiscoverParams({ year: '0000' }))).toBe(
false
);
});
it('drops a genre label without a valid genre id', () => {
const facets = parseDiscoverParams({
genre: 'drama',
@@ -1,4 +1,4 @@
import { TmdbMediaType } from '@iptvnator/shared/interfaces';
import { TmdbMediaType, isTmdbYearFacet } from '@iptvnator/shared/interfaces';
/**
* Facets of a portal Discover page, parsed from route query params.
@@ -30,8 +30,13 @@ export function parseDiscoverParams(params: RawParams): DiscoverRouteParams {
const type: TmdbMediaType =
rawString(params, 'type') === 'tv' ? 'tv' : 'movie';
// `0000` parses as four digits but filters by nothing — a deep link
// must not reach a state the chips themselves refuse to offer
const rawYear = rawString(params, 'year');
const year = rawYear && /^\d{4}$/.test(rawYear) ? Number(rawYear) : null;
const parsedYear =
rawYear && /^\d{4}$/.test(rawYear) ? Number(rawYear) : null;
const year =
parsedYear !== null && isTmdbYearFacet(parsedYear) ? parsedYear : null;
const rawGenre = rawString(params, 'genre');
const parsedGenre = rawGenre && /^\d+$/.test(rawGenre) ? Number(rawGenre) : null;