From 6b9bcd47509983cba49bba807423151f50029c9a Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Wed, 26 Nov 2025 13:01:21 +0100 Subject: [PATCH 1/5] Preview: Enforce inert body if manager is focus-trapped --- code/core/src/core-events/index.ts | 3 +++ code/core/src/manager/App.tsx | 23 +++++++++++++++++++++++ code/core/src/manager/globals/exports.ts | 1 + code/core/src/preview/runtime.ts | 13 ++++++++++++- 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/code/core/src/core-events/index.ts b/code/core/src/core-events/index.ts index f2acd7739990..1805aa43b57a 100644 --- a/code/core/src/core-events/index.ts +++ b/code/core/src/core-events/index.ts @@ -91,6 +91,8 @@ enum events { // Open a file in the code editor OPEN_IN_EDITOR_REQUEST = 'openInEditorRequest', OPEN_IN_EDITOR_RESPONSE = 'openInEditorResponse', + // Emitted when the manager UI sets up a focus trap + MANAGER_FOCUS_TRAP_CHANGE = 'managerFocusTrapChange', } // Enables: `import Events from ...` @@ -159,6 +161,7 @@ export const { ARGTYPES_INFO_RESPONSE, OPEN_IN_EDITOR_REQUEST, OPEN_IN_EDITOR_RESPONSE, + MANAGER_FOCUS_TRAP_CHANGE, } = events; export * from './data/create-new-story'; diff --git a/code/core/src/manager/App.tsx b/code/core/src/manager/App.tsx index 962fe6309b2b..93dec038c699 100644 --- a/code/core/src/manager/App.tsx +++ b/code/core/src/manager/App.tsx @@ -27,6 +27,29 @@ export const App = ({ managerLayoutState, setManagerLayoutState, pages, hasTab } document.body.setAttribute('data-shortcuts-enabled', enableShortcuts ? 'true' : 'false'); }, [enableShortcuts]); + useEffect(() => { + const rootElement = document.getElementById('root'); + if (!rootElement) { + return; + } + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes' && mutation.attributeName === 'inert') { + const hasInert = rootElement.hasAttribute('inert'); + addons.getChannel().emit('managerFocusTrapChange', hasInert); + } + }); + }); + + observer.observe(rootElement, { + attributes: true, + attributeFilter: ['inert'], + }); + + return () => observer.disconnect(); + }, []); + return ( <> diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index 4f855b346150..2eeb077f843c 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -587,6 +587,7 @@ export default { 'FORCE_REMOUNT', 'FORCE_RE_RENDER', 'GLOBALS_UPDATED', + 'MANAGER_FOCUS_TRAP_CHANGE', 'NAVIGATE_URL', 'OPEN_IN_EDITOR_REQUEST', 'OPEN_IN_EDITOR_RESPONSE', diff --git a/code/core/src/preview/runtime.ts b/code/core/src/preview/runtime.ts index 58d509c93e04..a6c68462731b 100644 --- a/code/core/src/preview/runtime.ts +++ b/code/core/src/preview/runtime.ts @@ -1,4 +1,4 @@ -import { TELEMETRY_ERROR } from 'storybook/internal/core-events'; +import { MANAGER_FOCUS_TRAP_CHANGE, TELEMETRY_ERROR } from 'storybook/internal/core-events'; import { global } from '@storybook/global'; @@ -31,6 +31,17 @@ export function setup() { channel.emit(TELEMETRY_ERROR, prepareForTelemetry(error)); }; + document.addEventListener('DOMContentLoaded', () => { + const channel = global.__STORYBOOK_ADDONS_CHANNEL__; + channel.on(MANAGER_FOCUS_TRAP_CHANGE, (isActive: boolean) => { + if (isActive) { + document.body.setAttribute('inert', 'true'); + } else { + document.body.removeAttribute('inert'); + } + }); + }); + // handle all uncaught StorybookError at the root of the application and log to telemetry if applicable global.addEventListener('error', errorListener); global.addEventListener('unhandledrejection', unhandledRejectionListener); From ae53acb37f060c8ba1499823dc5ef93c2c87a931 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Wed, 3 Dec 2025 18:21:07 +0100 Subject: [PATCH 2/5] Core: Make improvements to inert sync logic --- code/core/src/core-events/index.ts | 4 ++-- code/core/src/manager/App.tsx | 23 ++++++++++++++++------- code/core/src/manager/globals/exports.ts | 2 +- code/core/src/preview/runtime.ts | 13 ++++++++++--- 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/code/core/src/core-events/index.ts b/code/core/src/core-events/index.ts index 1805aa43b57a..bcd990b30aab 100644 --- a/code/core/src/core-events/index.ts +++ b/code/core/src/core-events/index.ts @@ -92,7 +92,7 @@ enum events { OPEN_IN_EDITOR_REQUEST = 'openInEditorRequest', OPEN_IN_EDITOR_RESPONSE = 'openInEditorResponse', // Emitted when the manager UI sets up a focus trap - MANAGER_FOCUS_TRAP_CHANGE = 'managerFocusTrapChange', + MANAGER_INERT_ATTRIBUTE_CHANGED = 'managerInertAttributeChanged', } // Enables: `import Events from ...` @@ -161,7 +161,7 @@ export const { ARGTYPES_INFO_RESPONSE, OPEN_IN_EDITOR_REQUEST, OPEN_IN_EDITOR_RESPONSE, - MANAGER_FOCUS_TRAP_CHANGE, + MANAGER_INERT_ATTRIBUTE_CHANGED, } = events; export * from './data/create-new-story'; diff --git a/code/core/src/manager/App.tsx b/code/core/src/manager/App.tsx index 93dec038c699..cecbc87f4809 100644 --- a/code/core/src/manager/App.tsx +++ b/code/core/src/manager/App.tsx @@ -1,6 +1,7 @@ import type { ComponentProps } from 'react'; import React, { useEffect } from 'react'; +import Events from 'storybook/internal/core-events'; import type { Addon_PageType } from 'storybook/internal/types'; import { addons } from 'storybook/manager-api'; @@ -22,24 +23,32 @@ type Props = { export const App = ({ managerLayoutState, setManagerLayoutState, pages, hasTab }: Props) => { const { setMobileAboutOpen } = useLayout(); + /** + * Lets us tell the UI whether or not keyboard shortcuts are enabled, in places where it's not + * convenient to load the addons singleton to figure it out. + */ const { enableShortcuts = true } = addons.getConfig(); useEffect(() => { document.body.setAttribute('data-shortcuts-enabled', enableShortcuts ? 'true' : 'false'); }, [enableShortcuts]); + /** + * Detects when our component library has enabled a focus trap. By convention, react-aria sets the + * document root to `inert` when a focus trap is enabled. We observe that attribute and inform the + * preview iframe when to respect the focus trap, via a channel event. This is necessary because + * inert is no longer propagated into iframes as per https://github.com/whatwg/html/issues/7605, + * and the replacement permission policy is not yet widely available + * (https://github.com/w3c/webappsec-permissions-policy/issues/273). + */ useEffect(() => { const rootElement = document.getElementById('root'); if (!rootElement) { return; } - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.type === 'attributes' && mutation.attributeName === 'inert') { - const hasInert = rootElement.hasAttribute('inert'); - addons.getChannel().emit('managerFocusTrapChange', hasInert); - } - }); + const observer = new MutationObserver(() => { + const hasInert = rootElement.hasAttribute('inert'); + addons.getChannel().emit(Events.MANAGER_INERT_ATTRIBUTE_CHANGED, hasInert); }); observer.observe(rootElement, { diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index 2eeb077f843c..4a66831516fc 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -587,7 +587,7 @@ export default { 'FORCE_REMOUNT', 'FORCE_RE_RENDER', 'GLOBALS_UPDATED', - 'MANAGER_FOCUS_TRAP_CHANGE', + 'MANAGER_INERT_ATTRIBUTE_CHANGED', 'NAVIGATE_URL', 'OPEN_IN_EDITOR_REQUEST', 'OPEN_IN_EDITOR_RESPONSE', diff --git a/code/core/src/preview/runtime.ts b/code/core/src/preview/runtime.ts index a6c68462731b..7f2cd0e38b64 100644 --- a/code/core/src/preview/runtime.ts +++ b/code/core/src/preview/runtime.ts @@ -1,4 +1,4 @@ -import { MANAGER_FOCUS_TRAP_CHANGE, TELEMETRY_ERROR } from 'storybook/internal/core-events'; +import { MANAGER_INERT_ATTRIBUTE_CHANGED, TELEMETRY_ERROR } from 'storybook/internal/core-events'; import { global } from '@storybook/global'; @@ -31,10 +31,17 @@ export function setup() { channel.emit(TELEMETRY_ERROR, prepareForTelemetry(error)); }; + /** + * Ensure we synchronise the preview runtime's inert state with the manager's. The inert attribute + * used to be propagated into iframes, but this has changed, breaking focus trap implementations + * that rely on inert on the document root. We synchronise inert to ensure end user components + * don't programmatically focus when a focus trap is active in the manager UI. Otherwise, the UI + * could reach a deadlock state and be unusable. + */ document.addEventListener('DOMContentLoaded', () => { const channel = global.__STORYBOOK_ADDONS_CHANNEL__; - channel.on(MANAGER_FOCUS_TRAP_CHANGE, (isActive: boolean) => { - if (isActive) { + channel.on(MANAGER_INERT_ATTRIBUTE_CHANGED, (isInert: boolean) => { + if (isInert) { document.body.setAttribute('inert', 'true'); } else { document.body.removeAttribute('inert'); From 5809fbde15030693c8b28b97d1cdbff6f2b3fe88 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Wed, 3 Dec 2025 18:21:23 +0100 Subject: [PATCH 3/5] UI: Ensure Modal sets body inert --- code/core/src/components/components/Modal/Modal.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/code/core/src/components/components/Modal/Modal.tsx b/code/core/src/components/components/Modal/Modal.tsx index 80b839bbfae2..b2f734ae30db 100644 --- a/code/core/src/components/components/Modal/Modal.tsx +++ b/code/core/src/components/components/Modal/Modal.tsx @@ -4,7 +4,12 @@ import { deprecate } from 'storybook/internal/client-logger'; import type { DecoratorFunction } from 'storybook/internal/csf'; import { FocusScope } from '@react-aria/focus'; -import { Overlay, UNSAFE_PortalProvider, useModalOverlay } from '@react-aria/overlays'; +import { + Overlay, + UNSAFE_PortalProvider, + ariaHideOutside, + useModalOverlay, +} from '@react-aria/overlays'; import { mergeProps } from '@react-aria/utils'; import { useOverlayTriggerState } from '@react-stately/overlays'; import type { KeyboardEvent as RAKeyboardEvent } from '@react-types/shared'; @@ -169,6 +174,12 @@ function BaseModal({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isMounted]); + useEffect(() => { + if (isMounted && (open || defaultOpen) && overlayRef.current) { + return ariaHideOutside([overlayRef.current], { shouldUseInert: true }); + } + }, [isMounted, open, defaultOpen, overlayRef]); + if (!isMounted || status === 'exited' || status === 'unmounted') { return null; } From cfb765e404ad65ede8d0812c603fe431a263a147 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Wed, 3 Dec 2025 18:24:22 +0100 Subject: [PATCH 4/5] UI: Ensure Select sets body inert --- code/core/src/components/components/Select/Select.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/code/core/src/components/components/Select/Select.tsx b/code/core/src/components/components/Select/Select.tsx index 961e2990e9e6..2110f9ee2079 100644 --- a/code/core/src/components/components/Select/Select.tsx +++ b/code/core/src/components/components/Select/Select.tsx @@ -4,7 +4,7 @@ import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } import { RefreshIcon } from '@storybook/icons'; import { useInteractOutside } from '@react-aria/interactions'; -import { Overlay, useOverlay, useOverlayPosition } from '@react-aria/overlays'; +import { Overlay, ariaHideOutside, useOverlay, useOverlayPosition } from '@react-aria/overlays'; import { useObjectRef } from '@react-aria/utils'; import { useOverlayTriggerState } from '@react-stately/overlays'; import { darken, transparentize } from 'polished'; @@ -142,6 +142,12 @@ const MinimalistPopover: FC<{ onInteractOutside: handleClose, }); + useEffect(() => { + if (popoverRef.current) { + return ariaHideOutside([popoverRef.current], { shouldUseInert: true }); + } + }, [popoverRef]); + const { overlayProps: positionProps } = useOverlayPosition({ targetRef: triggerRef, overlayRef: popoverRef, From d1f9e60033e87257a8e91ded99452c72860ccf2d Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 5 Dec 2025 16:35:49 +0100 Subject: [PATCH 5/5] UI: Remove pointless hook dependency --- code/core/src/components/components/Select/Select.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/components/components/Select/Select.tsx b/code/core/src/components/components/Select/Select.tsx index 2110f9ee2079..2623342646be 100644 --- a/code/core/src/components/components/Select/Select.tsx +++ b/code/core/src/components/components/Select/Select.tsx @@ -146,7 +146,7 @@ const MinimalistPopover: FC<{ if (popoverRef.current) { return ariaHideOutside([popoverRef.current], { shouldUseInert: true }); } - }, [popoverRef]); + }, []); const { overlayProps: positionProps } = useOverlayPosition({ targetRef: triggerRef,