From d5478ee8d2c92c805e41fb053755271fa80efeaf Mon Sep 17 00:00:00 2001 From: Robyn MacCallum Date: Mon, 23 Feb 2026 12:57:58 -0500 Subject: [PATCH] [CL-971] update responsive behavior of three panel layout (#19086) (#19149) * update responsive behavior of three panel layout; give sidenav extra top padding on electron; add stories that show mix of drawer and sidenav states --------- Co-authored-by: Will Martin Co-authored-by: Claude Sonnet 4.6 --- apps/desktop/src/scss/environment.scss | 2 + .../access-selector-dialog.stories.ts | 2 +- .../navigation-switcher.component.html | 126 +++++----- .../navigation-switcher.component.spec.ts | 5 +- .../navigation-switcher.stories.ts | 84 ++++--- .../app/layouts/web-side-nav.component.html | 6 +- libs/components/src/dialog/dialog.service.ts | 2 - .../src/dialog/dialog/dialog.component.html | 4 +- .../src/dialog/dialog/dialog.component.ts | 48 +++- libs/components/src/dialog/drawer.service.ts | 32 ++- .../src/layout/layout.component.html | 103 +++++--- .../components/src/layout/layout.component.ts | 233 +++++++++++++++++- .../src/navigation/nav-item.stories.ts | 25 -- .../src/navigation/nav-logo.component.html | 2 +- .../src/navigation/nav-logo.component.ts | 1 + .../src/navigation/side-nav.component.html | 22 +- .../src/navigation/side-nav.component.ts | 7 +- .../src/navigation/side-nav.service.ts | 43 ++-- libs/components/src/shared/index.ts | 1 + libs/components/src/shared/root-font-size.ts | 7 + .../kitchen-sink/kitchen-sink.stories.ts | 63 ++++- libs/components/src/tw-theme.css | 4 + 22 files changed, 601 insertions(+), 221 deletions(-) create mode 100644 libs/components/src/shared/root-font-size.ts diff --git a/apps/desktop/src/scss/environment.scss b/apps/desktop/src/scss/environment.scss index 699f2246b4a..e6ea95ef17e 100644 --- a/apps/desktop/src/scss/environment.scss +++ b/apps/desktop/src/scss/environment.scss @@ -11,6 +11,8 @@ .vault > .groupings > .content > .inner-content { padding-top: 0; } + + --bit-sidenav-macos-extra-top-padding: 28px; } .environment-selector-btn { diff --git a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector-dialog.stories.ts b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector-dialog.stories.ts index 3e23eff13a9..f5c6939c284 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector-dialog.stories.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector-dialog.stories.ts @@ -25,7 +25,7 @@ const render: Story["render"] = (args) => ({ ...args, }, template: ` - + Access selector - @let accessibleProducts = accessibleProducts$ | async; - @if (accessibleProducts && accessibleProducts.length > 1) { - - - - } +@let accessibleProducts = accessibleProducts$ | async; +@if (accessibleProducts && accessibleProducts.length > 1) { + + + +} - @if (shouldShowPremiumUpgradeButton$ | async) { - - } +@if (shouldShowPremiumUpgradeButton$ | async) { + +} - @let moreProducts = moreProducts$ | async; - @if (moreProducts && moreProducts.length > 0) { -
- {{ "moreFromBitwarden" | i18n }} - - + + + +
+} diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts index 9a6de3ad9af..95676759147 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts @@ -8,7 +8,7 @@ import { BehaviorSubject } from "rxjs"; import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; -import { IconButtonModule, NavigationModule } from "@bitwarden/components"; +import { IconButtonModule, NavigationModule, SideNavService } from "@bitwarden/components"; // FIXME: remove `src` and fix import // eslint-disable-next-line no-restricted-imports import { NavItemComponent } from "@bitwarden/components/src/navigation/nav-item.component"; @@ -86,6 +86,9 @@ describe("NavigationProductSwitcherComponent", () => { beforeEach(() => { fixture = TestBed.createComponent(NavigationProductSwitcherComponent); + // SideNavService.open starts false (managed by LayoutComponent's ResizeObserver in a real + // app). Set it to true so NavItemComponent renders text labels (used in text-content checks). + TestBed.inject(SideNavService).open.set(true); fixture.detectChanges(); }); diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts index 7af255c6823..990b1f63267 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts @@ -1,8 +1,10 @@ import { Component, Directive, importProvidersFrom, Input } from "@angular/core"; +import { provideNoopAnimations } from "@angular/platform-browser/animations"; import { RouterModule } from "@angular/router"; import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular"; import { BehaviorSubject, Observable, of } from "rxjs"; +import { PasswordManagerLogo } from "@bitwarden/assets/svg"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; @@ -17,13 +19,13 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { SyncService } from "@bitwarden/common/platform/sync"; import { UserId } from "@bitwarden/common/types/guid"; import { + I18nMockService, LayoutComponent, NavigationModule, StorybookGlobalStateProvider, } from "@bitwarden/components"; -// FIXME: remove `src` and fix import // eslint-disable-next-line no-restricted-imports -import { I18nMockService } from "@bitwarden/components/src/utils/i18n-mock.service"; +import { positionFixedWrapperDecorator } from "@bitwarden/components/src/stories/storybook-decorators"; import { GlobalStateProvider } from "@bitwarden/state"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -109,15 +111,6 @@ class MockConfigService implements Partial { } } -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -@Component({ - selector: "story-layout", - template: ``, - standalone: false, -}) -class StoryLayoutComponent {} - // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -132,17 +125,23 @@ const translations: Record = { secureYourInfrastructure: "Secure your infrastructure", protectYourFamilyOrBusiness: "Protect your family or business", skipToContent: "Skip to content", + toggleSideNavigation: "Toggle side navigation", + resizeSideNavigation: "Resize side navigation", + submenu: "submenu", + toggleCollapse: "toggle collapse", + close: "Close", + loading: "Loading", }; export default { title: "Web/Navigation Product Switcher", decorators: [ + positionFixedWrapperDecorator(), moduleMetadata({ declarations: [ NavigationProductSwitcherComponent, MockOrganizationService, MockProviderService, - StoryLayoutComponent, StoryContentComponent, ], imports: [NavigationModule, RouterModule, LayoutComponent, I18nPipe], @@ -174,19 +173,11 @@ export default { }), applicationConfig({ providers: [ + provideNoopAnimations(), importProvidersFrom( - RouterModule.forRoot([ - { - path: "", - component: StoryLayoutComponent, - children: [ - { - path: "**", - component: StoryContentComponent, - }, - ], - }, - ]), + RouterModule.forRoot([{ path: "**", component: StoryContentComponent }], { + useHash: true, + }), ), { provide: GlobalStateProvider, @@ -203,12 +194,47 @@ type Story = StoryObj< const Template: Story = { render: (args) => ({ - props: args, + props: { ...args, logo: PasswordManagerLogo }, template: ` - -
- -
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `, }), }; diff --git a/apps/web/src/app/layouts/web-side-nav.component.html b/apps/web/src/app/layouts/web-side-nav.component.html index adb526bd593..081afc355a6 100644 --- a/apps/web/src/app/layouts/web-side-nav.component.html +++ b/apps/web/src/app/layouts/web-side-nav.component.html @@ -1,8 +1,12 @@ - + + + + + diff --git a/libs/components/src/dialog/dialog.service.ts b/libs/components/src/dialog/dialog.service.ts index ed17cb27327..d4cff75eddd 100644 --- a/libs/components/src/dialog/dialog.service.ts +++ b/libs/components/src/dialog/dialog.service.ts @@ -14,7 +14,6 @@ import { filter, firstValueFrom, map, Observable, Subject, switchMap, take } fro import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { isAtOrLargerThanBreakpoint } from "../utils/responsive-utils"; @@ -190,7 +189,6 @@ export class DialogService { private injector = inject(Injector); private router = inject(Router, { optional: true }); private authService = inject(AuthService, { optional: true }); - private i18nService = inject(I18nService); private backDropClasses = ["tw-fixed", "tw-bg-black", "tw-bg-opacity-30", "tw-inset-0"]; private defaultScrollStrategy = new CustomBlockScrollStrategy(); diff --git a/libs/components/src/dialog/dialog/dialog.component.html b/libs/components/src/dialog/dialog/dialog.component.html index f81f0594218..2f366b2362a 100644 --- a/libs/components/src/dialog/dialog/dialog.component.html +++ b/libs/components/src/dialog/dialog/dialog.component.html @@ -1,8 +1,10 @@ @let isDrawer = dialogRef?.isDrawer; + +@let widthClass = width() ?? "";
= { + small: 24, + default: 32, + large: 42, +}; + // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -69,6 +79,18 @@ export class DialogComponent implements AfterViewInit { private readonly destroyRef = inject(DestroyRef); private readonly ngZone = inject(NgZone); private readonly el = inject>(ElementRef); + private readonly drawerService = inject(DrawerService); + + constructor() { + effect(() => { + if (!this.dialogRef?.isDrawer) { + return; + } + const size = this.dialogSize(); + const rootFontSizePx = getRootFontSizePx(); + this.drawerService.declarePushWidth((drawerSizeToWidthRem[size] ?? 32) * rootFontSizePx); + }); + } private readonly dialogHeader = viewChild.required>("dialogHeader"); @@ -122,23 +144,31 @@ export class DialogComponent implements AfterViewInit { private readonly animationCompleted = signal(false); + /** Max width class */ protected readonly width = computed(() => { - const size = this.dialogSize() ?? "default"; - const isDrawer = this.dialogRef?.isDrawer; + const size = this.dialogSize(); - if (isDrawer) { - return drawerSizeToWidth[size]; + if (this.dialogRef?.isDrawer) { + return this.drawerService.isPushMode() ? drawerSizeToWidth[size] : ""; } - return dialogSizeToWidth[size]; }); protected readonly classes = computed(() => { - // `tw-max-h-[90vh]` is needed to prevent dialogs from overlapping the desktop header - const baseClasses = ["tw-flex", "tw-flex-col", "tw-w-screen"]; - const sizeClasses = this.dialogRef?.isDrawer ? ["tw-h-full"] : ["md:tw-p-4", "tw-max-h-[90vh]"]; + const isDrawer = this.dialogRef?.isDrawer; + // Drawers use tw-w-full (100% of column) so the element fills its grid track + // without overflowing — the column itself is capped by the grid template. + // Regular dialogs use tw-w-screen for full-width mobile presentation. + const widthClass = isDrawer ? "tw-w-full" : "tw-w-screen"; + const baseClasses = ["tw-flex", "tw-flex-col", widthClass]; + const sizeClasses = isDrawer + ? ["tw-h-full"] + : [ + "md:tw-p-4", + "tw-max-h-[90vh]", // needed to prevent dialogs from overlapping the desktop header + ]; - const size = this.dialogSize() ?? "default"; + const size = this.dialogSize(); const animationClasses = this.disableAnimations() || this.animationCompleted() || this.dialogRef?.isDrawer ? [] diff --git a/libs/components/src/dialog/drawer.service.ts b/libs/components/src/dialog/drawer.service.ts index 71b3ff967d7..bc2a90e006c 100644 --- a/libs/components/src/dialog/drawer.service.ts +++ b/libs/components/src/dialog/drawer.service.ts @@ -3,18 +3,40 @@ import { Injectable, signal } from "@angular/core"; @Injectable({ providedIn: "root" }) export class DrawerService { - private readonly _portal = signal | undefined>(undefined); - /** The portal to display */ - portal = this._portal.asReadonly(); + readonly portal = signal | undefined>(undefined); + + /** + * The drawer's preferred push-mode column width in px. + * Declared by the drawer content (e.g. bit-dialog) via declarePushWidth(). + * Zero when no drawer is active or the width has not been declared yet. + */ + readonly pushWidthPx = signal(0); + + /** + * Whether the drawer is currently in push mode (occupying its own grid column). + * Set by LayoutComponent via ResizeObserver; read by the drawer content for display. + */ + readonly isPushMode = signal(false); open(portal: Portal) { - this._portal.set(portal); + this.portal.set(portal); } close(portal: Portal) { if (portal === this.portal()) { - this._portal.set(undefined); + this.portal.set(undefined); + this.pushWidthPx.set(0); + this.isPushMode.set(false); } } + + /** + * Called by drawer content components (e.g. bit-dialog) to declare their natural + * push-mode column width so LayoutComponent can make accurate push/overlay decisions + * without measuring the DOM (which is unreliable when the column is 1fr). + */ + declarePushWidth(px: number) { + this.pushWidthPx.set(px); + } } diff --git a/libs/components/src/layout/layout.component.html b/libs/components/src/layout/layout.component.html index f0e2b601e38..1008f9c43b4 100644 --- a/libs/components/src/layout/layout.component.html +++ b/libs/components/src/layout/layout.component.html @@ -1,45 +1,68 @@ @let mainContentId = "main-content"; -
-
- - -
- - -
- -
- @if (sideNavService.open()) { -
- } -
+
+ + -
+ + + + + + + + @if (sideNavService.isOverlay() && siderailIsPushMode()) { + + } + + +
+ + +
+ + +
+ @if (sideNavService.isOverlay()) { +
+ } +
+ + +
diff --git a/libs/components/src/layout/layout.component.ts b/libs/components/src/layout/layout.component.ts index c71c4e73c6e..413296604c0 100644 --- a/libs/components/src/layout/layout.component.ts +++ b/libs/components/src/layout/layout.component.ts @@ -1,16 +1,35 @@ import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y"; import { PortalModule } from "@angular/cdk/portal"; import { CommonModule } from "@angular/common"; -import { booleanAttribute, Component, ElementRef, inject, input, viewChild } from "@angular/core"; +import { + afterNextRender, + booleanAttribute, + Component, + computed, + DestroyRef, + ElementRef, + inject, + input, + signal, + viewChild, +} from "@angular/core"; import { RouterModule } from "@angular/router"; +import { drawerSizeToWidthRem } from "../dialog/dialog/dialog.component"; import { DrawerService } from "../dialog/drawer.service"; import { LinkComponent, LinkModule } from "../link"; import { SideNavService } from "../navigation/side-nav.service"; -import { SharedModule } from "../shared"; +import { getRootFontSizePx, SharedModule } from "../shared"; import { ScrollLayoutHostDirective } from "./scroll-layout.directive"; +/** Matches tw-min-w-96 on
. */ +const MAIN_MIN_WIDTH_REM = 24; + +/** Approximate rendered width of the closed nav (siderail / icon strip). + * Derived from tw-w-[3.75rem] + tw-mx-0.5 margins in side-nav.component.html. */ +const SIDERAIL_WIDTH_REM = 4; + // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -33,9 +52,217 @@ import { ScrollLayoutHostDirective } from "./scroll-layout.directive"; }) export class LayoutComponent { protected sideNavService = inject(SideNavService); - protected drawerPortal = inject(DrawerService).portal; + private readonly drawerService = inject(DrawerService); + protected drawerPortal = this.drawerService.portal; + /** Rendered only when nothing is projected into the side-nav slot (ng-content fallback). */ + private readonly sideNavSlotFallback = viewChild("sideNavSlotFallback"); + protected readonly hasSideNav = computed(() => this.sideNavSlotFallback() == null); + + /** + * True as soon as a portal is active; false when no drawer is open. + * Derived directly from the portal signal so col 3 gets a non-zero track + * immediately on open — without waiting for the ResizeObserver to fire. + * This breaks the chicken-and-egg: col 3 = 0px → no resize event → drawer + * never appears. + */ + private readonly drawerIsActive = computed(() => this.drawerPortal() != null); + + private readonly destroyRef = inject(DestroyRef); + private readonly container = viewChild.required>("container"); private readonly mainContent = viewChild.required>("main"); + private readonly drawerContainer = viewChild.required>("drawerContainer"); + + /** + * Container width in px, updated by the ResizeObserver on every layout change. + * Exposed as a signal so gridTemplateColumns can reactively compute push vs + * overlay for the drawer without waiting for a ResizeObserver tick. + */ + private readonly containerWidthPx = signal(0); + + /** + * Whether the siderail (closed-nav icon strip) fits in its own column. + * Has a lower threshold than full-nav isPushMode because the siderail is + * much narrower — it should remain visible on intermediate viewport widths. + */ + protected readonly siderailIsPushMode = signal(false); + + /** + * The CSS grid-template-columns value for the three-panel layout. + * + * Column 1 (nav): navWidthRem when nav is push+open + * auto when nav is push+closed (icon strip) OR + * when only the siderail fits; a dummy placeholder + * div keeps col 1 stable when the nav is fixed (overlay) + * 0px when even the siderail doesn't fit + * Column 2 (main): minmax(mainMinWidthPx, 1fr) normally — the minmax base reserves + * space for main so CSS grid can shrink col 3 without JS arithmetic; + * 0px when drawer is in overlay mode (drawer takes the full row) + * Column 3 (drawer): auto when push (CSS shrinks naturally from declared max down to + * drawerMinPushWidthPx before JS switches to overlay); + * 1fr when overlay (takes over main's space); 0px when no drawer + */ + protected readonly gridTemplateColumns = computed(() => { + const navOpen = this.sideNavService.open(); + const navPush = this.sideNavService.isPushMode(); + const siderailPush = this.siderailIsPushMode(); + + // --- Drawer push/shrink/overlay --- + const drawerActive = this.drawerIsActive(); + const declaredDrawerWidth = this.drawerService.pushWidthPx(); + const containerWidth = this.containerWidthPx(); + const rootFontSizePx = getRootFontSizePx(); + const siderailWidthPx = SIDERAIL_WIDTH_REM * rootFontSizePx; + const drawerMinWidthPx = drawerSizeToWidthRem.small * rootFontSizePx; + const mainMinWidthPx = MAIN_MIN_WIDTH_REM * rootFontSizePx; + + // Push vs overlay: switch to overlay only when the minimum push width won't fit. + // The shrink zone between the declared max-width and the minimum is handled + // entirely by CSS grid: col2 uses minmax(mainMinWidthPx, 1fr) so its base + // size reserves space for main before col3 auto grows. When the container + // shrinks, col3 naturally receives less free space and narrows without any JS + // pixel arithmetic. + // + // dialog.component declares its push width via an effect() that runs during + // Angular's CD — before the ResizeObserver fires and before the browser paints. + // Falls back to the ResizeObserver-driven signal when not yet declared. + let drawerPush: boolean; + if (!drawerActive) { + drawerPush = false; + } else if (declaredDrawerWidth > 0 && containerWidth > 0) { + drawerPush = containerWidth - siderailWidthPx - drawerMinWidthPx >= mainMinWidthPx; + } else { + drawerPush = this.drawerService.isPushMode(); + } + + // --- Col 1 (nav / siderail) --- + // When the nav enters overlay mode (position:fixed) it leaves the grid's normal + // flow. A dummy placeholder div in the template keeps the col 1 auto track + // stable without needing an explicit px value here. + let col1: string; + if (!this.hasSideNav()) { + col1 = "0px"; // no side nav projected — collapse the column entirely + } else if (navOpen && navPush) { + col1 = `${this.sideNavService.widthRem()}rem`; // full nav, push+open + } else if (navPush || siderailPush) { + col1 = "auto"; // siderail in flow, size naturally + } else { + col1 = "0px"; // viewport too narrow even for siderail + } + + // col3: minmax(0px, declaredMax) instead of "auto" so the track is sized by its + // explicit bounds rather than by the item's content-based size. This lets CSS + // grid shrink the drawer column down to 0 when the available space is limited, + // while col2's minmax base reserves mainMinWidthPx for main first. + // The dialog uses tw-w-full so it fills the column without overflowing it. + let col3: string; + if (!drawerActive) { + col3 = "0px"; + } else if (!drawerPush) { + col3 = "1fr"; + } else if (declaredDrawerWidth > 0) { + col3 = `minmax(0px, ${declaredDrawerWidth}px)`; + } else { + col3 = "auto"; // fallback before dialog's effect declares its width + } + const col2 = !drawerActive || drawerPush ? `minmax(${mainMinWidthPx}px, 1fr)` : "0px"; + + return `${col1} ${col2} ${col3}`; + }); + + constructor() { + afterNextRender(() => { + const container = this.container().nativeElement; + const drawerContainer = this.drawerContainer().nativeElement; + + const update = () => { + const rootFontSizePx = getRootFontSizePx(); + const containerWidth = container.clientWidth; + const siderailPx = SIDERAIL_WIDTH_REM * rootFontSizePx; + const mainMinPx = MAIN_MIN_WIDTH_REM * rootFontSizePx; + const navWidthPx = this.sideNavService.widthRem() * rootFontSizePx; + const drawerMinPx = drawerSizeToWidthRem.small * rootFontSizePx; + + this.containerWidthPx.set(containerWidth); + + // Use the push width declared by the drawer content (e.g. bit-dialog) via + // DrawerService.declarePushWidth(). This is more reliable than DOM measurement + // because the drawerContainer's firstElementChild is the outer portal host + // component (e.g. app-vault-item), which fills the full 1fr column in overlay + // mode — making its offsetWidth useless for push-vs-overlay decisions. + const drawerWidthPx = this.drawerService.pushWidthPx(); + + // Can the full nav push alongside main (ignoring the drawer)? + const navAloneCanPush = containerWidth - navWidthPx >= mainMinPx; + + // Can the drawer push at full width with the full nav? + const drawerFullWidthNavCanPush = + drawerWidthPx > 0 && containerWidth - navWidthPx - drawerWidthPx >= mainMinPx; + + // Can the drawer push at full width with just the siderail? + const drawerFullWidthSiderailCanPush = + drawerWidthPx > 0 && containerWidth - siderailPx - drawerWidthPx >= mainMinPx; + + // Can the drawer push at minimum width with the full nav (shrink zone)? + const drawerMinWithNavCanPush = + drawerWidthPx > 0 && containerWidth - navWidthPx - drawerMinPx >= mainMinPx; + + // Can the drawer push at minimum width with just the siderail (shrink zone)? + const drawerMinWithSiderailCanPush = + drawerWidthPx > 0 && containerWidth - siderailPx - drawerMinPx >= mainMinPx; + + // When the drawer is open and space is limited, the full nav yields first — + // it closes to its siderail so the drawer can remain in push mode. When even + // the minimum push width doesn't fit, the drawer goes overlay. + // drawerPush: true if the drawer fits at any width (full or shrunk) alongside either nav or siderail. + // navPush: true if the full nav fits alongside the drawer; false if only the siderail fits. + // When no drawer is active, falls back to navAloneCanPush. + const drawerPush = + drawerFullWidthNavCanPush || + drawerFullWidthSiderailCanPush || + drawerMinWithNavCanPush || + drawerMinWithSiderailCanPush; + const navPush = drawerPush + ? drawerFullWidthNavCanPush || drawerMinWithNavCanPush + : navAloneCanPush && drawerWidthPx === 0; + + // In shrink-push mode the drawer occupies less than its declared max, so use + // the actual available space as the effective drawer width for the siderail check. + const drawerEffectivePx = drawerPush + ? Math.min(drawerWidthPx, Math.max(0, containerWidth - siderailPx - mainMinPx)) + : 0; + const siderailCanPush = drawerPush + ? containerWidth - siderailPx - drawerEffectivePx >= mainMinPx + : containerWidth - siderailPx >= mainMinPx; + + const wasInPushMode = this.sideNavService.isPushMode(); + + // Transitioning out of push mode → close the nav. + // (If already in overlay and open, leave it — it's intentionally overlaying content.) + if (!navPush && this.sideNavService.open() && wasInPushMode) { + this.sideNavService.open.set(false); + } + + // Transitioning into push mode → reopen unless the user explicitly closed it. + if ( + navPush && + !wasInPushMode && + this.sideNavService.userCollapsePreference() !== "closed" + ) { + this.sideNavService.open.set(true); + } + + this.sideNavService.isPushMode.set(navPush); + this.siderailIsPushMode.set(siderailCanPush); + this.drawerService.isPushMode.set(drawerPush); + }; + + const resizeObserver = new ResizeObserver(update); + resizeObserver.observe(container); + resizeObserver.observe(drawerContainer); + this.destroyRef.onDestroy(() => resizeObserver.disconnect()); + }); + } /** * Rounded top left corner for the main content area diff --git a/libs/components/src/navigation/nav-item.stories.ts b/libs/components/src/navigation/nav-item.stories.ts index 3036ab26348..172a2605a5c 100644 --- a/libs/components/src/navigation/nav-item.stories.ts +++ b/libs/components/src/navigation/nav-item.stories.ts @@ -146,28 +146,3 @@ export const ForceActiveStyles: Story = { `, }), }; - -export const CollapsedNavItems: Story = { - render: (args) => ({ - props: args, - template: ` - - - - `, - }), - play: async () => { - const toggleButton = document.querySelector( - "[aria-label='Toggle side navigation']", - ) as HTMLButtonElement; - - if (toggleButton) { - toggleButton.click(); - } - }, - parameters: { - chromatic: { - delay: 1000, - }, - }, -}; diff --git a/libs/components/src/navigation/nav-logo.component.html b/libs/components/src/navigation/nav-logo.component.html index 8323a0f3479..07f46f18038 100644 --- a/libs/components/src/navigation/nav-logo.component.html +++ b/libs/components/src/navigation/nav-logo.component.html @@ -1,5 +1,5 @@