feat(mobile): refonte responsive complète (header, drawer, dvh, cibles tactiles)
Le header était en grid-cols-[auto_1fr_auto] avec une barre de recherche non-wrappable (7 chips + input + 2 boutons, aucun min-w-0) : ~1000px de largeur intrinsèque dans un viewport de 375px. Toutes les routes débordaient horizontalement et les boutons Connexion/Inscription partaient hors écran. Socle - --header-h / --header-offset / --app-vh centralisent la métrique du header : supprime les top-16 codés en dur (channel-tabs était en top-0 et glissait sous le header au scroll). - Safe-area iOS, viewport-fit=cover, color-scheme/theme-color. - dvh partout (100vh > hauteur visible sur iOS), overflow-x: clip au lieu de hidden (hidden cassait silencieusement tous les sticky). - Cible tactile 44px sur pointeur grossier, skip link, focus-ring clavier, scroll-rail pour les tab strips. Header - Barre mobile (burger, logo, recherche, menu ⋮) + barre desktop inchangée. - Le menu ⋮ expose ce qui disparaissait : bande des thèmes, préférences, historique, favoris, téléchargements, thème, connexion/inscription. - Recherche en feuille sous le header avec focus automatique. - Échap / clic extérieur / navigation ferment les panneaux transitoires. Drawer - Partait à inset-y-0 : les 56 premiers px du menu étaient derrière le header. Descend sous le header, bouton fermer intégré, verrouille le scroll, se ferme à Échap. Barre de recherche - Chips masquées sous lg, remplacées par un bouton résumé qui ouvre le sélecteur de fournisseurs. Suggestions en dvh, menu Ctrl+K plein écran sur mobile. Pages - Shorts : cadre 9:16 contraint par dvh ET vw (il débordait en paysage), Préc./Suiv. qui étaient hors du conteneur height-capped. - Watch : barre d'actions qui wrappe, mode cinéma en opacity-0 group-hover donc invisible au doigt, thumbnails fluides, playsinline sur les iframes. - Historique, thèmes, chaîne, playlists, préférences, sessions : rails swipables, min-w-0, truncate, en-têtes qui wrappent. Vérifié : audit navigateur (CDP) sur 17 routes x 7 largeurs (320→1440), zéro débordement horizontal ; 19 tests d'interaction mobile ; suites de tests du dépôt vertes (api 40/40, transcript 27/27, search, suggest, playlists, preferences, subscriptions, highlight, telemetry).
This commit is contained in:
@@ -1,23 +1,26 @@
|
||||
<div class="container mx-auto p-4 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3 text-slate-200">
|
||||
<a [routerLink]="['/t', theme()]" class="px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<!-- Stacks below `sm`: title + provider picker side by side cannot fit a phone. -->
|
||||
<!-- Wraps at `lg`: between md and lg the desktop sidebar leaves too little
|
||||
room for the title and the provider picker to share a row. -->
|
||||
<div class="mb-6 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-3 text-slate-200">
|
||||
<a [routerLink]="['/t', theme()]" aria-label="Retour aux thèmes" class="chip shrink-0 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 transition-colors hover:bg-slate-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold">
|
||||
<span class="text-4xl mr-2">{{ themes.bySlug(theme())?.emoji }}</span>
|
||||
{{ themes.bySlug(theme())?.label }}
|
||||
<h1 class="min-w-0 text-2xl font-bold sm:text-3xl">
|
||||
<span class="mr-2 text-3xl sm:text-4xl">{{ themes.bySlug(theme())?.emoji }}</span>
|
||||
<span class="break-anywhere">{{ themes.bySlug(theme())?.label }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm font-medium text-slate-400">{{ 'themes.changeProvider' | t }}</label>
|
||||
<div class="relative">
|
||||
<select [ngModel]="provider()" (ngModelChange)="changeProvider($event)" class="appearance-none bg-slate-800 text-white rounded-md px-3 py-2 pr-8 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all">
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<label for="providerSel" class="text-sm font-medium text-slate-400">{{ 'themes.changeProvider' | t }}</label>
|
||||
<div class="relative min-w-0 flex-1 sm:flex-none">
|
||||
<select id="providerSel" [ngModel]="provider()" (ngModelChange)="changeProvider($event)" class="w-full appearance-none rounded-md border border-slate-700 bg-slate-800 px-3 py-2 pr-9 text-white transition-all focus:outline-none focus:ring-2 focus:ring-blue-500 sm:w-auto">
|
||||
<option *ngFor="let p of providersList()" [value]="p.id">{{ p.label }}</option>
|
||||
</select>
|
||||
<svg class="w-5 h-5 absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<svg class="pointer-events-none absolute right-2.5 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -25,19 +28,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Filtre rapide Vidéos / Shorts (masqué sur Twitch : onglets à sections ci-dessous) -->
|
||||
<div *ngIf="provider() !== 'twitch'" class="flex items-center gap-2 mb-4" role="tablist" aria-label="Filtrer vidéos ou shorts">
|
||||
<div *ngIf="provider() !== 'twitch'" class="scroll-rail no-scrollbar mb-4 flex items-center gap-2" role="tablist" aria-label="Filtrer vidéos ou shorts">
|
||||
<button (click)="setContentFilter('all')" [attr.aria-pressed]="contentFilter() === 'all'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
|
||||
[class]="contentFilter() === 'all' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Tout
|
||||
</button>
|
||||
<button (click)="setContentFilter('videos')" [attr.aria-pressed]="contentFilter() === 'videos'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
|
||||
[class]="contentFilter() === 'videos' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Vidéos
|
||||
</button>
|
||||
<button (click)="setContentFilter('shorts')" [attr.aria-pressed]="contentFilter() === 'shorts'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
|
||||
[class]="contentFilter() === 'shorts' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Shorts<span *ngIf="allVideos().length"> ({{ shortsCount() }})</span>
|
||||
</button>
|
||||
@@ -52,29 +55,29 @@
|
||||
<div class="font-semibold text-slate-100">{{ twitchGame()?.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 overflow-x-auto pb-1" role="tablist" aria-label="Sections Twitch">
|
||||
<div class="scroll-rail no-scrollbar flex items-center gap-2 pb-1" role="tablist" aria-label="Sections Twitch">
|
||||
<button (click)="setTwitchTab('all')" [attr.aria-pressed]="twitchTab() === 'all'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
[class]="twitchTab() === 'all' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Tout
|
||||
</button>
|
||||
<button (click)="setTwitchTab('live')" [attr.aria-pressed]="twitchTab() === 'live'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
[class]="twitchTab() === 'live' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
En direct<span *ngIf="twitchLives().length"> ({{ twitchLives().length }})</span>
|
||||
</button>
|
||||
<button (click)="setTwitchTab('video')" [attr.aria-pressed]="twitchTab() === 'video'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
[class]="twitchTab() === 'video' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Vidéos<span *ngIf="twitchVods().length"> ({{ twitchVods().length }})</span>
|
||||
</button>
|
||||
<button (click)="setTwitchTab('clip')" [attr.aria-pressed]="twitchTab() === 'clip'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
[class]="twitchTab() === 'clip' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Clips<span *ngIf="twitchClips().length"> ({{ twitchClips().length }})</span>
|
||||
</button>
|
||||
<button (click)="setTwitchTab('channel')" [attr.aria-pressed]="twitchTab() === 'channel'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
[class]="twitchTab() === 'channel' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Chaînes<span *ngIf="twitchChannels().length"> ({{ twitchChannels().length }})</span>
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="container mx-auto p-4 sm:p-6">
|
||||
<h2 class="text-3xl font-bold mb-4 text-slate-100 border-l-4 border-red-500 pl-4 flex items-center gap-2">
|
||||
<h2 class="mb-4 flex items-center gap-2 border-l-4 border-red-500 pl-4 text-2xl font-bold text-slate-100 sm:text-3xl">
|
||||
<span>{{ themeLabel() }}</span>
|
||||
</h2>
|
||||
|
||||
|
||||
@@ -1,53 +1,62 @@
|
||||
@if (collapsed()) {
|
||||
<!-- Barre repliée : fine, rouvrable à tout moment -->
|
||||
<div class="sticky top-16 left-0 right-0 z-40 flex w-full items-center gap-2 border-b border-slate-700/60 bg-slate-800/60 px-3 py-1 backdrop-blur-sm">
|
||||
<span class="text-xs font-medium text-slate-400">{{ 'themes.title' | t : 'Thèmes' }}</span>
|
||||
@if (activeThemeLabel(); as label) {
|
||||
<span class="rounded-full border border-red-500/60 bg-slate-100/10 px-2 py-0.5 text-xs text-white">{{ label }}</span>
|
||||
}
|
||||
<div class="sticky z-40 flex w-full items-center gap-2 border-b border-slate-700/60 bg-slate-800/60 px-3 py-1 backdrop-blur-sm"
|
||||
[style.top]="'var(--header-offset)'"
|
||||
[style.paddingLeft]="'max(0.75rem, var(--safe-left))'"
|
||||
[style.paddingRight]="'max(0.75rem, var(--safe-right))'">
|
||||
<span class="shrink-0 text-xs font-medium text-slate-400">{{ 'themes.title' | t : 'Thèmes' }}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-xs text-slate-500">
|
||||
@if (activeThemeLabel(); as label) {
|
||||
<span class="inline-block max-w-full truncate rounded-full border border-red-500/60 bg-slate-100/10 px-2 py-0.5 text-slate-200">{{ label }}</span>
|
||||
}
|
||||
</span>
|
||||
<button type="button" (click)="toggleCollapsed()" [attr.aria-expanded]="false" aria-controls="themes-bar"
|
||||
[title]="'themes.show' | t : 'Afficher la bande des thèmes'"
|
||||
class="ml-auto inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-semibold text-slate-300 transition hover:bg-slate-700 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500">
|
||||
class="chip ml-auto inline-flex shrink-0 items-center gap-1 rounded-full px-3 text-xs font-semibold text-slate-300 transition hover:bg-slate-700 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500">
|
||||
<span>{{ 'themes.show' | t : 'Afficher' }}</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<nav id="themes-bar" class="sticky top-16 left-0 right-0 z-40 w-full relative"
|
||||
<nav id="themes-bar" class="sticky z-40 w-full relative"
|
||||
[style.top]="'var(--header-offset)'"
|
||||
[ngClass]="{
|
||||
'bg-slate-800 border-b border-slate-700': isDarkTheme(),
|
||||
'bg-slate-900 border-b border-slate-800': isBlackTheme(),
|
||||
'bg-slate-700 border-b border-slate-600': isBlueTheme()
|
||||
'bg-slate-700 border-b border-slate-600': isBlueTheme(),
|
||||
'bg-slate-50 border-b border-slate-200': isLightTheme()
|
||||
}">
|
||||
<!-- Left scroll button -->
|
||||
<button type="button" (click)="scrollBy(-320)" aria-label="Scroll left"
|
||||
<!-- Left scroll button: pointer devices only. Touch users swipe. -->
|
||||
<button type="button" (click)="scrollBy(-320)" aria-label="Scroll left" tabindex="-1"
|
||||
class="hidden md:flex items-center justify-center absolute left-2 top-1/2 -translate-y-1/2 h-9 w-9 rounded-full shadow z-10"
|
||||
[ngClass]="{
|
||||
'bg-white/90 border border-slate-300 text-slate-700 hover:bg-slate-100': isLightTheme(),
|
||||
'bg-slate-800/90 border border-slate-700 text-slate-200 hover:bg-slate-700': isDarkTheme() || isBlackTheme() || isBlueTheme()
|
||||
'bg-slate-800/90 border border-slate-700 text-slate-200 hover:bg-slate-700': !isLightTheme()
|
||||
}">
|
||||
«
|
||||
</button>
|
||||
|
||||
<!-- Right scroll button -->
|
||||
<button type="button" (click)="scrollBy(320)" aria-label="Scroll right"
|
||||
<!-- Right scroll button: pointer devices only. Touch users swipe. -->
|
||||
<button type="button" (click)="scrollBy(320)" aria-label="Scroll right" tabindex="-1"
|
||||
class="hidden md:flex items-center justify-center absolute right-2 top-1/2 -translate-y-1/2 h-9 w-9 rounded-full shadow z-10"
|
||||
[ngClass]="{
|
||||
'bg-white/90 border border-slate-300 text-slate-700 hover:bg-slate-100': isLightTheme(),
|
||||
'bg-slate-800/90 border border-slate-700 text-slate-200 hover:bg-slate-700': isDarkTheme() || isBlackTheme() || isBlueTheme()
|
||||
'bg-slate-800/90 border border-slate-700 text-slate-200 hover:bg-slate-700': !isLightTheme()
|
||||
}">
|
||||
»
|
||||
</button>
|
||||
|
||||
<!-- Replier la bande : action disponible uniquement dans le header -->
|
||||
<div class="w-full overflow-x-auto no-scrollbar" #scroll (wheel)="onWheel($event)">
|
||||
<ul role="tablist" aria-label="Themes" class="flex gap-2 px-12 py-2 min-w-max items-center">
|
||||
<!-- Replier la bande : action disponible uniquement dans le header.
|
||||
On phones the rail is edge-to-edge with a swipe affordance instead of
|
||||
the reserved arrow gutters. -->
|
||||
<div class="scroll-rail no-scrollbar md:overflow-x-auto" #scroll (wheel)="onWheel($event)">
|
||||
<ul role="tablist" aria-label="Themes" class="flex min-w-max items-center gap-2 px-3 py-2 md:px-12">
|
||||
<li *ngFor="let t of displayedThemes(); let i = index" class="shrink-0">
|
||||
<button
|
||||
#pill
|
||||
role="tab"
|
||||
type="button"
|
||||
class="px-3 py-1.5 rounded-full text-sm font-medium border transition-colors whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
class="chip px-3 py-1.5 rounded-full text-sm font-medium border transition-colors whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
[class.bg-slate-100/10]="activeSlug() === t.slug"
|
||||
[class.text-white]="activeSlug() === t.slug"
|
||||
[class.border-red-500]="activeSlug() === t.slug"
|
||||
@@ -63,5 +72,14 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Scroll affordance: the rail hides its scrollbar, so fade the trailing
|
||||
edge to signal that more themes exist to the right. -->
|
||||
<span class="pointer-events-none absolute inset-y-0 right-0 w-10"
|
||||
aria-hidden="true"
|
||||
[ngClass]="{
|
||||
'bg-gradient-to-l from-slate-800 to-transparent': !isLightTheme(),
|
||||
'bg-gradient-to-l from-slate-50 to-transparent': isLightTheme()
|
||||
}"></span>
|
||||
</nav>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user