Skip to content

Conversation

@Sidnioulz
Copy link
Member

@Sidnioulz Sidnioulz commented Nov 26, 2025

Closes #32596

What I did

  • Added an event so that manager can inform preview it is focus trapped
  • Added focus trap detection logic to manager (reads inert attr set by RAC on #root when it traps focus)
  • Added focus trap event handler to preview (enforces inert on preview body to prevent any programmatic focus from happening)

Checklist for Contributors

Testing

The changes in this PR are covered in the following automated tests:

  • stories
  • unit tests
  • integration tests
  • end-to-end tests

Manual testing

  1. Run the code
  2. Navigate to http://localhost:6006/?path=/story/overlay-modal--always-open
  3. Open your favourite manager modal with the keyboard
  4. Navigate within the modal with the keyboard and notice how focus isn't getting stolen any more

Documentation

ø

Checklist for Maintainers

  • When this PR is ready for testing, make sure to add ci:normal, ci:merged or ci:daily GH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found in code/lib/cli-storybook/src/sandbox-templates.ts

  • Make sure this PR contains one of the labels below:

    Available labels
    • bug: Internal changes that fixes incorrect behavior.
    • maintenance: User-facing maintenance tasks.
    • dependencies: Upgrading (sometimes downgrading) dependencies.
    • build: Internal-facing build tooling & test updates. Will not show up in release changelog.
    • cleanup: Minor cleanup style change. Will not show up in release changelog.
    • documentation: Documentation only changes. Will not show up in release changelog.
    • feature request: Introducing a new feature.
    • BREAKING CHANGE: Changes that break compatibility in some way with current major version.
    • other: Changes that don't fit in the above categories.

🦋 Canary release

This PR does not have a canary release associated. You can request a canary release of this pull request by mentioning the @storybookjs/core team here.

core team members can create a canary release here or locally with gh workflow run --repo storybookjs/storybook publish.yml --field pr=<PR_NUMBER>

Summary by CodeRabbit

  • New Features
    • Added focus trap state change detection and event handling. The system now properly synchronizes focus containment state across the application, enabling more reliable focus management during interactions.

✏️ Tip: You can customize this high-level summary in your review settings.

@Sidnioulz Sidnioulz added bug ui ci:normal a11y: keyboard Accessibility issues related to keyboard navigation or shortcuts labels Nov 26, 2025
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 26, 2025

📝 Walkthrough

Walkthrough

A focus trap management event system is introduced across the manager and preview layers. A new MANAGER_FOCUS_TRAP_CHANGE event is defined, exported through public APIs, observed in the manager when the inert attribute changes on the root element, and handled in the preview runtime to synchronize the document body's inert state.

Changes

Cohort / File(s) Summary
Event Definition
code/core/src/core-events/index.ts
Added MANAGER_FOCUS_TRAP_CHANGE event constant with value 'managerFocusTrapChange' to the events enum and exposed via named export.
Manager-Side Focus Trap Observation
code/core/src/manager/App.tsx
Added useEffect hook that uses MutationObserver to watch the root element for inert attribute changes and emits MANAGER_FOCUS_TRAP_CHANGE events with current inert state.
Public Event Exports
code/core/src/manager/globals/exports.ts
Added 'MANAGER_FOCUS_TRAP_CHANGE' string to the public events array under 'storybook/internal/core-events' export.
Preview Runtime Event Subscription
code/core/src/preview/runtime.ts
Imported MANAGER_FOCUS_TRAP_CHANGE and subscribed to it on the addons channel during DOMContentLoaded; sets or removes document.body.inert based on event isActive state.

Sequence Diagram

sequenceDiagram
    participant Manager
    participant Channel as Addons Channel
    participant Preview
    participant DOM

    Manager->>DOM: Observe inert attribute via MutationObserver
    DOM-->>Manager: inert attribute changes
    Manager->>Channel: Emit MANAGER_FOCUS_TRAP_CHANGE event
    Channel->>Preview: Deliver event
    Preview->>DOM: Set or remove document.body.inert
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

  • MutationObserver implementation in App.tsx: Verify proper observer setup, cleanup on unmount, and early exit handling when root element is missing.
  • Event emission logic: Confirm that hasInert state is correctly captured and passed through the event.
  • Preview runtime subscription: Ensure the channel listener is registered at the correct lifecycle phase and that inert attribute manipulation follows accessibility best practices.
  • Public API consistency: Validate that event naming and export patterns align with existing core events conventions.
✨ Finishing touches
  • 📝 Generate docstrings

Tip

📝 Customizable high-level summaries are now available in beta!

You can now customize how CodeRabbit generates the high-level summary in your pull requests — including its content, structure, tone, and formatting.

  • Provide your own instructions using the high_level_summary_instructions setting.
  • Format the summary however you like (bullet lists, tables, multi-section layouts, contributor stats, etc.).
  • Use high_level_summary_in_walkthrough to move the summary from the description to the walkthrough section.

Example instruction:

"Divide the high-level summary into five sections:

  1. 📝 Description — Summarize the main change in 50–60 words, explaining what was done.
  2. 📓 References — List relevant issues, discussions, documentation, or related PRs.
  3. 📦 Dependencies & Requirements — Mention any new/updated dependencies, environment variable changes, or configuration updates.
  4. 📊 Contributor Summary — Include a Markdown table showing contributions:
    | Contributor | Lines Added | Lines Removed | Files Changed |
  5. ✔️ Additional Notes — Add any extra reviewer context.
    Keep each section concise (under 200 words) and use bullet or numbered lists for clarity."

Note: This feature is currently in beta for Pro-tier users, and pricing will be announced later.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (3)
code/core/src/manager/App.tsx (1)

30-51: Use core-events constant instead of string literal and consider emitting initial state

The observer logic and cleanup look good, but two small robustness improvements would help:

  • Import and use the MANAGER_FOCUS_TRAP_CHANGE constant from storybook/internal/core-events instead of the hard‑coded 'managerFocusTrapChange' string to keep the event name single‑sourced and avoid drift with core-events/index.ts.
  • After wiring the observer, consider emitting the current inert state once (e.g., addons.getChannel().emit(MANAGER_FOCUS_TRAP_CHANGE, rootElement.hasAttribute('inert'))) so the preview is correct even if #root already had inert set before this effect starts (for example, if a focus trap uses useLayoutEffect).

A possible sketch:

-import { addons } from 'storybook/manager-api';
+import { addons } from 'storybook/manager-api';
+import { MANAGER_FOCUS_TRAP_CHANGE } from 'storybook/internal/core-events';
@@
-  useEffect(() => {
-    const rootElement = document.getElementById('root');
+  useEffect(() => {
+    const rootElement = document.getElementById('root');
     if (!rootElement) {
       return;
     }
 
-    const observer = new MutationObserver((mutations) => {
+    const channel = addons.getChannel();
+    const emitState = () => {
+      const hasInert = rootElement.hasAttribute('inert');
+      channel.emit(MANAGER_FOCUS_TRAP_CHANGE, hasInert);
+    };
+
+    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);
+          emitState();
         }
       });
     });
@@
-    observer.observe(rootElement, {
+    observer.observe(rootElement, {
       attributes: true,
       attributeFilter: ['inert'],
     });
 
-    return () => observer.disconnect();
+    emitState();
+
+    return () => observer.disconnect();
   }, []);
code/core/src/core-events/index.ts (1)

94-96: Clarify event comment to describe state changes and payload

The enum entry and re‑export look correct and consistent with usage. To make the contract clearer for consumers, consider tightening the comment to reflect that this event is fired on state changes and carries a boolean payload:

-  // Emitted when the manager UI sets up a focus trap
+  // Emitted whenever the manager UI's focus trap state changes; payload: boolean isActive
   MANAGER_FOCUS_TRAP_CHANGE = 'managerFocusTrapChange',

This makes it obvious to listeners (like preview/runtime.ts) what they should expect.

Also applies to: 164-165

code/core/src/preview/runtime.ts (1)

1-1: Register focus-trap listener without depending solely on DOMContentLoaded timing

The event wiring and inert toggling look aligned with the new manager event, but relying on DOMContentLoaded introduces a timing edge case: if setup() runs after DOMContentLoaded has already fired (e.g., depending on how the bundle is loaded), this listener never runs and the MANAGER_FOCUS_TRAP_CHANGE subscription is never installed.

You can avoid this by subscribing immediately and guarding document.body, which should be safe given that focus traps are only triggered on user interaction:

-import { MANAGER_FOCUS_TRAP_CHANGE, TELEMETRY_ERROR } from 'storybook/internal/core-events';
+import { MANAGER_FOCUS_TRAP_CHANGE, TELEMETRY_ERROR } from 'storybook/internal/core-events';
@@
-  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');
-      }
-    });
-  });
+  const channel = global.__STORYBOOK_ADDONS_CHANNEL__;
+  channel.on(MANAGER_FOCUS_TRAP_CHANGE, (isActive: boolean) => {
+    const body = document.body;
+    if (!body) return;
+
+    if (isActive) {
+      body.setAttribute('inert', 'true');
+    } else {
+      body.removeAttribute('inert');
+    }
+  });

If you prefer to keep the DOMContentLoaded guard, consider a document.readyState check:

const subscribe = () => {
  const channel = global.__STORYBOOK_ADDONS_CHANNEL__;
  channel.on(MANAGER_FOCUS_TRAP_CHANGE, /* handler as above */);
};

if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', subscribe, { once: true });
} else {
  subscribe();
}

This ensures the preview always reacts to the manager’s focus‑trap state.

Also applies to: 34-43

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2972335 and 085f4e8.

📒 Files selected for processing (4)
  • code/core/src/core-events/index.ts (2 hunks)
  • code/core/src/manager/App.tsx (1 hunks)
  • code/core/src/manager/globals/exports.ts (1 hunks)
  • code/core/src/preview/runtime.ts (2 hunks)
🧰 Additional context used
📓 Path-based instructions (4)
**/*.{ts,tsx,js,jsx,mjs}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Use camelCase for variable and function names

Files:

  • code/core/src/manager/App.tsx
  • code/core/src/core-events/index.ts
  • code/core/src/manager/globals/exports.ts
  • code/core/src/preview/runtime.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

**/*.{ts,tsx}: Enable TypeScript strict mode
Export functions from modules for testing purposes

Files:

  • code/core/src/manager/App.tsx
  • code/core/src/core-events/index.ts
  • code/core/src/manager/globals/exports.ts
  • code/core/src/preview/runtime.ts
**/*.{ts,tsx,js,jsx,json,html,mjs}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

**/*.{ts,tsx,js,jsx,json,html,mjs}: Use ESLint and Prettier for code style enforcement
Run 'yarn prettier --write ' to format code after making changes
Run 'yarn lint:js:cmd ' to check for ESLint issues after making changes

Files:

  • code/core/src/manager/App.tsx
  • code/core/src/core-events/index.ts
  • code/core/src/manager/globals/exports.ts
  • code/core/src/preview/runtime.ts
code/**/!(*.test).{ts,tsx,js,mjs}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

code/**/!(*.test).{ts,tsx,js,mjs}: Use 'logger' from 'storybook/internal/node-logger' for server-side (Node.js) logging, not console.log/console.warn/console.error
Use 'logger' from 'storybook/internal/client-logger' for client-side (browser) logging, not console.log/console.warn/console.error
Do not use console.log, console.warn, or console.error directly unless in isolated files where importing loggers would significantly increase bundle size

Files:

  • code/core/src/manager/App.tsx
  • code/core/src/core-events/index.ts
  • code/core/src/manager/globals/exports.ts
  • code/core/src/preview/runtime.ts
🧠 Learnings (6)
📓 Common learnings
Learnt from: Sidnioulz
Repo: storybookjs/storybook PR: 32458
File: code/core/src/components/components/Tabs/Tabs.stories.tsx:222-227
Timestamp: 2025-11-05T09:36:55.944Z
Learning: Repo: storybookjs/storybook PR: 32458 — In code/core/src/components/components/Button/Button.tsx (React/TypeScript), ButtonProps includes ariaLabel?: string | false and the component maps it to the DOM aria-label. Convention: ariaLabel is mandatory on all Button usages — provide a descriptive string for icon-only buttons; set ariaLabel=false when the button’s children already serve as the accessible name. Do not suggest using a raw aria-label prop on Button call sites.
Learnt from: Sidnioulz
Repo: storybookjs/storybook PR: 32458
File: code/core/src/manager/components/preview/Toolbar.tsx:102-105
Timestamp: 2025-10-03T07:55:42.639Z
Learning: In code/core/src/manager/components/preview/Toolbar.tsx, we intentionally do not add aria-label/aria-labelledby to StyledToolbar (AbstractToolbar) because the enclosing section is already labeled via an sr-only heading and the toolbar is the only content. Revisit only if real user testing indicates a need.
Learnt from: ndelangen
Repo: storybookjs/storybook PR: 32507
File: code/core/src/manager/globals/globals-module-info.ts:25-33
Timestamp: 2025-09-24T09:39:39.233Z
Learning: In Storybook, storybook/actions/decorator is a preview-only entrypoint and should not be included in manager globals configuration. The duplicatedKeys array in code/core/src/manager/globals/globals-module-info.ts is specifically for manager-side externalization, not preview entrypoints.
📚 Learning: 2025-09-24T09:39:39.233Z
Learnt from: ndelangen
Repo: storybookjs/storybook PR: 32507
File: code/core/src/manager/globals/globals-module-info.ts:25-33
Timestamp: 2025-09-24T09:39:39.233Z
Learning: In Storybook, storybook/actions/decorator is a preview-only entrypoint and should not be included in manager globals configuration. The duplicatedKeys array in code/core/src/manager/globals/globals-module-info.ts is specifically for manager-side externalization, not preview entrypoints.

Applied to files:

  • code/core/src/manager/globals/exports.ts
  • code/core/src/preview/runtime.ts
📚 Learning: 2025-09-18T20:51:06.618Z
Learnt from: Sidnioulz
Repo: storybookjs/storybook PR: 32458
File: code/core/src/viewport/components/Tool.tsx:38-39
Timestamp: 2025-09-18T20:51:06.618Z
Learning: The useGlobals hook from storybook/manager-api returns a tuple where the third element (storyGlobals) is typed as Globals, not Globals | undefined. This means TypeScript guarantees it's always defined, making the `in` operator safe to use without additional null checks.

Applied to files:

  • code/core/src/preview/runtime.ts
📚 Learning: 2025-11-24T17:49:47.659Z
Learnt from: CR
Repo: storybookjs/storybook PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-11-24T17:49:47.659Z
Learning: Applies to code/**/!(*.test).{ts,tsx,js,mjs} : Use 'logger' from 'storybook/internal/client-logger' for client-side (browser) logging, not console.log/console.warn/console.error

Applied to files:

  • code/core/src/preview/runtime.ts
📚 Learning: 2025-09-18T20:51:06.618Z
Learnt from: Sidnioulz
Repo: storybookjs/storybook PR: 32458
File: code/core/src/viewport/components/Tool.tsx:38-39
Timestamp: 2025-09-18T20:51:06.618Z
Learning: In viewport tool code, when using the `useGlobals` hook from storybook/manager-api, the third returned value `storyGlobals` is guaranteed by TypeScript to be defined (not undefined/null), making the `in` operator safe to use without additional null checks.

Applied to files:

  • code/core/src/preview/runtime.ts
📚 Learning: 2025-11-24T17:49:47.659Z
Learnt from: CR
Repo: storybookjs/storybook PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-11-24T17:49:47.659Z
Learning: Applies to code/**/!(*.test).{ts,tsx,js,mjs} : Use 'logger' from 'storybook/internal/node-logger' for server-side (Node.js) logging, not console.log/console.warn/console.error

Applied to files:

  • code/core/src/preview/runtime.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: normal
  • GitHub Check: Core Unit Tests, windows-latest
  • GitHub Check: get-branch
🔇 Additional comments (1)
code/core/src/manager/globals/exports.ts (1)

574-636: LGTM on adding MANAGER_FOCUS_TRAP_CHANGE to globals

Including 'MANAGER_FOCUS_TRAP_CHANGE' in the storybook/internal/core-events export list is consistent with other core events and keeps the globals surface aligned with core-events/index.ts.

@nx-cloud
Copy link

nx-cloud bot commented Nov 26, 2025

View your CI Pipeline Execution ↗ for commit 085f4e8

Command Status Duration Result
nx run-many -t check -c production --parallel=7 ✅ Succeeded 1s View ↗
nx run-many -t build -c production --parallel=3 ✅ Succeeded 2s View ↗

☁️ Nx Cloud last updated this comment at 2025-11-26 12:26:29 UTC

@Sidnioulz Sidnioulz added the patch:yes Bugfix & documentation PR that need to be picked to main branch label Nov 26, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a11y: keyboard Accessibility issues related to keyboard navigation or shortcuts bug ci:normal patch:yes Bugfix & documentation PR that need to be picked to main branch ui

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Focus trap war between preview and manager when both have an overlay open

2 participants