fix(portal-shell): dark mode actually applies to component-scoped surfaces #87
@@ -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,12 +30,12 @@
|
||||
overflow-y: auto;
|
||||
background-color: #f9fafb;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
:where(.dark) & {
|
||||
:host-context(.dark) .shell-main {
|
||||
background-color: #0b1220;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully
|
||||
// visible and focusable when reached via Tab from the address bar.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user