Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a0745ab
UI: Add isolation mode shortcut
yannbf Nov 18, 2025
50cc86d
Core: Add openInIsolation method to Storybook API
yannbf Nov 18, 2025
32fc146
Document api methods
yannbf Nov 18, 2025
a25cbd7
move openInIsolation to story module
yannbf Nov 18, 2025
e1d1ab4
Merge branch 'next' into yann/add-isolation-mode-shortcut
ndelangen Nov 18, 2025
0ac7ecd
Merge branch 'next' into yann/add-isolation-mode-shortcut
yannbf Nov 18, 2025
e4ca504
Merge branch 'yann/add-isolation-mode-shortcut' of github.com:storybo…
yannbf Nov 18, 2025
eb99b4d
Merge branch 'next' into yann/add-isolation-mode-shortcut
ndelangen Nov 19, 2025
cb0846b
Merge branch 'next' into yann/add-isolation-mode-shortcut
yannbf Nov 20, 2025
27582a0
remove unnecessary types
yannbf Nov 20, 2025
19b37e9
Merge branch 'yann/add-isolation-mode-shortcut' of github.com:storybo…
yannbf Nov 20, 2025
b1f7c9f
Merge branch 'next' into yann/add-isolation-mode-shortcut
yannbf Nov 20, 2025
2c0792d
Merge branch 'next' into yann/add-isolation-mode-shortcut
yannbf Nov 20, 2025
577be7b
fix tests
yannbf Nov 20, 2025
2c7459d
Merge branch 'next' into yann/add-isolation-mode-shortcut
ndelangen Nov 20, 2025
e6d9ba4
Merge branch 'next' into yann/add-isolation-mode-shortcut
ndelangen Nov 21, 2025
86fcfd2
Merge branch 'next' into yann/add-isolation-mode-shortcut
yannbf Nov 26, 2025
1c4b8fc
Merge branch 'next' into yann/add-isolation-mode-shortcut
yannbf Dec 3, 2025
edc546d
Merge branch 'next' into yann/add-isolation-mode-shortcut
valentinpalkovic Dec 9, 2025
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
7 changes: 7 additions & 0 deletions code/core/src/manager-api/modules/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export interface API_Shortcuts {
remount: API_KeyCollection;
openInEditor: API_KeyCollection;
copyStoryLink: API_KeyCollection;
openInIsolation: API_KeyCollection;
// TODO: bring this back once we want to add shortcuts for this
// copyStoryName: API_KeyCollection;
}
Expand Down Expand Up @@ -153,6 +154,7 @@ export const defaultShortcuts: API_Shortcuts = Object.freeze({
remount: ['alt', 'R'],
openInEditor: ['alt', 'shift', 'E'],
copyStoryLink: ['alt', 'shift', 'L'],
openInIsolation: ['alt', 'shift', 'I'],
// TODO: bring this back once we want to add shortcuts for this
// copyStoryName: ['alt', 'shift', 'C'],
});
Expand Down Expand Up @@ -397,6 +399,11 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => {
}
break;
}
case 'openInIsolation': {
const { refId, storyId } = store.getState();
fullAPI.openInIsolation(storyId, refId);
break;
}
// TODO: bring this back once we want to add shortcuts for this
// case 'copyStoryName': {
// const storyData = fullAPI.getCurrentStoryData();
Expand Down
38 changes: 38 additions & 0 deletions code/core/src/manager-api/modules/stories.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { logger } from 'storybook/internal/client-logger';
import { getStoryHref } from 'storybook/internal/components';
import {
CONFIG_ERROR,
CURRENT_STORY_WAS_SET,
Expand Down Expand Up @@ -291,6 +292,17 @@ export interface SubAPI {
* @returns {Promise<void>} A promise that resolves when the state has been updated.
*/
experimental_setFilter: (addonId: string, filterFunction: API_FilterFunction) => Promise<void>;
/**
* Opens a story in isolation mode in a new tab/window.
*
* @param {string} storyId - The ID of the story to open.
* @param {string | null | undefined} refId - The ID of the ref for the story. Pass null/undefined
* for local stories.
* @param {'story' | 'docs'} viewMode - The view mode to open the story in. Defaults to current
* view mode.
* @returns {void}
*/
openInIsolation: (storyId: string, refId?: string | null, viewMode?: 'story' | 'docs') => void;
}

const removedOptions = ['enableShortcuts', 'theme', 'showRoots'];
Expand Down Expand Up @@ -707,6 +719,32 @@ export const init: ModuleFn<SubAPI, SubState> = ({

provider.channel?.emit(SET_FILTER, { id });
},

openInIsolation: (storyId, refId, viewMode) => {
const { location } = global.document;
const { refs, customQueryParams, viewMode: currentViewMode } = store.getState();

// Get the ref object from refs map using refId
const ref = refId ? refs[refId] : null;

let baseUrl = `${location.origin}${location.pathname}`;

if (!baseUrl.endsWith('/')) {
baseUrl += '/';
}

const iframeUrl = ref
? `${ref.url}/iframe.html`
: (global.PREVIEW_URL as string) || `${baseUrl}iframe.html`;

const storyViewMode = viewMode ?? currentViewMode;

const href = getStoryHref(iframeUrl, storyId, {
...customQueryParams,
...(storyViewMode && { viewMode: storyViewMode }),
});
global.window.open(href, '_blank', 'noopener,noreferrer');
},
Comment on lines +723 to +747
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Add validation for invalid refId to match existing patterns.

The implementation should validate that refId exists in the refs map when provided, similar to the resolveStory method (line 350-353). Currently, if an invalid refId is provided, the code silently falls back to the local iframe URL, which could mask configuration errors.

Apply this diff to add validation:

 openInIsolation: (storyId, refId, viewMode) => {
   const { location } = global.document;
   const { refs, customQueryParams, viewMode: currentViewMode } = store.getState();

-  // Get the ref object from refs map using refId
+  // Validate and get the ref object from refs map using refId
+  if (refId && !refs[refId]) {
+    logger.warn(`Ref with id ${refId} does not exist`);
+    return;
+  }
+
   const ref = refId ? refs[refId] : null;

   let baseUrl = `${location.origin}${location.pathname}`;

This ensures consistent behavior across the API and provides clear feedback when an invalid refId is used.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In code/core/src/manager-api/modules/stories.ts around lines 723 to 747, the
openInIsolation function does not validate that a provided refId exists in the
refs map and silently falls back to the local iframe URL; update the function to
check if refId is provided and refs[refId] is missing, and throw a clear error
(e.g. "Unknown refId: <refId>") to match resolveStory's behavior — perform the
validation immediately after reading refs and before building iframeUrl, and
keep the rest of the logic unchanged.

};

// On initial load, the local iframe will select the first story (or other "selection specifier")
Expand Down
1 change: 1 addition & 0 deletions code/core/src/manager-api/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export type API = addons.SubAPI &
version.SubAPI &
url.SubAPI &
whatsnew.SubAPI &
openInEditor.SubAPI &
Other;

interface Other {
Expand Down
1 change: 1 addition & 0 deletions code/core/src/manager-api/tests/stories.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @vitest-environment happy-dom
import type { Mocked } from 'vitest';
import { describe, expect, it, vi } from 'vitest';

Expand Down
45 changes: 27 additions & 18 deletions code/core/src/manager/components/preview/tools/share.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import React, { useMemo, useState } from 'react';

import {
Button,
PopoverProvider,
TooltipLinkList,
getStoryHref,
} from 'storybook/internal/components';
import { Button, PopoverProvider, TooltipLinkList } from 'storybook/internal/components';
import type { Addon_BaseType } from 'storybook/internal/types';

import { global } from '@storybook/global';
Expand Down Expand Up @@ -78,23 +73,22 @@ const QRDescription = styled.div(({ theme }) => ({
}));

function ShareMenu({
baseUrl,
storyId,
queryParams,
qrUrl,
isDevelopment,
refId,
storyId,
}: {
baseUrl: string;
storyId: string;
queryParams: Record<string, any>;
qrUrl: string;
isDevelopment: boolean;
refId: string | null | undefined;
storyId: string;
}) {
const api = useStorybookApi();
const shortcutKeys = api.getShortcutKeys();
const enableShortcuts = !!shortcutKeys;
const [copied, setCopied] = useState(false);
const copyStoryLink = shortcutKeys?.copyStoryLink;
const openInIsolation = shortcutKeys?.openInIsolation;

const links = useMemo(() => {
const copyTitle = copied ? 'Copied!' : 'Copy story link';
Expand All @@ -113,11 +107,11 @@ function ShareMenu({
},
{
id: 'open-new-tab',
title: 'Open in isolation mode',
title: 'Open in isolation',
icon: <BugIcon />,
right: enableShortcuts ? <Shortcut keys={openInIsolation} /> : null,
onClick: () => {
const href = getStoryHref(baseUrl, storyId, queryParams);
window.open(href, '_blank', 'noopener,noreferrer');
api.openInIsolation(storyId, refId);
},
},
],
Expand All @@ -144,7 +138,17 @@ function ShareMenu({
]);

return baseLinks;
}, [baseUrl, storyId, queryParams, copied, qrUrl, enableShortcuts, copyStoryLink, isDevelopment]);
}, [
copied,
qrUrl,
enableShortcuts,
copyStoryLink,
isDevelopment,
api,
openInIsolation,
refId,
storyId,
]);

return <TooltipLinkList links={links} style={{ width: 210 }} />;
}
Expand All @@ -157,7 +161,7 @@ export const shareTool: Addon_BaseType = {
render: () => {
return (
<Consumer filter={mapper}>
{({ baseUrl, storyId, queryParams }) => {
{({ storyId, refId }) => {
const isDevelopment = global.CONFIG_TYPE === 'DEVELOPMENT';
const storyUrl = global.STORYBOOK_NETWORK_ADDRESS
? new URL(window.location.search, global.STORYBOOK_NETWORK_ADDRESS).href
Expand All @@ -169,7 +173,12 @@ export const shareTool: Addon_BaseType = {
placement="bottom"
padding={0}
popover={
<ShareMenu {...{ baseUrl, storyId, queryParams, qrUrl: storyUrl, isDevelopment }} />
<ShareMenu
qrUrl={storyUrl}
isDevelopment={isDevelopment}
refId={refId}
storyId={storyId}
/>
}
>
<Button padding="small" variant="ghost" ariaLabel="Share" tooltip="Share...">
Expand Down
27 changes: 27 additions & 0 deletions docs/_snippets/storybook-addons-api-addnotification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
```js
import { addons } from '@storybook/manager-api';
import { CheckIcon } from '@storybook/icons';

addons.register('my-organisation/my-addon', (api) => {
// Add a simple notification
api.addNotification({
id: 'my-notification',
content: {
headline: 'Action completed',
subHeadline: 'Your changes have been saved successfully',
},
duration: 5000, // 5 seconds
});

// Add a notification with an icon
api.addNotification({
id: 'success-notification',
content: {
headline: 'Success!',
subHeadline: 'Operation completed successfully',
},
icon: <CheckIcon />,
duration: 3000,
});
});
```
7 changes: 7 additions & 0 deletions docs/_snippets/storybook-addons-api-getcurrentstorydata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
```js
addons.register('my-organisation/my-addon', (api) => {
// Get data about the currently selected story
const storyData = api.getCurrentStoryData();
console.log('Current story:', storyData.id, storyData.title);
});
```
23 changes: 23 additions & 0 deletions docs/_snippets/storybook-addons-api-openineditor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
```js
addons.register('my-organisation/my-addon', (api) => {
// Open a file in the editor
api.openInEditor({
file: './src/components/Button.tsx',
});

// Handle the api response
api
.openInEditor({
file: './src/components/Button.tsx',
line: 42,
column: 15,
})
.then((response) => {
if (response.error) {
console.error('Failed to open file:', response.error);
} else {
console.log('File opened successfully');
}
});
});
```
12 changes: 12 additions & 0 deletions docs/_snippets/storybook-addons-api-openinisolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
```js
addons.register('my-organisation/my-addon', (api) => {
// Open the current story in isolation mode
api.openInIsolation('button--primary');

// Open a story from an external ref in isolation
api.openInIsolation('external-button--secondary', 'external-ref');

// Open a story in docs view mode
api.openInIsolation('button--primary', null, 'docs');
});
```
12 changes: 12 additions & 0 deletions docs/_snippets/storybook-addons-api-togglefullscreen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
```js
addons.register('my-organisation/my-addon', (api) => {
// Toggle fullscreen mode
api.toggleFullscreen();

// Enable fullscreen
api.toggleFullscreen(true);

// Disable fullscreen
api.toggleFullscreen(false);
});
```
12 changes: 12 additions & 0 deletions docs/_snippets/storybook-addons-api-togglepanel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
```js
addons.register('my-organisation/my-addon', (api) => {
// Toggle panel visibility
api.togglePanel();

// Show the panel
api.togglePanel(true);

// Hide the panel
api.togglePanel(false);
});
```
70 changes: 70 additions & 0 deletions docs/addons/addons-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,76 @@ This method allows you to register a handler function called whenever the user n

{/* prettier-ignore-end */}

### api.openInIsolation(storyId, refId?, viewMode?)

Opens a story in isolation mode in a new tab/window. This allows you to programmatically open stories in the same way as the "Open in isolation" button in the share menu.

- `storyId`: The ID of the story to open (required)
- `refId`: The ID of the ref for external stories. Pass `null`/`undefined` for local stories (optional)
- `viewMode`: The view mode to open the story in. Can be `'story'` or `'docs'` (optional, defaults to current view mode)

{/* prettier-ignore-start */}

<CodeSnippets path="storybook-addons-api-openinisolation.md" />

{/* prettier-ignore-end */}

### api.openInEditor(payload)

Opens a file in the configured code editor. Useful for "Edit in IDE" functionality in addons.

- `payload.file`: The file path to open (required)
- `payload.line`: Optional line number to jump to
- `payload.column`: Optional column number to jump to

Returns a Promise that resolves with information about whether the operation was successful.

{/* prettier-ignore-start */}

<CodeSnippets path="storybook-addons-api-openineditor.md" />

{/* prettier-ignore-end */}

### api.getCurrentStoryData()

Returns the current story's data, including its ID, kind, name, and parameters.

{/* prettier-ignore-start */}

<CodeSnippets path="storybook-addons-api-getcurrentstorydata.md" />

{/* prettier-ignore-end */}

### api.toggleFullscreen(toggled?)

Toggles the fullscreen mode of the Storybook UI. Pass `true` to enable fullscreen, `false` to disable, or omit to toggle the current state.

{/* prettier-ignore-start */}

<CodeSnippets path="storybook-addons-api-togglefullscreen.md" />

{/* prettier-ignore-end */}

### api.togglePanel(toggled?)

Toggles the visibility of the addon panel. Pass `true` to show the panel, `false` to hide, or omit to toggle the current state.

{/* prettier-ignore-start */}

<CodeSnippets path="storybook-addons-api-togglepanel.md" />

{/* prettier-ignore-end */}

### api.addNotification(notification)

Displays a notification in the Storybook UI. The notification object should contain `id`, `content`, and optionally `duration` and `icon`.

{/* prettier-ignore-start */}

<CodeSnippets path="storybook-addons-api-addnotification.md" />

{/* prettier-ignore-end */}

### addons.setConfig(config)

This method allows you to override the default Storybook UI configuration (e.g., set up a [theme](../configure/user-interface/theming.mdx) or hide UI elements):
Expand Down
Loading