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,41 +1,41 @@
|
||||
<div class="fixed inset-0 z-[9999] bg-black/60" (click)="close.emit()">
|
||||
<div class="fixed inset-0 grid place-items-center">
|
||||
<div class="fixed inset-0 grid place-items-center p-4" [style.paddingBottom]="'max(1rem, var(--safe-bottom))'">
|
||||
<div #dialog
|
||||
class="w-[90%] max-w-lg bg-slate-900 rounded-xl shadow-2xl border border-slate-700"
|
||||
class="dialog-scroll flex w-full max-w-lg flex-col overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="providersTitle"
|
||||
(click)="$event.stopPropagation()"
|
||||
tabindex="0"
|
||||
(keydown)="onDialogKeydown($event)">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 id="providersTitle" class="text-slate-100 font-semibold">Providers</h2>
|
||||
<button type="button" (click)="close.emit()" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" aria-label="Fermer (Esc)">Esc</button>
|
||||
</div>
|
||||
<div class="p-4 space-y-3">
|
||||
<input type="text" [ngModel]="filter()" (ngModelChange)="filter.set($event)" placeholder="filter providers…" aria-label="Filtrer les fournisseurs" class="w-full px-3 py-2 rounded bg-slate-800 text-slate-100 placeholder-slate-400 border border-slate-700"/>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="inline-flex items-center gap-2 text-slate-200">
|
||||
<input type="checkbox" [checked]="allMode()" (change)="toggleAllMode()"/>
|
||||
<span>All</span>
|
||||
<div class="sticky top-0 z-10 shrink-0 border-b border-slate-700 bg-slate-900 p-4 flex items-center justify-between gap-3">
|
||||
<h2 id="providersTitle" class="text-slate-100 font-semibold">Providers</h2>
|
||||
<button type="button" (click)="close.emit()" class="chip px-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" aria-label="Fermer (Esc)">Esc</button>
|
||||
</div>
|
||||
<div class="p-4 space-y-3">
|
||||
<input type="text" [ngModel]="filter()" (ngModelChange)="filter.set($event)" placeholder="filter providers…" aria-label="Filtrer les fournisseurs" class="w-full min-w-0 px-3 py-2 rounded bg-slate-800 text-slate-100 placeholder-slate-400 border border-slate-700"/>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="inline-flex items-center gap-2 text-slate-200 py-1">
|
||||
<input type="checkbox" [checked]="allMode()" (change)="toggleAllMode()"/>
|
||||
<span>All</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<label *ngFor="let p of providers()" class="flex items-center gap-2 px-3 py-3 rounded border border-slate-700 bg-slate-800 text-slate-100">
|
||||
<input type="checkbox" class="shrink-0" [checked]="isChecked(p.id)" (change)="toggle(p.id)"/>
|
||||
<span class="text-xs font-semibold shrink-0" [class]="p.colorClass">{{ p.shortLabel }}</span>
|
||||
<span class="min-w-0 truncate text-sm">{{ p.displayName }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="mt-2 inline-flex items-center gap-2 text-slate-300 py-1">
|
||||
<input type="checkbox" [ngModel]="rememberDefault()" (ngModelChange)="rememberDefault.set($event)" />
|
||||
<span>Remember as default</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label *ngFor="let p of providers()" class="flex items-center gap-2 px-2 py-2 rounded border border-slate-700 bg-slate-800 text-slate-100">
|
||||
<input type="checkbox" [checked]="isChecked(p.id)" (change)="toggle(p.id)"/>
|
||||
<span class="text-xs font-semibold" [class]="p.colorClass">{{ p.shortLabel }}</span>
|
||||
<span class="text-sm">{{ p.displayName }}</span>
|
||||
</label>
|
||||
<div class="sticky bottom-0 z-10 shrink-0 border-t border-slate-700 bg-slate-900 p-4 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||
<button type="button" (click)="close.emit()" class="px-4 py-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Cancel</button>
|
||||
<button type="button" (click)="onApply()" class="px-4 py-3 rounded bg-red-600 hover:bg-red-500 text-white">Apply filters</button>
|
||||
</div>
|
||||
<label class="mt-2 inline-flex items-center gap-2 text-slate-300">
|
||||
<input type="checkbox" [ngModel]="rememberDefault()" (ngModelChange)="rememberDefault.set($event)" />
|
||||
<span>Remember as default</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="p-4 border-t border-slate-700 flex items-center justify-end gap-2">
|
||||
<button type="button" (click)="close.emit()" class="px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Cancel</button>
|
||||
<button type="button" (click)="onApply()" class="px-3 py-2 rounded bg-red-600 hover:bg-red-500 text-white">Apply filters</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,48 +1,62 @@
|
||||
<form class="w-full relative" novalidate (submit)="onSubmit($event)" role="search" aria-label="Recherche">
|
||||
<!-- Input -->
|
||||
<div class="flex items-center gap-2 rounded-lg ring-1 ring-slate-600/50 bg-slate-800/70 px-3 py-2">
|
||||
<!-- Provider chips -->
|
||||
<button type="button"
|
||||
(click)="toggleAll()"
|
||||
class="text-xs px-2 py-0.5 rounded border"
|
||||
[ngClass]="{
|
||||
'bg-slate-200 text-slate-800 border-slate-300': providerMode()==='all',
|
||||
'bg-slate-700 text-slate-100 border-slate-600': providerMode()!=='all'
|
||||
}"
|
||||
[attr.aria-pressed]="providerMode()==='all'"
|
||||
title="Tous les fournisseurs">
|
||||
All
|
||||
</button>
|
||||
<ng-container *ngFor="let c of chips; let i = index">
|
||||
<button type="button" (click)="toggleChip(c.id)" (keydown.space)="$event.preventDefault(); toggleChip(c.id)" (keydown.enter)="$event.preventDefault(); toggleChip(c.id)" tabindex="0" role="button"
|
||||
class="text-xs px-2 py-0.5 rounded border"
|
||||
<form class="w-full min-w-0 relative" novalidate (submit)="onSubmit($event)" role="search" aria-label="Recherche">
|
||||
<!-- Input row -->
|
||||
<div class="flex items-center gap-2 rounded-lg ring-1 ring-slate-600/50 bg-slate-800/70 px-2 py-1.5 sm:px-3 sm:py-2">
|
||||
<!-- Provider chips: only when there is genuinely room for them (>= lg).
|
||||
On smaller screens the "@" picker below is the equivalent entry point. -->
|
||||
<div class="hidden lg:flex items-center gap-1.5 shrink-0">
|
||||
<button type="button"
|
||||
(click)="toggleAll()"
|
||||
class="chip text-xs px-2.5 rounded border transition-colors"
|
||||
[ngClass]="{
|
||||
'bg-slate-700 text-slate-100 border-slate-600': !(selected().includes(c.id)),
|
||||
'bg-slate-200 text-slate-800 border-slate-300': selected().includes(c.id)
|
||||
'bg-slate-200 text-slate-800 border-slate-300': providerMode()==='all',
|
||||
'bg-slate-700 text-slate-100 border-slate-600': providerMode()!=='all'
|
||||
}"
|
||||
[attr.aria-pressed]="selected().includes(c.id)"
|
||||
[title]="'Inclure ' + c.label + ' (Alt+' + (i + 1) + ')'">
|
||||
<span [class]="c.colorClass">{{ c.label }}</span>
|
||||
[attr.aria-pressed]="providerMode()==='all'"
|
||||
title="Tous les fournisseurs">
|
||||
All
|
||||
</button>
|
||||
</ng-container>
|
||||
<ng-container *ngFor="let c of chips; let i = index">
|
||||
<button type="button" (click)="toggleChip(c.id)" (keydown.space)="$event.preventDefault(); toggleChip(c.id)" (keydown.enter)="$event.preventDefault(); toggleChip(c.id)" tabindex="0" role="button"
|
||||
class="chip text-xs px-2.5 rounded border transition-colors"
|
||||
[ngClass]="{
|
||||
'bg-slate-700 text-slate-100 border-slate-600': !(selected().includes(c.id)),
|
||||
'bg-slate-200 text-slate-800 border-slate-300': selected().includes(c.id)
|
||||
}"
|
||||
[attr.aria-pressed]="selected().includes(c.id)"
|
||||
[title]="'Inclure ' + c.label + ' (Alt+' + (i + 1) + ')'">
|
||||
<span [class]="c.colorClass">{{ c.label }}</span>
|
||||
</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<!-- Text input: combobox pattern for the inline '@' autocomplete -->
|
||||
<input #qInput type="search"
|
||||
class="flex-1 bg-transparent outline-none text-slate-100 placeholder-slate-400 px-2"
|
||||
class="min-w-0 flex-1 bg-transparent outline-none text-slate-100 placeholder-slate-400 px-1 sm:px-2 py-2"
|
||||
[placeholder]="placeholder"
|
||||
[ngModel]="query()" (ngModelChange)="queryUpdate($event)" (keydown)="handleKeydown($event)" (keydown.enter)="onSubmit($event)" (focus)="onSuggestInputFocus()" name="q"
|
||||
autocomplete="off" aria-label="Rechercher des vidéos"
|
||||
autocomplete="off" aria-label="Rechercher des vidéos" enterkeyhint="search"
|
||||
role="combobox" aria-expanded="false" aria-autocomplete="list"
|
||||
[attr.aria-expanded]="atOpen() || suggestionsOpen()"
|
||||
[attr.aria-controls]="atOpen() ? 'atOptionsList' : (suggestionsOpen() ? 'sgOptionsList' : null)"
|
||||
[attr.aria-activedescendant]="atOpen() && atOptions().length > 0 ? 'atOption-' + atIndex() : (suggestionsOpen() && suggestIndex() >= 0 ? 'sgOption-' + suggestIndex() : null)"/>
|
||||
|
||||
<!-- Actions -->
|
||||
<button type="button" (click)="openPicker()" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600" aria-label="Choisir des fournisseurs (@)">@</button>
|
||||
<button type="submit" class="ml-1 px-3 py-1 rounded bg-red-600 hover:bg-red-500 text-white"
|
||||
<!-- Provider picker trigger: shows the active selection as a summary so the
|
||||
user always knows where they are searching. -->
|
||||
<button type="button" (click)="openPicker()"
|
||||
class="chip shrink-0 text-xs font-medium px-2.5 rounded bg-slate-700 text-slate-100 hover:bg-slate-600"
|
||||
aria-haspopup="dialog"
|
||||
[attr.aria-label]="'Fournisseurs : ' + providerSummary()">
|
||||
<span class="max-w-[5.5rem] truncate lg:hidden">{{ providerSummary() }}</span>
|
||||
<span class="hidden lg:inline" aria-hidden="true">@</span>
|
||||
</button>
|
||||
|
||||
<button type="submit" class="chip ml-auto shrink-0 inline-flex items-center gap-1.5 rounded bg-red-600 px-3 text-sm text-white hover:bg-red-500"
|
||||
(click)="onSubmit($event)"
|
||||
[attr.title]="(query() || '').trim().length < 2 ? 'Tapez au moins 2 caractères' : null">
|
||||
Search
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 lg:hidden" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5Zm-6 0A4.5 4.5 0 1 1 14 9.5 4.49 4.49 0 0 1 9.5 14Z"/>
|
||||
</svg>
|
||||
<span class="hidden lg:inline">Search</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -55,16 +69,16 @@
|
||||
|
||||
<!-- Inline @autocomplete popover -->
|
||||
<div *ngIf="atOpen() && atOptions().length > 0"
|
||||
class="absolute z-50 mt-1 left-3 right-3 rounded-lg shadow-xl overflow-hidden"
|
||||
class="absolute z-50 mt-1 left-0 right-0 max-h-[60dvh] overflow-y-auto rounded-lg shadow-xl"
|
||||
style="top: 100%">
|
||||
<ul id="atOptionsList" role="listbox" aria-label="Fournisseurs"
|
||||
class="max-h-64 overflow-y-auto"
|
||||
class="max-h-[60dvh] overflow-y-auto"
|
||||
[ngClass]="{ 'bg-slate-900 border border-slate-700 text-slate-200': true }">
|
||||
<li *ngFor="let opt of atOptions(); let i = index" role="option"
|
||||
[attr.aria-selected]="i === atIndex()"
|
||||
[id]="'atOption-' + i">
|
||||
<button type="button" (click)="applyAtOption(opt.id)"
|
||||
class="w-full text-left px-3 py-2 text-sm"
|
||||
class="w-full text-left px-3 py-3 text-sm"
|
||||
[ngClass]="{
|
||||
'bg-slate-800': i === atIndex(),
|
||||
'hover:bg-slate-800': i !== atIndex()
|
||||
@@ -77,43 +91,39 @@
|
||||
|
||||
<!-- Step 15: query typeahead panel (hidden while the @ popover is open) -->
|
||||
<div *ngIf="suggestionsOpen() && !atOpen() && suggestFlat().length > 0"
|
||||
class="absolute z-40 mt-1 left-3 right-3 rounded-lg shadow-xl overflow-hidden bg-slate-900 border border-slate-700 text-slate-200"
|
||||
class="absolute z-40 mt-1 left-0 right-0 max-h-[60dvh] overflow-y-auto rounded-lg shadow-xl bg-slate-900 border border-slate-700 text-slate-200"
|
||||
style="top: 100%">
|
||||
<ul id="sgOptionsList" role="listbox" aria-label="Suggestions de recherche"
|
||||
class="max-h-72 overflow-y-auto py-1">
|
||||
class="max-h-[60dvh] overflow-y-auto py-1">
|
||||
<li *ngFor="let item of suggestFlat(); let i = index" role="option"
|
||||
[attr.aria-selected]="i === suggestIndex()"
|
||||
[id]="'sgOption-' + i">
|
||||
<button type="button" (click)="applySuggestion(item.text, true)"
|
||||
(mouseenter)="suggestIndex.set(i)"
|
||||
class="w-full flex items-center gap-2 text-left px-3 py-2 text-sm"
|
||||
[ngClass]="{
|
||||
'bg-slate-800': i === suggestIndex(),
|
||||
'hover:bg-slate-800': i !== suggestIndex()
|
||||
}">
|
||||
<span *ngIf="!item.provider" class="text-slate-500" title="Recherche récente">🕘</span>
|
||||
<span *ngIf="item.provider" class="text-[10px] font-bold px-1.5 py-0.5 rounded bg-slate-700 text-slate-300">{{ providerBadge(item.provider) }}</span>
|
||||
<span class="flex-1 truncate" [title]="item.text"><span>{{ suggestHighlight(item.text).pre }}</span><mark *ngIf="suggestHighlight(item.text).match" class="bg-transparent text-inherit font-semibold underline decoration-red-500">{{ suggestHighlight(item.text).match }}</mark><span>{{ suggestHighlight(item.text).post }}</span></span>
|
||||
class="w-full flex items-center gap-2 text-left px-3 py-3 text-sm">
|
||||
<span *ngIf="!item.provider" class="text-slate-500 shrink-0" title="Recherche récente">🕘</span>
|
||||
<span *ngIf="item.provider" class="shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded bg-slate-700 text-slate-300">{{ providerBadge(item.provider) }}</span>
|
||||
<span class="min-w-0 flex-1 truncate" [title]="item.text"><span>{{ suggestHighlight(item.text).pre }}</span><mark *ngIf="suggestHighlight(item.text).match" class="bg-transparent text-inherit font-semibold underline decoration-red-500">{{ suggestHighlight(item.text).match }}</mark><span>{{ suggestHighlight(item.text).post }}</span></span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div *ngIf="suggestLoading()" class="px-3 py-1 text-xs text-slate-500 border-t border-slate-800">Chargement…</div>
|
||||
<div *ngIf="suggestLoading()" class="px-3 py-2 text-xs text-slate-500 border-t border-slate-800">Chargement…</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Menu (Ctrl/Cmd+K) -->
|
||||
<div *ngIf="quickOpen()" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; background-color: rgba(0, 0, 0, 0.6);" role="presentation" (click)="closeQuickMenu()">
|
||||
<div #quickDialog style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -0%); width: 90%; max-width: 56rem; max-height: 85vh; border-radius: 0.75rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; background-color: #0f172a; border: 1px solid #334155; display: flex; flex-direction: column;" role="dialog" aria-modal="true" aria-labelledby="quickMenuTitle" (click)="$event.stopPropagation()" (keydown)="onQuickMenuKeydown($event)">
|
||||
<div class="p-3 border-b border-slate-700 flex items-center justify-between">
|
||||
<div *ngIf="quickOpen()" class="quick-menu-backdrop" role="presentation" (click)="closeQuickMenu()">
|
||||
<div #quickDialog class="quick-menu-dialog" role="dialog" aria-modal="true" aria-labelledby="quickMenuTitle" (click)="$event.stopPropagation()" (keydown)="onQuickMenuKeydown($event)">
|
||||
<div class="p-3 border-b border-slate-700 flex items-center justify-between gap-3">
|
||||
<h2 id="quickMenuTitle" class="text-slate-100 font-semibold">Quick Menu</h2>
|
||||
<button type="button" (click)="closeQuickMenu()" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" aria-label="Fermer le menu (Esc)">Esc</button>
|
||||
<button type="button" (click)="closeQuickMenu()" class="chip px-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" aria-label="Fermer le menu (Esc)">Esc</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-0 overflow-y-auto">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 overflow-y-auto min-h-0 flex-1">
|
||||
<!-- History column -->
|
||||
<div class="p-4 border-r border-slate-700">
|
||||
<div class="p-4 md:border-r md:border-slate-700">
|
||||
<div class="text-slate-300 text-sm mb-2">History</div>
|
||||
<ul class="space-y-1 max-h-80 overflow-y-auto">
|
||||
<ul class="space-y-1 max-h-64 overflow-y-auto">
|
||||
<li *ngFor="let it of recentSearches()">
|
||||
<button type="button" class="w-full text-left px-2 py-1 rounded hover:bg-slate-800 text-slate-200"
|
||||
<button type="button" class="w-full text-left px-3 py-3 rounded hover:bg-slate-800 text-slate-200 break-anywhere"
|
||||
(click)="queryUpdate(it.query || ''); closeQuickMenu()">{{ it.query }}</button>
|
||||
</li>
|
||||
<li *ngIf="(recentSearches()?.length || 0) === 0" class="text-slate-500 text-sm">No recent searches.</li>
|
||||
@@ -121,20 +131,19 @@
|
||||
</div>
|
||||
<!-- Providers column -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center justify-between gap-3 mb-2">
|
||||
<div class="text-slate-300 text-sm">Providers</div>
|
||||
<button type="button" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100"
|
||||
<button type="button" class="chip text-xs px-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100"
|
||||
(click)="quickToggleAll()">All</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<label *ngFor="let c of chips" class="flex items-center gap-2 px-2 py-2 rounded border border-slate-700 bg-slate-800 text-slate-100">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<label *ngFor="let c of chips" class="flex items-center gap-2 px-3 py-3 rounded border border-slate-700 bg-slate-800 text-slate-100">
|
||||
<input type="checkbox" [checked]="selected().includes(c.id)" (change)="quickToggle(c.id)"/>
|
||||
<span class="text-xs font-semibold" [class]="c.colorClass">{{ c.label }}</span>
|
||||
<span class="text-sm">{{ c.label }}</span>
|
||||
<span class="text-sm truncate" [class]="c.colorClass">{{ c.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4 text-right">
|
||||
<button type="button" class="px-3 py-2 rounded bg-red-600 hover:bg-red-500 text-white"
|
||||
<button type="button" class="w-full sm:w-auto px-4 py-3 rounded bg-red-600 hover:bg-red-500 text-white"
|
||||
(click)="applyQuickMenu({ selection: selected().length ? selected() : 'all', rememberDefault: false })">Apply filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,12 @@ export interface SuggestFlatItem {
|
||||
templateUrl: './search-box.component.html'
|
||||
})
|
||||
export class SearchBoxComponent {
|
||||
/**
|
||||
* Host element, so a parent can tell whether this instance is the visible one.
|
||||
* Optional: unit tests instantiate the component without a host element.
|
||||
*/
|
||||
readonly el = inject(ElementRef, { optional: true });
|
||||
|
||||
private search = inject(SearchService);
|
||||
private users = inject(UserService);
|
||||
private history = inject(HistoryService);
|
||||
@@ -130,11 +136,35 @@ export class SearchBoxComponent {
|
||||
|
||||
readonly chips = PROVIDERS.map(p => ({ id: p.id, label: p.shortLabel, colorClass: p.colorClass }));
|
||||
|
||||
/**
|
||||
* Focus the query input programmatically (Ctrl/Cmd+K, `/`, mobile search
|
||||
* sheet). Exposed so the parent header does not have to reach into the DOM.
|
||||
*/
|
||||
focusInput(): HTMLInputElement | undefined {
|
||||
try { return this.qInputRef?.nativeElement; } catch { return undefined; }
|
||||
}
|
||||
|
||||
// Derived state
|
||||
readonly providersForRequest = computed<ProviderId[] | 'all'>(() => {
|
||||
return this.providerMode() === 'all' ? 'all' : (this.selected().length ? this.selected() : 'all');
|
||||
});
|
||||
|
||||
/**
|
||||
* Short human label for the current provider selection. On phones the chip
|
||||
* row is hidden, so this label is the only feedback the user gets about
|
||||
* where the search will be routed.
|
||||
*/
|
||||
readonly providerSummary = computed<string>(() => {
|
||||
if (this.providerMode() === 'all') return 'All';
|
||||
const sel = this.selected();
|
||||
if (!sel.length) return 'All';
|
||||
if (sel.length === 1) {
|
||||
const found = PROVIDERS.find((p) => p.id === sel[0]);
|
||||
return found ? found.shortLabel : String(sel[0]).toUpperCase();
|
||||
}
|
||||
return `${sel.length} src`;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Flag to avoid clobbering user input after initial hydration from SearchService
|
||||
let hydratedFromService = false;
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
}
|
||||
|
||||
@if (hasQuery() && availableTags().length > 0) {
|
||||
<div class="mb-4 flex items-center gap-2 overflow-x-auto pb-1">
|
||||
<div class="scroll-rail no-scrollbar mb-4 flex items-center gap-2 pb-1">
|
||||
@for (tag of availableTags(); track tag.key) {
|
||||
<button
|
||||
class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="{
|
||||
'bg-slate-200 text-slate-900 border-slate-200': filterTag() === tag.key,
|
||||
'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700': filterTag() !== tag.key
|
||||
@@ -43,35 +43,35 @@
|
||||
|
||||
@if (hasQuery()) {
|
||||
<!-- Pills de type façon directory Twitch : Directs / Vidéos / Clips / Chaînes -->
|
||||
<div class="flex items-center gap-2 overflow-x-auto pb-1" role="tablist" aria-label="Filtrer par type de contenu">
|
||||
<button class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
<div class="scroll-rail no-scrollbar flex items-center gap-2 pb-1" role="tablist" aria-label="Filtrer par type de contenu">
|
||||
<button class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'all' ? 'bg-slate-200 text-slate-900 border-slate-200' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'all'" (click)="setTypePill('all')">
|
||||
Tout ({{ typeCounts().all }})
|
||||
</button>
|
||||
<button *ngIf="typeCounts().live > 0 || typePill() === 'live'" class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
<button *ngIf="typeCounts().live > 0 || typePill() === 'live'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'live' ? 'bg-purple-600 text-white border-purple-500' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'live'" (click)="setTypePill('live')">
|
||||
Directs ({{ typeCounts().live }})
|
||||
</button>
|
||||
<button *ngIf="typeCounts().video > 0 || typePill() === 'video'" class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
<button *ngIf="typeCounts().video > 0 || typePill() === 'video'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'video' ? 'bg-slate-200 text-slate-900 border-slate-200' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'video'" (click)="setTypePill('video')">
|
||||
Vidéos ({{ typeCounts().video }})
|
||||
</button>
|
||||
<button *ngIf="typeCounts().clip > 0 || typePill() === 'short'" class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
<button *ngIf="typeCounts().clip > 0 || typePill() === 'short'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'short' ? 'bg-red-600 text-white border-red-500' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'short'" (click)="setTypePill('short')">
|
||||
Clips ({{ typeCounts().clip }})
|
||||
</button>
|
||||
<button *ngIf="typeCounts().channel > 0 || typePill() === 'channel'" class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
<button *ngIf="typeCounts().channel > 0 || typePill() === 'channel'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'channel' ? 'bg-slate-200 text-slate-900 border-slate-200' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'channel'" (click)="setTypePill('channel')">
|
||||
Chaînes ({{ typeCounts().channel }})
|
||||
</button>
|
||||
</div>
|
||||
<!-- Barre de filtrage (même UX que la page thème / fournisseur) -->
|
||||
<div class="bg-slate-800/50 p-4 rounded-xl border border-slate-700 my-4">
|
||||
<div class="rounded-xl border border-slate-700 bg-slate-800/50 p-4 my-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div>
|
||||
<label for="sortSelect" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.sort' | t }}</label>
|
||||
|
||||
Reference in New Issue
Block a user