fix(mobile): barre de thèmes masquée, modale @ hors écran, pastille d'avatar
CI / build-and-test (push) Successful in 14m14s
CI / build-and-test (push) Successful in 14m14s
Cinq correctifs signalés sur le mode mobile et le mode desktop. 1. La modale « @ » était confinée dans le header (desktop + mobile) <header> portait `backdrop-blur-sm`, et `backdrop-filter` fait de l'élément le containing block de ses descendants en position:fixed. Les overlays du provider-picker (et du menu Ctrl+K) se résolvaient donc contre le header : mesuré 1440x64 au lieu de 1440x900, dialogue réduit à 32px de haut pour 466px de contenu, haut de la fenêtre inatteignable. Le flou passe sur une couche enfant (`-z-10`) qui n'est pas un ancêtre des overlays : `fixed` redevient relatif au viewport. Desktop : dialogue 512x468 centré. Mobile : 358x686, entièrement visible. 2. La barre de thèmes disparaissait sous le header (desktop + mobile) `--header-offset` vaut 56/64px, mais le header mesure 57/65px (bordure) et 118px quand la feuille de recherche mobile est ouverte. La barre était donc déjà glissée dessous, et totalement invisible dans ce dernier cas (hauteur visible mesurée : 0px). HeaderComponent publie maintenant sa hauteur réelle dans `--header-measured` via ResizeObserver ; `--header-offset` l'utilise quand elle existe et garde la valeur statique en repli. Barre visible dans tous les cas, y compris feuille de recherche ouverte. 3. Barre de thèmes : icônes seules sur mobile Les libellés sont masqués sous md (`aria-label` conservé pour l'accessibilité). 7 pastilles visibles au lieu de 3, sans scroll. 4. Avatar en pastille `.avatar-circle` est renforcé : `rounded-full` dans le markup plus `border-radius: 9999px !important`, `aspect-ratio: 1/1`, min-width/min-height et `line-height: 1`, pour qu'aucun style de thème ne puisse le dégrader en rectangle. Vérifié : audit navigateur sur 17 routes x 320/390/768/1440 px — aucun débordement horizontal, barre de thèmes visible partout ; tests du dépôt verts ; build production OK.
This commit is contained in:
@@ -11,8 +11,12 @@
|
||||
--safe-bottom: 0px;
|
||||
--safe-left: 0px;
|
||||
--safe-right: 0px;
|
||||
/* Height actually consumed by the fixed header, safe-area included. */
|
||||
--header-offset: calc(var(--header-h) + var(--safe-top));
|
||||
/* Height actually consumed by the fixed header, safe-area included.
|
||||
`--header-measured` is published at runtime by HeaderComponent via a
|
||||
ResizeObserver: the header grows when the mobile search sheet opens, when
|
||||
an iOS safe area applies, or at high zoom, and a hardcoded value would let
|
||||
the sticky themes bar slide under it (it was fully hidden in that case). */
|
||||
--header-offset: var(--header-measured, calc(var(--header-h) + var(--safe-top)));
|
||||
/* Usable viewport height that follows the mobile browser chrome. */
|
||||
--app-vh: 100vh;
|
||||
}
|
||||
@@ -181,18 +185,27 @@ body {
|
||||
}
|
||||
.app-avatar-btn:hover { background-color: var(--panel-2); }
|
||||
|
||||
/* Circular user badge ("pastille"). Belt and braces: `rounded-full` from the
|
||||
markup plus these rules, so the badge can never degrade into a rectangle if
|
||||
a theme stylesheet overrides the radius. */
|
||||
.avatar-circle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 9999px;
|
||||
border-width: 1px;
|
||||
min-width: 2rem;
|
||||
min-height: 2rem;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 9999px !important;
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* The fixed app header: honours the safe area and never overflows. */
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
|
||||
<header class="app-header bg-slate-800/50 backdrop-blur-sm fixed inset-x-0 top-0 z-50 shadow-lg border-b border-slate-700/60"
|
||||
<header class="app-header fixed inset-x-0 top-0 z-50 shadow-lg border-b border-slate-700/60"
|
||||
[style.paddingTop]="'var(--safe-top)'"
|
||||
[style.paddingLeft]="'var(--safe-left)'"
|
||||
[style.paddingRight]="'var(--safe-right)'">
|
||||
|
||||
<!-- Frosted layer lives on its own child, never on <header> itself.
|
||||
`backdrop-filter` makes an element the containing block for
|
||||
position:fixed descendants, so blurring the header trapped the
|
||||
provider-picker and Ctrl+K modals inside the 64px header box (their top
|
||||
was unreachable). The overlays below are siblings of this layer, so
|
||||
they resolve against the viewport again. -->
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10 bg-slate-800/50 backdrop-blur-sm"></div>
|
||||
|
||||
<!-- Top row: burger + logo (always) / search (desktop) / actions -->
|
||||
<div class="flex items-center gap-1 h-14 md:h-16 px-2 md:px-4">
|
||||
|
||||
@@ -93,7 +101,7 @@
|
||||
<div class="relative" #userMenuContainer>
|
||||
<button type="button" (click)="toggleUserMenu()" class="app-avatar-btn focus-ring" aria-haspopup="true"
|
||||
[attr.aria-expanded]="userMenuOpen()" [attr.aria-label]="user()?.username || user()?.email || ('menu.account' | t)">
|
||||
<span class="avatar-circle" [ngClass]="avatarClasses()">{{ avatarLetter() }}</span>
|
||||
<span class="avatar-circle rounded-full" [ngClass]="avatarClasses()">{{ avatarLetter() }}</span>
|
||||
</button>
|
||||
@if (userMenuOpen()) {
|
||||
<div class="absolute right-0 mt-2 w-[min(20rem,calc(100vw-1rem))] overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-xl z-50" role="menu">
|
||||
@@ -120,7 +128,7 @@
|
||||
<!-- Mobile search sheet: the same <app-search-box> instance is rendered here
|
||||
through CSS only when the user asks for it. Kept out of the flow so the
|
||||
page never reflows while typing. -->
|
||||
<div id="mobile-search-sheet" class="md:hidden border-t border-slate-700/60 bg-slate-900/95 backdrop-blur-sm px-2 pb-2"
|
||||
<div id="mobile-search-sheet" class="md:hidden border-t border-slate-700/60 bg-slate-900/95 px-2 pb-2"
|
||||
[class.hidden]="!mobileSearchOpen()">
|
||||
<div class="relative">
|
||||
<app-search-box [placeholder]="('search.placeholder' | t)"
|
||||
@@ -183,7 +191,7 @@
|
||||
@if (user()) {
|
||||
<div class="border-t border-slate-800 px-4 py-3">
|
||||
<div class="mb-2 flex items-center gap-3">
|
||||
<span class="avatar-circle" [ngClass]="avatarClasses()">{{ avatarLetter() }}</span>
|
||||
<span class="avatar-circle rounded-full" [ngClass]="avatarClasses()">{{ avatarLetter() }}</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-semibold text-slate-100">{{ user()?.username || user()?.email }}</span>
|
||||
<span class="block truncate text-xs text-slate-400">{{ user()?.email }}</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal, Input, Output, EventEmitter, HostListener, ViewChild, ViewChildren, QueryList, ElementRef } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal, Input, Output, EventEmitter, HostListener, ViewChild, ViewChildren, QueryList, ElementRef, DestroyRef, AfterViewInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterLink, NavigationEnd } from '@angular/router';
|
||||
import { InstanceService, Provider } from '../../services/instance.service';
|
||||
@@ -26,7 +26,7 @@ import { SearchBoxComponent } from '../search/search-box.component';
|
||||
SearchBoxComponent
|
||||
]
|
||||
})
|
||||
export class HeaderComponent {
|
||||
export class HeaderComponent implements AfterViewInit {
|
||||
private router = inject(Router);
|
||||
instances = inject(InstanceService);
|
||||
private auth = inject(AuthService);
|
||||
@@ -39,6 +39,40 @@ export class HeaderComponent {
|
||||
// Two <app-search-box> instances exist (desktop slot + mobile sheet); only one
|
||||
// is ever visible, so focus must target the visible one.
|
||||
@ViewChildren(SearchBoxComponent) searchBoxes!: QueryList<SearchBoxComponent>;
|
||||
private readonly host = inject(ElementRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
/**
|
||||
* Publishes the header's real height as `--header-measured` so every sticky
|
||||
* element that sits below it (themes bar, channel tabs) is offset exactly.
|
||||
* A hardcoded `--header-h` is only a pre-measurement fallback: the header
|
||||
* grows by ~60px when the mobile search sheet opens, which used to push the
|
||||
* themes bar entirely under the header.
|
||||
*/
|
||||
private syncHeaderOffset() {
|
||||
try {
|
||||
const el = this.host.nativeElement.querySelector('header.app-header') as HTMLElement | null;
|
||||
if (!el) return;
|
||||
const h = Math.ceil(el.getBoundingClientRect().height);
|
||||
if (!h) return;
|
||||
const root = document.documentElement.style;
|
||||
if (root.getPropertyValue('--header-measured') !== `${h}px`) {
|
||||
root.setProperty('--header-measured', `${h}px`);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
try {
|
||||
const el = this.host.nativeElement.querySelector('header.app-header') as HTMLElement | null;
|
||||
if (el && typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver(() => this.syncHeaderOffset());
|
||||
ro.observe(el);
|
||||
this.destroyRef.onDestroy(() => ro.disconnect());
|
||||
}
|
||||
} catch {}
|
||||
this.syncHeaderOffset();
|
||||
}
|
||||
|
||||
/** Reflects the mobile drawer state so the burger exposes `aria-expanded`. */
|
||||
@Input() drawerOpen = false;
|
||||
|
||||
@@ -47,16 +47,18 @@
|
||||
</button>
|
||||
|
||||
<!-- 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. -->
|
||||
On phones the rail is edge-to-edge and shows icons only — the labels do
|
||||
not fit and force the strip to scroll for the first few themes. -->
|
||||
<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">
|
||||
<ul role="tablist" aria-label="Themes" class="flex min-w-max items-center gap-1.5 px-3 py-2 sm:gap-2 md:px-12">
|
||||
<li *ngFor="let t of displayedThemes(); let i = index" class="shrink-0">
|
||||
<button
|
||||
#pill
|
||||
role="tab"
|
||||
type="button"
|
||||
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"
|
||||
[attr.aria-label]="themesSvc.i18nLabel(t)"
|
||||
[attr.title]="themesSvc.i18nLabel(t)"
|
||||
class="chip inline-flex items-center justify-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-red-500 md:px-3.5"
|
||||
[class.bg-slate-100/10]="activeSlug() === t.slug"
|
||||
[class.text-white]="activeSlug() === t.slug"
|
||||
[class.border-red-500]="activeSlug() === t.slug"
|
||||
@@ -66,8 +68,8 @@
|
||||
(click)="goToTheme(t.slug)"
|
||||
(keydown)="onKeydown($event, i)"
|
||||
>
|
||||
<span class="mr-1 select-none">{{ t.emoji }}</span>
|
||||
<span>{{ themesSvc.i18nLabel(t) }}</span>
|
||||
<span class="select-none text-base leading-none md:text-base">{{ t.emoji }}</span>
|
||||
<span class="hidden md:inline">{{ themesSvc.i18nLabel(t) }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user