fix(portal-shell): dark mode actually applies to component-scoped surfaces #87

Merged
julien merged 1 commits from fix/portal-shell/dark-mode-scoping into main 2026-05-11 10:45:40 +02:00
4 changed files with 64 additions and 32 deletions
+12 -4
View File
@@ -3,6 +3,14 @@
// owns its own scrolling (long menus don't push the header off-screen);
// the main pane scrolls independently so opening a long page never
// scrolls the sidebar out of view.
//
// Dark mode goes through `:host-context(.dark)` — the `.dark` class
// sits on <html>, outside this component's view encapsulation, so a
// nested `:where(.dark) &` would compile to a selector with the
// `_ngcontent` attribute appended to `.dark` itself, which <html>
// doesn't carry. `:host-context()` is the Angular-canonical escape
// hatch that walks the DOM up from the host and matches without
// applying the scoping attribute to the ancestor selector.
:host {
display: flex;
flex-direction: column;
@@ -22,11 +30,11 @@
overflow-y: auto;
background-color: #f9fafb;
color: #111827;
}
:where(.dark) & {
background-color: #0b1220;
color: #e5e7eb;
}
:host-context(.dark) .shell-main {
background-color: #0b1220;
color: #e5e7eb;
}
// Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully
@@ -5,6 +5,10 @@
// change, update both files together — extracting a shared SCSS
// variable would be the right move only if a third surface needs the
// same coupling.
//
// No vertical divider between the logo zone and the rest of the
// header on purpose — the alignment carries the visual relationship
// to the sidebar rail below; a second hairline would only add noise.
.header__logo-zone {
display: flex;
align-items: center;
@@ -12,13 +16,8 @@
width: 16rem;
height: 100%;
padding: 0 0.75rem;
border-right: 1px solid #e5e7eb;
transition: width 0.18s ease-out;
:where(.dark) & {
border-right-color: #1f2937;
}
&--collapsed {
width: 4rem;
padding: 0 0.5rem;
@@ -4,6 +4,12 @@
// room on each side. The transition is short and animation-friendly —
// users with `prefers-reduced-motion: reduce` get the no-animation
// branch below.
//
// Dark mode rules sit OUTSIDE the nested blocks and key off
// `:host-context(.dark)` — `.dark` lives on <html>, outside this
// component's encapsulation, and a nested `:where(.dark) &` would be
// rewritten by Angular's emulated CSS scoping into a selector that
// no longer matches <html>.
.sidebar {
width: 16rem;
transition: width 0.18s ease-out;
@@ -44,27 +50,14 @@
background-color 0.12s ease-out,
color 0.12s ease-out;
:where(.dark) & {
color: #e5e7eb;
}
&:hover {
background-color: #f3f4f6;
color: var(--color-brand-primary-500);
:where(.dark) & {
background-color: #1f2937;
color: var(--color-brand-primary-300);
}
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500);
outline-offset: 2px;
:where(.dark) & {
outline-color: var(--color-brand-primary-300);
}
}
}
@@ -76,15 +69,6 @@
background-color: var(--color-brand-primary-600);
color: #fff;
}
:where(.dark) & {
background-color: var(--color-brand-primary-400);
&:hover {
background-color: var(--color-brand-primary-300);
color: #0b1620;
}
}
}
.sidebar__label {
@@ -101,3 +85,30 @@
padding-right: 0.5rem;
}
}
// --- Dark mode overrides --------------------------------------------------
:host-context(.dark) {
.sidebar__link,
.sidebar__toggle {
color: #e5e7eb;
&:hover {
background-color: #1f2937;
color: var(--color-brand-primary-300);
}
&:focus-visible {
outline-color: var(--color-brand-primary-300);
}
}
.sidebar__link--active {
background-color: var(--color-brand-primary-400);
&:hover {
background-color: var(--color-brand-primary-300);
color: #0b1620;
}
}
}
@@ -1,5 +1,11 @@
import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu';
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
ViewEncapsulation,
computed,
inject,
} from '@angular/core';
import { Icon, type IconName } from '../icon/icon';
import { LayoutStateService, type ThemeMode } from '../../state/layout-state.service';
@@ -36,6 +42,14 @@ const MODE_OPTIONS: readonly ModeOption[] = [
templateUrl: './theme-switcher.html',
styleUrl: './theme-switcher.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
// The CDK menu opens in an overlay portal appended to <body>, so it
// is NOT a descendant of this component's host. With the default
// emulated encapsulation, the menu DOM would lose the scoping
// attribute and our `.theme-switcher__*` rules would not apply.
// Disabling encapsulation emits the styles into the global sheet —
// the BEM-style class names below are specific enough to avoid
// collisions, and `.dark` ancestor matching just works.
encapsulation: ViewEncapsulation.None,
})
export class ThemeSwitcher {
private readonly layout = inject(LayoutStateService);