diff --git a/web/src/app.css b/web/src/app.css index 1a18c361..52c52004 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -727,8 +727,11 @@ dialog.attachment-image-lightbox .attachment-image-lightbox-close:hover { /* Modals / dialogs / tooltips. Most KeyboardShortcuts-style overlays carry role="dialog" on the backdrop. The shared Modal primitive uses a native (implicit dialog role, no literal role attribute), so it - is covered by the `dialog[open]` selector above. */ - [role="dialog"], + is covered by the `dialog[open]` selector above. + `:not(.item-pane)` keeps the detail pane printable: on mobile the pane + carries role="dialog" (TASK-2131) but IS the content being printed, not a + transient overlay to strip. */ + [role="dialog"]:not(.item-pane), [role="tooltip"], [role="menu"] { display: none !important; diff --git a/web/src/lib/collections/paneFocus.svelte.test.ts b/web/src/lib/collections/paneFocus.svelte.test.ts index 96a9d14e..1fd1288b 100644 --- a/web/src/lib/collections/paneFocus.svelte.test.ts +++ b/web/src/lib/collections/paneFocus.svelte.test.ts @@ -4,6 +4,7 @@ import { paneFocusables, nextTrapTarget, resolvePaneReturnTarget, + inExemptSurface, } from './paneFocus'; // jsdom has no layout engine, so `offsetParent` / `getClientRects` can't gate @@ -64,6 +65,47 @@ describe('paneFocusables', () => { }); }); +describe('inExemptSurface', () => { + it('does NOT treat the pane region itself as exempt (TASK-2131)', () => { + // The mobile overlay carries role="dialog"; an in-pane control must not be + // seen as an exempt foreign surface (that killed the mobile Tab trap and + // confused the focus-follows classifier). Excluded via `:not(.item-pane)`. + const root = mount(` + + `); + expect(inExemptSurface(root.querySelector('#in-pane'))).toBe(false); + }); + + it('treats a nested dialog/menu opened FROM the pane as exempt', () => { + // A real overlay (e.g. a BottomSheet or field-select menu) that portals in + // while the pane is open still owns its own focus/keyboard. + const root = mount(` + + + `); + expect(inExemptSurface(root.querySelector('#menu-item'))).toBe(true); + expect(inExemptSurface(root.querySelector('#sheet-btn'))).toBe(true); + }); + + it('treats a native and standalone overlays as exempt; nothing else', () => { + const root = mount(` + +
option
+
+ + `); + expect(inExemptSurface(root.querySelector('#native'))).toBe(true); + expect(inExemptSurface(root.querySelector('#opt'))).toBe(true); + expect(inExemptSurface(root.querySelector('#ctx'))).toBe(true); + expect(inExemptSurface(root.querySelector('#plain'))).toBe(false); + expect(inExemptSurface(null)).toBe(false); + }); +}); + describe('nextTrapTarget', () => { function setup() { const container = mount(`123`); diff --git a/web/src/lib/collections/paneFocus.ts b/web/src/lib/collections/paneFocus.ts index 7e0ebf15..f447169f 100644 --- a/web/src/lib/collections/paneFocus.ts +++ b/web/src/lib/collections/paneFocus.ts @@ -57,9 +57,16 @@ export function paneFocusables( * must not read a focus / pointerdown on them as a master↔pane switch. * ONE definition shared by both call sites (PaneHost's mobile trap + the host * route's activePane classifier) so the two can't drift. + * + * `[role="dialog"]:not(.item-pane)` EXCLUDES the pane region itself (TASK-2131): + * the mobile overlay now carries `role="dialog"` (a true modal), but it is the + * region these consumers operate ON, not a foreign overlay to defer to — without + * the `:not`, `closest()` from any in-pane element would match the pane and + * wrongly mark the whole pane exempt (killing the mobile Tab trap and confusing + * the classifier). A genuinely nested dialog opened FROM the pane still matches. */ export const PANE_EXEMPT_SURFACE_SELECTOR = - 'dialog, [role="dialog"], [role="menu"], [role="listbox"], .block-context-menu'; + 'dialog, [role="dialog"]:not(.item-pane), [role="menu"], [role="listbox"], .block-context-menu'; /** True when `el` (or an ancestor) is one of the {@link PANE_EXEMPT_SURFACE_SELECTOR} overlays. */ export function inExemptSurface(el: Element | null | undefined): boolean { diff --git a/web/src/lib/components/collections/PaneHost.svelte b/web/src/lib/components/collections/PaneHost.svelte index f5eb6c72..d7849e4f 100644 --- a/web/src/lib/components/collections/PaneHost.svelte +++ b/web/src/lib/components/collections/PaneHost.svelte @@ -26,6 +26,7 @@ import { onDestroy, untrack } from 'svelte'; import ItemDetail from '$lib/components/items/ItemDetail.svelte'; import { viewport } from '$lib/stores/breakpoint.svelte'; + import { paneOverlay } from '$lib/stores/paneOverlay.svelte'; import { paneFocusables, nextTrapTarget, inExemptSurface } from '$lib/collections/paneFocus'; import type { PaneTarget } from '$lib/types'; @@ -330,10 +331,11 @@ // can't see these). // Both DEFER to a native modal (Share / Edit Collection / the // Open-Children confirm — `Modal.svelte` uses `showModal()`), which renders - // in the top layer above the overlay and owns its own focus cycle. `