diff --git a/.changeset/28077-console-sidebar-rail-rework.md b/.changeset/28077-console-sidebar-rail-rework.md
new file mode 100644
index 00000000000..a257de54623
--- /dev/null
+++ b/.changeset/28077-console-sidebar-rail-rework.md
@@ -0,0 +1,14 @@
+---
+'@object-ui/app-shell': patch
+---
+
+fix(console): the narrow-window sidebar rail draws no expanded first frame, folds on one Esc, and keeps the wide-window choice apart
+
+Clause-②: no
+Metadata-protocol: none
+
+Three fixes to the console sidebar's icon rail below 1416 px:
+
+- **No expanded first frame.** A load on a narrow window drew the expanded sidebar once and then folded it, because the sidebar started from the state the last page saved. The console now hands the sidebar the rail before it first draws, so every frame from the first one is the rail. At 1416 px and above nothing changes.
+- **One Esc folds the overlay.** With the pointer on an entry, or focus on an entry or on "Keep sidebar open", the first Esc went to the entry's hidden tooltip and the overlay stayed open until a second Esc. One Esc now folds it; an entry's open menu still takes the Esc first.
+- **The wide-window choice is kept apart.** Turning "Keep sidebar open" on and off on a narrow window no longer changes the expanded or collapsed sidebar the reader chose on a wide window, across a resize and a reload. Only an open or close on a wide window is kept as that choice.
diff --git a/ui/packages/app-shell/src/chrome/KeyboardShortcutsDialog.wiredOnly-11674.test.tsx b/ui/packages/app-shell/src/chrome/KeyboardShortcutsDialog.wiredOnly-11674.test.tsx
index 506c45f83b9..5fa38127224 100644
--- a/ui/packages/app-shell/src/chrome/KeyboardShortcutsDialog.wiredOnly-11674.test.tsx
+++ b/ui/packages/app-shell/src/chrome/KeyboardShortcutsDialog.wiredOnly-11674.test.tsx
@@ -146,9 +146,11 @@ function chordEvents(chord: ShortcutChord): KeyboardEventInit[] {
const probe = () => screen.getByTestId('probe');
// `SidebarProvider` persists its state in this cookie and reads it on mount,
-// so one toggle would decide the next mount's starting state.
+// and the console keeps the wide-window choice under its own key
+// (objectstack#28079), so one toggle would decide the next mount's starting state.
const clearSidebarCookie = () => {
document.cookie = 'sidebar_state=; path=/; max-age=0';
+ localStorage.removeItem('os:console-sidebar:wide-open');
};
const dialogShown = () => screen.queryByTestId('overlay:keyboard-shortcuts') !== null;
diff --git a/ui/packages/app-shell/src/hooks/__tests__/useResponsiveSidebar.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useResponsiveSidebar.test.tsx
index 44d51032bbd..65ff8a2458c 100644
--- a/ui/packages/app-shell/src/hooks/__tests__/useResponsiveSidebar.test.tsx
+++ b/ui/packages/app-shell/src/hooks/__tests__/useResponsiveSidebar.test.tsx
@@ -5,6 +5,8 @@
* narrower than `CONSOLE_SIDEBAR_RAIL_BELOW_WIDTH` and unfolds when it widens
* (objectstack#27593). The wide window's open state survives the fold, a
* reader's "keep open" turns the fold off, and the phone drawer is left alone.
+ * Only an open or close on a wide window is kept as the wide-window choice
+ * (objectstack#28079).
*/
import '@testing-library/jest-dom/vitest';
@@ -14,7 +16,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { useResponsiveSidebar } from '../useResponsiveSidebar';
import {
CONSOLE_SIDEBAR_KEEP_OPEN_PREFERENCE,
- readStashedWideOpen,
+ readWideWindowOpen,
} from '../../layout/consoleSidebarRail';
import { writeWorkspacePreference } from '../../utils/workspacePreference';
@@ -100,11 +102,11 @@ describe('useResponsiveSidebar (objectstack#27593)', () => {
resizeTo(1280);
expect(openState()).toBe('false');
- expect(readStashedWideOpen()).toBe(true);
+ expect(readWideWindowOpen()).toBe(true);
resizeTo(1440);
expect(openState()).toBe('true');
- expect(readStashedWideOpen()).toBeUndefined();
+ expect(readWideWindowOpen()).toBe(true);
});
it('hands back a collapsed wide state as collapsed', () => {
@@ -127,22 +129,22 @@ describe('useResponsiveSidebar (objectstack#27593)', () => {
expect(openState()).toBe('false');
cleanup();
- // The primitive's cookie recorded the fold; the stash carries the wide state.
+ // The primitive's cookie recorded the fold; the wide-window choice is kept apart.
renderAt(1440);
expect(openState()).toBe('true');
- expect(readStashedWideOpen()).toBeUndefined();
+ expect(readWideWindowOpen()).toBe(true);
});
it('"keep open" turns the fold off: a narrow window keeps the sidebar open', () => {
writeWorkspacePreference(CONSOLE_SIDEBAR_KEEP_OPEN_PREFERENCE, 'user', 'on');
renderAt(1280);
expect(openState()).toBe('true');
- expect(readStashedWideOpen()).toBeUndefined();
+ expect(readWideWindowOpen()).toBeUndefined();
});
it('leaves the open state alone below the phone breakpoint, where the drawer takes over', () => {
renderAt(700);
expect(openState()).toBe('true');
- expect(readStashedWideOpen()).toBeUndefined();
+ expect(readWideWindowOpen()).toBeUndefined();
});
});
diff --git a/ui/packages/app-shell/src/hooks/useResponsiveSidebar.ts b/ui/packages/app-shell/src/hooks/useResponsiveSidebar.ts
index 63e2218fb85..c2d7027fac8 100644
--- a/ui/packages/app-shell/src/hooks/useResponsiveSidebar.ts
+++ b/ui/packages/app-shell/src/hooks/useResponsiveSidebar.ts
@@ -7,46 +7,53 @@
* (objectstack#27593). A reader who keeps the sidebar open is left alone.
* Must be called inside a SidebarProvider context.
*
- * The fold is not the reader's choice, so it does not keep one: the open
- * state the sidebar had on the wide window is stashed when the rail takes
- * over and handed back when the window widens, or on the next load that
- * opens wide, because the primitive's cookie records every open and close,
- * the fold's included.
+ * The fold is not the reader's choice, so it does not keep one: every open
+ * and close on a wide window is kept as the wide-window choice, and only
+ * those (objectstack#28079). A window that turns wide, or a load that opens
+ * wide, gets that choice back, whatever the fold, the overlay or a sidebar
+ * kept open on a narrow window did to the primitive's cookie meanwhile.
* @module
*/
import { useLayoutEffect, useRef } from 'react';
import { useSidebar } from '@object-ui/components';
import {
- readStashedWideOpen,
+ readWideWindowOpen,
+ seedWideWindowOpen,
useConsoleSidebarRail,
- writeStashedWideOpen,
+ writeWideWindowOpen,
} from '../layout/consoleSidebarRail.js';
export function useResponsiveSidebar() {
const { open, setOpen } = useSidebar();
- const { narrow, keepOpen, railMode } = useConsoleSidebarRail();
- const openRef = useRef(open);
+ const { wide, railMode } = useConsoleSidebarRail();
const setOpenRef = useRef(setOpen);
+ const last = useRef<{ wide: boolean; railMode: boolean } | null>(null);
useLayoutEffect(() => {
- openRef.current = open;
setOpenRef.current = setOpen;
});
// A layout effect, so a window that opens narrow paints the rail first.
useLayoutEffect(() => {
- if (railMode) {
- // A stash left by an earlier narrow load is the wide state; keep it.
- if (readStashedWideOpen() === undefined) writeStashedWideOpen(openRef.current);
- setOpenRef.current(false);
+ const previous = last.current;
+ last.current = { wide, railMode };
+ if (wide) {
+ // A load that opens wide, or a window that turns wide: the reader's wide-window choice.
+ if (!previous?.wide) {
+ const wideOpen = readWideWindowOpen();
+ if (wideOpen !== undefined && wideOpen !== open) {
+ setOpenRef.current(wideOpen);
+ return;
+ }
+ }
+ writeWideWindowOpen(open);
return;
}
- const wideOpen = readStashedWideOpen();
- if (wideOpen === undefined) return;
- writeStashedWideOpen(undefined);
- // "Keep open" on a narrow window: the reader just chose the open sidebar.
- if (narrow && keepOpen) return;
- setOpenRef.current(wideOpen);
- }, [railMode, narrow, keepOpen]);
+ // The fold takes over: a narrow load, a window that narrows, "keep open" turned off.
+ if (railMode && !previous?.railMode) {
+ seedWideWindowOpen(open);
+ setOpenRef.current(false);
+ }
+ }, [wide, railMode, open]);
}
diff --git a/ui/packages/app-shell/src/layout/ConsoleLayout.tsx b/ui/packages/app-shell/src/layout/ConsoleLayout.tsx
index af02e7cdcd6..51f979e7359 100644
--- a/ui/packages/app-shell/src/layout/ConsoleLayout.tsx
+++ b/ui/packages/app-shell/src/layout/ConsoleLayout.tsx
@@ -8,7 +8,7 @@
* @module
*/
-import React, { useCallback, useEffect, useRef } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { AppShell } from '@object-ui/layout';
import { useIsMobile } from '@object-ui/components';
@@ -31,6 +31,7 @@ import { UnifiedSidebar } from './UnifiedSidebar.js';
import { AppHeader } from './AppHeader.js';
import { MobileViewSwitcherProvider } from './MobileViewSwitcherContext.js';
import { useResponsiveSidebar } from '../hooks/useResponsiveSidebar.js';
+import { primeConsoleSidebarOpen } from './consoleSidebarRail.js';
import { useAiSurfaceEnabled } from '../hooks/useAiSurface.js';
import { useNavigationContext } from '../context/NavigationContext.js';
import { CommandPaletteProvider } from '../context/CommandPaletteProvider.js';
@@ -135,6 +136,9 @@ export function ConsoleLayout({
userId,
}: ConsoleLayoutProps) {
const appLabel = resolveKeyedI18nLabel(activeApp?.label) || activeAppName;
+ // objectstack#28077 — once, before `AppShell`'s sidebar provider reads its
+ // cookie: a narrow window's first frame is already the rail.
+ useState(primeConsoleSidebarOpen);
// Runtime, server-pushed AI gating (shared with the `/ai` route guard and the
// top-bar AI link): show the chatbot only when the server actually serves AI,
// or when an explicit `VITE_AI_BASE_URL` opt-in points at an external server.
diff --git a/ui/packages/app-shell/src/layout/__tests__/ConsoleLayout.sidebarFirstFrame-28077.test.tsx b/ui/packages/app-shell/src/layout/__tests__/ConsoleLayout.sidebarFirstFrame-28077.test.tsx
new file mode 100644
index 00000000000..e8a7bb60da8
--- /dev/null
+++ b/ui/packages/app-shell/src/layout/__tests__/ConsoleLayout.sidebarFirstFrame-28077.test.tsx
@@ -0,0 +1,123 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * ConsoleLayout — on a window below the fold width the console sidebar's
+ * first frame is already the icon rail (objectstack#28077).
+ *
+ * The sidebar provider takes its first open state from the `sidebar_state`
+ * cookie the last page wrote. Before, a narrow window drew that state (the
+ * expanded sidebar) once and then folded it, with the primitive's width
+ * transition in between. The probe stands in for the sidebar and records the
+ * state of every render, the first one included.
+ *
+ * Three ways in, all at 1280 px: a first visit (no cookie), a load after the
+ * sidebar was expanded on a wide window, and a reload while the overlay was
+ * shown (both leave the cookie expanded). At 1440 px nothing changes.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import { cleanup, render } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { createI18n, I18nProvider } from '@object-ui/i18n';
+import { useSidebar } from '@object-ui/components';
+
+const renders: string[] = [];
+
+vi.mock('../../hooks/useAiSurface', () => ({
+ useAiSurfaceEnabled: () => ({ enabled: false, isLoading: false }),
+}));
+vi.mock('../UnifiedSidebar', () => ({
+ UnifiedSidebar: function SidebarProbe() {
+ renders.push(useSidebar().state);
+ return null;
+ },
+}));
+vi.mock('../AppHeader', () => ({ AppHeader: () => null }));
+vi.mock('../ConsoleNotificationBanners', () => ({ ConsoleNotificationBanners: () => null }));
+vi.mock('../../preview/DraftPreviewBar', () => ({ DraftPreviewBar: () => null }));
+vi.mock('../../preview/UnpublishedAppBar', () => ({ UnpublishedAppBar: () => null }));
+
+import { ConsoleLayout } from '../ConsoleLayout';
+import { NavigationProvider } from '../../context/NavigationContext';
+import { CONSOLE_SIDEBAR_KEEP_OPEN_PREFERENCE } from '../consoleSidebarRail';
+import { writeWorkspacePreference } from '../../utils/workspacePreference';
+
+const APP = { name: 'crm', label: 'CRM', active: true, navigation: [] };
+
+function setViewportWidth(width: number) {
+ (window as unknown as { happyDOM: { setViewport: (v: { width: number; height: number }) => void } }).happyDOM.setViewport({
+ width,
+ height: 900,
+ });
+}
+
+function load(width: number) {
+ setViewportWidth(width);
+ renders.length = 0;
+ return render(
+
+
+
+
+
+
+
+
+ ,
+ );
+}
+
+const clearCookie = () => {
+ document.cookie = 'sidebar_state=; path=/; max-age=0';
+};
+
+beforeEach(() => {
+ localStorage.clear();
+ clearCookie();
+});
+
+afterEach(() => {
+ cleanup();
+ setViewportWidth(1024);
+ localStorage.clear();
+ clearCookie();
+});
+
+describe('the narrow window’s first frame is the rail (objectstack#28077)', () => {
+ it('a first visit at 1280 px: every render, the first included, is the rail', () => {
+ load(1280);
+ expect(renders[0]).toBe('collapsed');
+ expect(new Set(renders)).toEqual(new Set(['collapsed']));
+ });
+
+ it('a 1280 px load after the sidebar was expanded on a wide window', () => {
+ load(1440);
+ expect(renders.at(-1)).toBe('expanded');
+ cleanup();
+
+ load(1280);
+ expect(renders[0]).toBe('collapsed');
+ expect(new Set(renders)).toEqual(new Set(['collapsed']));
+ });
+
+ it('a 1280 px reload while the overlay was shown (the cookie says expanded)', () => {
+ document.cookie = 'sidebar_state=true; path=/';
+ load(1280);
+ expect(renders[0]).toBe('collapsed');
+ expect(new Set(renders)).toEqual(new Set(['collapsed']));
+ });
+
+ it('at 1440 px nothing changes: expanded from the first render', () => {
+ load(1440);
+ expect(new Set(renders)).toEqual(new Set(['expanded']));
+ });
+
+ it('a sidebar kept open on a narrow window keeps the state it had', () => {
+ writeWorkspacePreference(CONSOLE_SIDEBAR_KEEP_OPEN_PREFERENCE, 'user', 'on');
+ document.cookie = 'sidebar_state=true; path=/';
+ load(1280);
+ expect(new Set(renders)).toEqual(new Set(['expanded']));
+ });
+});
diff --git a/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.railOverlay-27593.test.tsx b/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.railOverlay-27593.test.tsx
index b2aa657cd54..04dd9a68c8d 100644
--- a/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.railOverlay-27593.test.tsx
+++ b/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.railOverlay-27593.test.tsx
@@ -14,6 +14,10 @@
* an entry's own menu (a portal) does not, and the app bar's trigger
* toggles it once.
* - "Keep sidebar open" ends the fold for this browser and survives a reload.
+ * - One Esc folds it wherever the pointer or focus is inside it: the hidden
+ * tooltip an entry opens does not take the key (objectstack#28078).
+ * - "Keep sidebar open" on a narrow window and the wide-window choice are kept
+ * apart, across a resize and a reload (objectstack#28079).
*
* The test DOM computes no Tailwind and no layout, so widths are read as the
* class that sets them; the rendered widths are measured in a browser on the
@@ -98,7 +102,7 @@ vi.mock('../ContextSelectors', () => ({
import { SidebarProvider, useSidebar } from '@object-ui/components';
import { UnifiedSidebar } from '../UnifiedSidebar';
import { useResponsiveSidebar } from '../../hooks/useResponsiveSidebar';
-import { RAIL_OVERLAY_FRAME_KEEPS_GAP, RAIL_OVERLAY_PANEL } from '../consoleSidebarRail';
+import { RAIL_OVERLAY_FRAME_KEEPS_GAP, RAIL_OVERLAY_PANEL, primeConsoleSidebarOpen } from '../consoleSidebarRail';
/**
* Size the test window: happy-dom's own viewport, so `innerWidth`, the
@@ -181,6 +185,26 @@ const background = () => panel().querySelector('[data-sidebar="side
const keepOpen = () => screen.queryByTestId('sidebar-keep-open');
const tokens = (el: Element) => el.className.split(/\s+/).filter(Boolean);
+/** A page load: the console layout primes the provider before it mounts. */
+function reload(width: number) {
+ cleanup();
+ setViewportWidth(width);
+ primeConsoleSidebarOpen();
+ return renderShell(width);
+}
+
+function resizeTo(width: number) {
+ act(() => {
+ setViewportWidth(width);
+ });
+}
+
+const appBarTrigger = () => within(screen.getByTestId('app-bar')).getByRole('button', { name: 'Toggle sidebar' });
+
+/** An entry's tooltip: open but hidden while the sidebar is expanded. */
+const hiddenTooltip = () =>
+ Array.from(document.querySelectorAll('[role="tooltip"]')).find((el) => el.closest('[hidden]'));
+
function expandRail() {
fireEvent.click(background());
expect(sidebarRoot()).toHaveAttribute('data-state', 'expanded');
@@ -299,6 +323,89 @@ describe('the narrow-window icon rail (objectstack#27593)', () => {
expectRail();
});
+ describe('one Esc folds it wherever the pointer or focus is inside it (objectstack#28078)', () => {
+ it('with focus on an entry, whose hidden tooltip is open', () => {
+ renderShell(1280);
+ expandRail();
+ const entry = within(panel()).getByRole('link', { name: 'Contacts' });
+ act(() => entry.focus());
+ expect(hiddenTooltip(), 'the entry opened its hidden tooltip').toBeDefined();
+ fireEvent.keyDown(entry, { key: 'Escape' });
+ expectRail();
+ });
+
+ it('with the pointer on an entry', async () => {
+ renderShell(1280);
+ expandRail();
+ fireEvent.pointerMove(within(panel()).getByRole('link', { name: 'Accounts' }), { pointerType: 'mouse' });
+ await act(() => new Promise((resolve) => setTimeout(resolve, 10)));
+ expect(hiddenTooltip(), 'the entry opened its hidden tooltip').toBeDefined();
+ fireEvent.keyDown(document.body, { key: 'Escape' });
+ expectRail();
+ });
+
+ it('with focus on "Keep sidebar open"', () => {
+ renderShell(1280);
+ expandRail();
+ const toggle = keepOpen()!;
+ act(() => toggle.focus());
+ expect(hiddenTooltip(), 'the toggle opened its hidden tooltip').toBeDefined();
+ fireEvent.keyDown(toggle, { key: 'Escape' });
+ expectRail();
+ });
+
+ it('an entry’s open menu still takes the Esc first', () => {
+ renderShell(1280);
+ expandRail();
+ const entry = within(panel()).getByRole('link', { name: 'Accounts' });
+ act(() => entry.focus());
+ fireEvent.keyDown(within(panel()).getByRole('button', { name: 'More actions for Accounts' }), { key: 'Enter' });
+ const item = screen.getAllByRole('menuitem')[0];
+ fireEvent.keyDown(item, { key: 'Escape' });
+ expect(sidebarRoot()).toHaveAttribute('data-state', 'expanded');
+ });
+ });
+
+ describe('"Keep sidebar open" leaves the wide-window choice alone (objectstack#28079)', () => {
+ it('a collapsed wide-window sidebar comes back collapsed, after a reload too', () => {
+ renderShell(1440);
+ fireEvent.click(appBarTrigger());
+ expect(sidebarRoot()).toHaveAttribute('data-state', 'collapsed');
+
+ resizeTo(1280);
+ expandRail();
+ fireEvent.click(keepOpen()!);
+ expect(keepOpen()).toHaveAttribute('aria-pressed', 'true');
+ fireEvent.click(keepOpen()!);
+ expectRail();
+
+ resizeTo(1440);
+ expect(sidebarRoot()).toHaveAttribute('data-state', 'collapsed');
+ reload(1440);
+ expect(sidebarRoot()).toHaveAttribute('data-state', 'collapsed');
+ });
+
+ it('an expanded wide-window sidebar comes back expanded, after a reload too', () => {
+ renderShell(1440);
+ expect(sidebarRoot()).toHaveAttribute('data-state', 'expanded');
+
+ resizeTo(1280);
+ expandRail();
+ fireEvent.click(keepOpen()!);
+ // Kept open on the narrow window, the reader collapses it there…
+ fireEvent.click(appBarTrigger());
+ expect(sidebarRoot()).toHaveAttribute('data-state', 'collapsed');
+ // …and turns "keep open" off again.
+ fireEvent.click(keepOpen()!);
+ expectRail();
+
+ resizeTo(1440);
+ expect(sidebarRoot()).toHaveAttribute('data-state', 'expanded');
+ reload(1440);
+ expect(sidebarRoot()).toHaveAttribute('data-state', 'expanded');
+ });
+ });
+
it('"Keep sidebar open" ends the fold for this browser, survives a reload, and gives it back', () => {
renderShell(1280);
expandRail();
diff --git a/ui/packages/app-shell/src/layout/consoleSidebarRail.ts b/ui/packages/app-shell/src/layout/consoleSidebarRail.ts
index 6b012cf9a41..35cf074f076 100644
--- a/ui/packages/app-shell/src/layout/consoleSidebarRail.ts
+++ b/ui/packages/app-shell/src/layout/consoleSidebarRail.ts
@@ -20,6 +20,7 @@ import { useLocation } from 'react-router-dom';
import { cn, useIsViewportBelow, useSidebar } from '@object-ui/components';
import {
defineWorkspacePreference,
+ readWorkspacePreference,
useWorkspacePreference,
versionedPreferenceCodec,
workspacePreferenceStorage,
@@ -68,21 +69,22 @@ export const CONSOLE_SIDEBAR_KEEP_OPEN_PREFERENCE = defineWorkspacePreference part.trim());
+ if (name !== SIDEBAR_STATE_COOKIE) continue;
+ return value === 'true' ? true : value === 'false' ? false : undefined;
+ }
+ return undefined;
+}
+
+/**
+ * The phone breakpoint (`useIsMobile`'s 768 px in `@object-ui/components`):
+ * below it the primitive's drawer takes over and the fold stays off. Read
+ * synchronously here, while `useIsMobile` answers `false` until its effect.
+ */
+const PHONE_BELOW_WIDTH = 768;
+
+/**
+ * Hand the sidebar provider the open state its first frame draws, before it
+ * mounts (objectstack#28077): the provider starts from its cookie, which the
+ * last page wrote, so a narrow window would draw the expanded sidebar once and
+ * then fold it, with the primitive's width transition in between. Below the
+ * fold width with no "keep open" the first frame is the rail; on a wide
+ * window it is the reader's wide-window choice; a sidebar kept open, and the
+ * phone, keep what the cookie says. Call it once, from the component that
+ * renders the provider, before the provider's first render.
+ */
+export function primeConsoleSidebarOpen(): void {
+ if (typeof window === 'undefined' || typeof document === 'undefined') return;
+ const width = window.innerWidth;
+ if (width < PHONE_BELOW_WIDTH) return;
+ let open: boolean | undefined;
+ if (width >= CONSOLE_SIDEBAR_RAIL_BELOW_WIDTH) {
+ open = readWideWindowOpen();
+ } else if (readWorkspacePreference(CONSOLE_SIDEBAR_KEEP_OPEN_PREFERENCE, 'user') !== 'on') {
+ // The primitive's own default is open.
+ seedWideWindowOpen(readSidebarStateCookie() ?? true);
+ open = false;
+ }
+ if (open === undefined) return;
+ document.cookie = `${SIDEBAR_STATE_COOKIE}=${open}; path=/; max-age=${SIDEBAR_STATE_COOKIE_MAX_AGE}`;
+}
+
export interface ConsoleSidebarRail {
+ /** The window is at or above {@link CONSOLE_SIDEBAR_RAIL_BELOW_WIDTH}. */
+ wide: boolean;
/** The window is below {@link CONSOLE_SIDEBAR_RAIL_BELOW_WIDTH} and above the phone breakpoint. */
narrow: boolean;
/** The reader keeps the sidebar open on a narrow window. */
@@ -123,6 +186,7 @@ export interface ConsoleSidebarRail {
export function useConsoleSidebarRail({ enabled = true }: { enabled?: boolean } = {}): ConsoleSidebarRail {
const { isMobile, open } = useSidebar();
const belowRailWidth = useIsViewportBelow(CONSOLE_SIDEBAR_RAIL_BELOW_WIDTH);
+ const belowPhoneWidth = useIsViewportBelow(PHONE_BELOW_WIDTH);
const [keepOpenChoice, setKeepOpenChoice] = useWorkspacePreference(
CONSOLE_SIDEBAR_KEEP_OPEN_PREFERENCE,
'user',
@@ -132,10 +196,10 @@ export function useConsoleSidebarRail({ enabled = true }: { enabled?: boolean }
(keepOpen: boolean) => setKeepOpenChoice(keepOpen ? 'on' : 'off'),
[setKeepOpenChoice],
);
- const narrow = enabled && belowRailWidth && !isMobile;
+ const narrow = enabled && belowRailWidth && !belowPhoneWidth && !isMobile;
const keepOpen = keepOpenChoice === 'on';
const railMode = narrow && !keepOpen;
- return { narrow, keepOpen, setKeepOpen, railMode, overlayOpen: railMode && open };
+ return { wide: !belowRailWidth, narrow, keepOpen, setKeepOpen, railMode, overlayOpen: railMode && open };
}
/**
@@ -151,6 +215,23 @@ export const RAIL_OVERLAY_FRAME_KEEPS_GAP = '[&>[data-collapsible=icon]>div:firs
/** The panel's classes while the rail is expanded over the content: above the page, with a shadow. */
export const RAIL_OVERLAY_PANEL = 'z-40 shadow-xl';
+/** A layer drawn over the page that takes Esc for itself. */
+const ESCAPE_LAYER = '[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"]';
+
+/**
+ * Would this Esc go to a tooltip nobody sees (objectstack#28078)? The
+ * primitive keeps an entry's tooltip open, but hidden, while the sidebar is
+ * expanded: hovering or focusing an entry in the overlay opens it, and its
+ * layer takes the next Esc and marks it handled. Read before any layer's own
+ * Esc listener runs: an open hidden tooltip and no layer shown.
+ */
+function escapeOnlyForHiddenTooltip(): boolean {
+ const shown = (el: Element) => !el.closest('[hidden]');
+ const tooltips = Array.from(document.querySelectorAll('[role="tooltip"]'));
+ if (!tooltips.some((el) => !shown(el))) return false;
+ return !Array.from(document.querySelectorAll(ESCAPE_LAYER)).some(shown);
+}
+
/** What a click on the rail lands on when it is not the rail's background. */
const RAIL_CONTROL =
'a[href], button, input, select, textarea, [role="button"], [role="menuitem"], [role="combobox"], [role="switch"]';
@@ -206,14 +287,22 @@ export function useConsoleSidebarRailPanel(rail: ConsoleSidebarRail): ConsoleSid
if (event.target instanceof Element && event.target.closest('[data-sidebar="trigger"]')) return;
setOpenRef.current(false);
};
+ // On the window in the capture phase, ahead of the layers' own listeners.
+ let hiddenTooltipEscape = false;
+ const onKeyDownCapture = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') hiddenTooltipEscape = escapeOnlyForHiddenTooltip();
+ };
const onKeyDown = (event: KeyboardEvent) => {
- // A layer that handled Esc (a menu, a dialog) marks it handled.
- if (event.key === 'Escape' && !event.defaultPrevented) setOpenRef.current(false);
+ if (event.key !== 'Escape') return;
+ // A layer that handled Esc (a menu, a dialog) marks it handled; a hidden tooltip does not count.
+ if (!event.defaultPrevented || hiddenTooltipEscape) setOpenRef.current(false);
};
document.addEventListener('pointerdown', onPointerDown);
+ window.addEventListener('keydown', onKeyDownCapture, true);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
+ window.removeEventListener('keydown', onKeyDownCapture, true);
document.removeEventListener('keydown', onKeyDown);
};
}, [overlayOpen]);