diff --git a/app/(sandbox)/plugin-kit.css b/app/(sandbox)/plugin-kit.css
index 19718a50e..d2fbef0a3 100644
--- a/app/(sandbox)/plugin-kit.css
+++ b/app/(sandbox)/plugin-kit.css
@@ -80,13 +80,18 @@
forced colours. Kit components carry no focus ring of their own. Only
containers focused from script (tabindex="-1" without a control role) skip
it. */
-:is(.bk, .bk *):focus-visible:not([tabindex="-1"]:not(a[href], button, input, select, textarea, [role="button"], [role="menuitem"], [role="option"], [role="tab"], [role="row"], [role="gridcell"], [role="checkbox"], [role="switch"], [role="radio"])) {
+:is(.bk, .bk *):focus-visible:where(:not([tabindex="-1"]:not(a[href], button, input, select, textarea, [role="button"], [role="menuitem"], [role="option"], [role="tab"], [role="row"], [role="gridcell"], [role="checkbox"], [role="switch"], [role="radio"]))) {
outline: 2px solid var(--color-ring) !important;
outline-offset: 2px !important;
}
:is(.bk [role="option"], .bk [role="row"], .bk [role="tab"], .bk .focus-inset, .bk.focus-inset):focus-visible {
outline-offset: -2px !important;
}
+/* A field usually spans the whole frame, and the frame clips what leaves it:
+ drawn outside, its ring only shows above and below it. */
+:is(input.bk, textarea.bk, select.bk, .bk input, .bk textarea, .bk select):focus-visible {
+ outline-offset: -2px !important;
+}
@media (forced-colors: active) {
:is(.bk, .bk *):focus-visible {
outline-color: Highlight !important;
diff --git a/app/accessibility.css b/app/accessibility.css
index e016d2204..5a2c12d50 100644
--- a/app/accessibility.css
+++ b/app/accessibility.css
@@ -130,8 +130,9 @@
}
/* Inside a scroll container a positive offset is clipped; draw it inside.
`focus-inset` does the same for any control that sits flush against the
- edge of a clipping container. */
-:is([role="option"], [role="row"], [role="treeitem"], [role="gridcell"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="tab"], .focus-inset):focus-visible {
+ edge of a clipping container. The selector is padded to outweigh the two
+ rules above, whose :not() lists count towards their specificity. */
+html:root :is([role="option"], [role="row"], [role="treeitem"], [role="gridcell"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="tab"], .focus-inset):focus-visible:focus-visible:focus-visible:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1) !important;
}
/* The focused element scrolls clear of sticky headers and the bottom bar,
diff --git a/app/globals.css b/app/globals.css
index 30a67a4e3..b9aee76aa 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1304,6 +1304,39 @@ html[data-window-controls-left] [data-window-rail] {
inset-inline-end: 3.25rem;
}
+/* Large text in a narrow window leaves the field little width, less than these
+ paddings add up to: the input then stuck out under the bell, with the filter
+ button on top of the magnifier. Here the text gets what the magnifier had. */
+[data-interface-layout="mountain-view"] .mv-topbar-search {
+ container-type: inline-size;
+}
+@container (max-width: 16rem) {
+ [data-interface-layout="mountain-view"] .mv-topbar input[data-search-input] {
+ padding-inline-start: 0.75rem;
+ padding-inline-end: 2.75rem;
+ }
+ [data-interface-layout="mountain-view"] .mv-topbar-search > div > form > div > svg {
+ display: none;
+ }
+ /* Typed text stops at the clear button, and the / key cap makes way for the
+ placeholder. */
+ [data-interface-layout="mountain-view"] .mv-topbar input[data-search-input]:not(:placeholder-shown) {
+ padding-inline-end: 5rem;
+ }
+ [data-interface-layout="mountain-view"] .mv-topbar-search > div > form > div > div kbd {
+ display: none;
+ }
+}
+/* No room left for both: the text goes first. */
+@container (max-width: 9rem) {
+ [data-interface-layout="mountain-view"] .mv-topbar input[data-search-input]:not(:placeholder-shown) {
+ padding-inline-end: 2.75rem;
+ }
+ [data-interface-layout="mountain-view"] .mv-topbar-search > div > form > div > div {
+ display: none;
+ }
+}
+
/* The magnifier sits on the field's baseline grid, one 48px hit area in. */
[data-interface-layout="mountain-view"] .mv-topbar-search > div > form > div > svg {
inset-inline-start: 1rem;
@@ -1460,10 +1493,21 @@ html[data-window-controls-left] [data-window-rail] {
/* The account popover: a wide card with a very round corner, rather
than the narrow menu the rail and the sidebar switchers drop. */
[data-interface-layout="mountain-view"] [data-mv-account-popover] {
- width: var(--mv-popover-w);
+ width: min(var(--mv-popover-w), calc(100vw - 2rem));
border-radius: var(--mv-popover-radius);
}
+/* The compose window docks beside the side panel, not over it: over it, the
+ window hides the panel's Close button and the rail's own buttons. The rail is
+ 3.5rem wide, which grows with the text size, an open panel a fixed 300px plus
+ 8px of margin, which doesn't. */
+[data-interface-layout="mountain-view"]:has([data-mv-side-rail]) {
+ --mv-compose-end: 4.5rem;
+}
+[data-interface-layout="mountain-view"]:has([data-mv-side-panel], [data-plugin-side-panel]) {
+ --mv-compose-end: calc(3.5rem + 308px + 1rem);
+}
+
/* The advanced filters drop from the search field with a small gap and take
its corner, so the two read as one control. Position and width come from
the field's own box, measured by FilterPanelHost. */
diff --git a/components/admin/__tests__/admin-shell-resize.test.tsx b/components/admin/__tests__/admin-shell-resize.test.tsx
new file mode 100644
index 000000000..98086ec5c
--- /dev/null
+++ b/components/admin/__tests__/admin-shell-resize.test.tsx
@@ -0,0 +1,73 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { useEffect, useState } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('next/navigation', () => ({ usePathname: () => '/admin/settings' }));
+vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
+vi.mock('@/components/settings/about-data-settings', () => ({ AboutFooter: () => null }));
+vi.mock('@/lib/browser-navigation', () => ({
+ apiFetch: vi.fn(() => Promise.resolve({ ok: false })),
+ getPathPrefix: () => '',
+ withBasePath: (href: string) => href,
+}));
+
+import { AdminShell } from '../admin-shell';
+
+// Turning a tablet round crosses the breakpoint between the shell's two
+// layouts. The page used to be remounted there, so what the admin had typed
+// and not yet saved was gone without a word.
+describe('AdminShell across the desktop breakpoint', () => {
+ function mockWidth(initial: boolean) {
+ let desktop = initial;
+ const listeners = new Set<() => void>();
+ window.matchMedia = vi.fn((query: string) => ({
+ get matches() { return desktop && query === '(width >= 64rem)'; },
+ media: query,
+ addEventListener: (_: string, cb: () => void) => listeners.add(cb),
+ removeEventListener: (_: string, cb: () => void) => listeners.delete(cb),
+ })) as unknown as typeof window.matchMedia;
+ return (next: boolean) => act(() => {
+ desktop = next;
+ listeners.forEach((cb) => cb());
+ });
+ }
+
+ function Page({ onMount }: { onMount: () => void }) {
+ const [value, setValue] = useState('saved');
+ useEffect(onMount, [onMount]);
+ return
setValue(e.target.value)} />;
+ }
+
+ it('keeps the page mounted, with its edits, in both directions', () => {
+ Element.prototype.scrollTo = () => {};
+ const setDesktop = mockWidth(true);
+ const onMount = vi.fn();
+ render(
+
{}}>
+
+ ,
+ );
+ fireEvent.change(screen.getByRole('textbox', { name: 'field' }), { target: { value: 'edited' } });
+
+ setDesktop(false);
+ expect(screen.getByRole('textbox', { name: 'field' })).toHaveValue('edited');
+ setDesktop(true);
+ expect(screen.getByRole('textbox', { name: 'field' })).toHaveValue('edited');
+ expect(onMount).toHaveBeenCalledTimes(1);
+ });
+
+ // On a laptop the list runs past its fold: a page opened from a link used
+ // to leave its own entry out of sight.
+ it('brings the open page into view in the sidebar', () => {
+ Element.prototype.scrollTo = () => {};
+ const scrollIntoView = vi.fn();
+ Element.prototype.scrollIntoView = scrollIntoView;
+ mockWidth(true);
+ render(
+
{}}>
+ page
+ ,
+ );
+ expect(scrollIntoView.mock.contexts.some((el) => (el as Element).getAttribute('aria-current') === 'page')).toBe(true);
+ });
+});
diff --git a/components/admin/admin-shell.tsx b/components/admin/admin-shell.tsx
index 0c2ffeaf7..fc93a2622 100644
--- a/components/admin/admin-shell.tsx
+++ b/components/admin/admin-shell.tsx
@@ -1,6 +1,7 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
+import { createPortal } from 'react-dom';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { ArrowLeft, ChevronRight, KeyRound, LogOut, Search, Shield, X } from '@/components/icons';
@@ -62,6 +63,16 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
const [pendingApprovals, setPendingApprovals] = useState(0);
const [pendingSection, setPendingSection] = useState<{ page: AdminPageId; id: string } | null>(null);
const scrollRef = useRef
(null);
+ const navRef = useRef(null);
+ // The two layouts are different trees, so a page drawn inside them would be
+ // remounted when the window crosses the breakpoint (a tablet turned round)
+ // and lose what the admin had typed. It lives in one element instead, which
+ // moves into whichever layout is showing. The portal comes after the layout
+ // so the element is in the document before the page's effects run.
+ const [pageHost] = useState(() => (typeof document === 'undefined' ? null : document.createElement('div')));
+ const pageSlot = useCallback((slot: HTMLDivElement | null) => {
+ if (slot && pageHost) slot.appendChild(pageHost);
+ }, [pageHost]);
const onChangePassword = pathname === '/admin/change-password';
const activePage: AdminPageId | null = location.page ?? (isDesktop && !onChangePassword ? 'dashboard' : null);
@@ -95,6 +106,12 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
scrollRef.current?.scrollTo({ top: 0 });
}, [scrollKey]);
+ // A page opened from a link or a reload can be far down the list, under its
+ // fold on a laptop: bring its entry into view.
+ useEffect(() => {
+ navRef.current?.querySelector('[aria-current="page"]')?.scrollIntoView?.({ block: 'nearest' });
+ }, [activePage, isDesktop, hydrated]);
+
// A section picked from the search scrolls into view once its page has
// rendered it; pages fetch first, so retry for a moment.
useEffect(() => {
@@ -257,8 +274,10 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
);
}
+ const showIndex = location.page === null && !onChangePassword;
+ const page = pageHost && (isDesktop || !showIndex) ? createPortal(children, pageHost) : null;
+
if (!isDesktop) {
- const showIndex = location.page === null && !onChangePassword;
const title = onChangePassword ? 'Change password' : activeDef?.label ?? 'Admin';
const backHref = location.detail ? adminPath(location.page) : '/admin';
return (
@@ -346,7 +365,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
) : (
{readOnlyBanner}
- {children}
+
)}
@@ -355,6 +374,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,