Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion code/core/src/components/components/Modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down
8 changes: 7 additions & 1 deletion code/core/src/components/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -142,6 +142,12 @@ const MinimalistPopover: FC<{
onInteractOutside: handleClose,
});

useEffect(() => {
if (popoverRef.current) {
return ariaHideOutside([popoverRef.current], { shouldUseInert: true });
}
}, []);

const { overlayProps: positionProps } = useOverlayPosition({
targetRef: triggerRef,
overlayRef: popoverRef,
Expand Down
3 changes: 3 additions & 0 deletions code/core/src/core-events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_INERT_ATTRIBUTE_CHANGED = 'managerInertAttributeChanged',
}

// Enables: `import Events from ...`
Expand Down Expand Up @@ -159,6 +161,7 @@ export const {
ARGTYPES_INFO_RESPONSE,
OPEN_IN_EDITOR_REQUEST,
OPEN_IN_EDITOR_RESPONSE,
MANAGER_INERT_ATTRIBUTE_CHANGED,
} = events;

export * from './data/create-new-story';
Expand Down
32 changes: 32 additions & 0 deletions code/core/src/manager/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -22,11 +23,42 @@ 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(() => {
Comment thread
Sidnioulz marked this conversation as resolved.
const rootElement = document.getElementById('root');
if (!rootElement) {
return;
}

const observer = new MutationObserver(() => {
const hasInert = rootElement.hasAttribute('inert');
addons.getChannel().emit(Events.MANAGER_INERT_ATTRIBUTE_CHANGED, hasInert);
});

observer.observe(rootElement, {
attributes: true,
attributeFilter: ['inert'],
});

return () => observer.disconnect();
}, []);

return (
<>
<Global styles={createGlobal} />
Expand Down
1 change: 1 addition & 0 deletions code/core/src/manager/globals/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,7 @@ export default {
'FORCE_REMOUNT',
'FORCE_RE_RENDER',
'GLOBALS_UPDATED',
'MANAGER_INERT_ATTRIBUTE_CHANGED',
'NAVIGATE_URL',
'OPEN_IN_EDITOR_REQUEST',
'OPEN_IN_EDITOR_RESPONSE',
Expand Down
20 changes: 19 additions & 1 deletion code/core/src/preview/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TELEMETRY_ERROR } from 'storybook/internal/core-events';
import { MANAGER_INERT_ATTRIBUTE_CHANGED, TELEMETRY_ERROR } from 'storybook/internal/core-events';

import { global } from '@storybook/global';

Expand Down Expand Up @@ -31,6 +31,24 @@ 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_INERT_ATTRIBUTE_CHANGED, (isInert: boolean) => {
if (isInert) {
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);
Expand Down