Skip to content
108 changes: 87 additions & 21 deletions code/core/src/manager-api/modules/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { global } from '@storybook/global';
import { pick, toMerged } from 'es-toolkit/object';
import { isEqual as deepEqual } from 'es-toolkit/predicate';
import type { ThemeVars } from 'storybook/theming';
import { deprecate } from 'storybook/internal/client-logger';
import { create } from 'storybook/theming/create';

import merge from '../lib/merge.ts';
Expand Down Expand Up @@ -132,7 +133,6 @@ export const getDefaultLayoutState: () => SubState = () => {
},
layout: {
initialActive: ActiveTabs.CANVAS,
showToolbar: true,
navSize: DEFAULT_NAV_SIZE,
bottomPanelHeight: DEFAULT_BOTTOM_PANEL_HEIGHT,
rightPanelWidth: DEFAULT_RIGHT_PANEL_WIDTH,
Expand All @@ -142,9 +142,13 @@ export const getDefaultLayoutState: () => SubState = () => {
rightPanelWidth: DEFAULT_RIGHT_PANEL_WIDTH,
},
panelPosition: 'bottom',
showNav: true,
showPanel: true,
showTabs: true,
showToolbar: true,
},
layoutCustomisations: {
showPanel: undefined,
showSidebar: undefined,
showToolbar: undefined,
},
Expand Down Expand Up @@ -192,6 +196,81 @@ const getRecentVisibleSizes = (layoutState: API_Layout) => {
};
};

/**
* Merges layout options into the existing layout state and translates
* `showNav` / `showPanel` booleans into the underlying size fields.
*
* Layout keys can be provided either at the top level (deprecated) or under
* `options.layout` (preferred). Nested layout keys take precedence.
*
* Numeric sizes are merged in before applying show/hide flags, so
* `recentVisibleSizes` is captured from the latest size values.
*/
const applyLayoutOptions = (
layoutState: API_Layout,
options: { layout?: Partial<API_Layout>; [key: string]: any },
singleStory: boolean
) => {
const layoutKeys = Object.keys(layoutState);
const layoutAtTopLevel = pick(options, layoutKeys);

for (const key of Object.keys(layoutAtTopLevel)) {
Comment thread
Sidnioulz marked this conversation as resolved.
deprecate(
`Calling \`setConfig({ ${key}: ... })\` is deprecated. Please call \`setConfig({ layout: { ${key}: ... } })\` instead.`
);
}

const mergedLayoutOptions = toMerged(layoutAtTopLevel, options.layout || {});
const { showPanel, showNav } = mergedLayoutOptions;

// Safety net: drop any unknown keys that aren't part of API_Layout.
const typedLayoutKeys = layoutKeys as (keyof API_Layout)[];
const nextLayoutState = toMerged(layoutState, pick(mergedLayoutOptions, typedLayoutKeys));

// singleStory always hides the sidebar; otherwise honor showSidebar.
if (showNav === false || singleStory) {
nextLayoutState.recentVisibleSizes = getRecentVisibleSizes(nextLayoutState);
nextLayoutState.navSize = 0;
} else if (showNav === true) {
nextLayoutState.navSize = nextLayoutState.recentVisibleSizes.navSize;
}

if (showPanel === false) {
nextLayoutState.recentVisibleSizes = getRecentVisibleSizes(nextLayoutState);
nextLayoutState.bottomPanelHeight = 0;
nextLayoutState.rightPanelWidth = 0;
} else if (showPanel === true) {
nextLayoutState.bottomPanelHeight = nextLayoutState.recentVisibleSizes.bottomPanelHeight;
nextLayoutState.rightPanelWidth = nextLayoutState.recentVisibleSizes.rightPanelWidth;
}

return nextLayoutState;
};

/**
* Merges ui options into the existing ui state.
*
* Ui keys can be provided either at the top level (deprecated) or under
* `options.ui` (preferred). Nested ui keys take precedence.
*
* Numeric sizes are merged in before applying show/hide flags, so
* `recentVisibleSizes` is captured from the latest size values.
*/
const applyUiOptions = (uiState: API_UI, options: { ui?: Partial<API_UI>; [key: string]: any }) => {
const uiKeys = Object.keys(uiState);
const uiAtTopLevel = pick(options, uiKeys);

for (const key of Object.keys(uiAtTopLevel)) {
deprecate(
`Calling \`setConfig({ ${key}: ... })\` is deprecated. Please call \`setConfig({ ui: { ${key}: ... } })\` instead.`
);
}

// Safety net: drop any unknown keys that aren't part of API_UI.
const typedUiKeys = uiKeys as (keyof API_UI)[];
return toMerged(uiState, pick(toMerged(uiAtTopLevel, options.ui || {}), typedUiKeys));
};

export const init: ModuleFn<SubAPI, SubState> = ({ store, provider, singleStory }) => {
const api = {
toggleFullscreen(nextState?: boolean) {
Expand Down Expand Up @@ -439,23 +518,19 @@ export const init: ModuleFn<SubAPI, SubState> = ({ store, provider, singleStory
},

getInitialOptions() {
const { theme, selectedPanel, layoutCustomisations, ...options } = provider.getConfig();
const userConfig = provider.getConfig();
const defaultLayoutState = getDefaultLayoutState();

const { theme, selectedPanel, layoutCustomisations } = userConfig;

return {
...defaultLayoutState,
layout: {
...toMerged(
defaultLayoutState.layout,
pick(options, Object.keys(defaultLayoutState.layout))
),
...(singleStory && { navSize: 0 }),
},
layout: applyLayoutOptions(defaultLayoutState.layout, userConfig, !!singleStory),
layoutCustomisations: {
...defaultLayoutState.layoutCustomisations,
...(layoutCustomisations ?? {}),
},
ui: toMerged(defaultLayoutState.ui, pick(options, Object.keys(defaultLayoutState.ui))),
ui: applyUiOptions(defaultLayoutState.ui, userConfig),
selectedPanel: selectedPanel || defaultLayoutState.selectedPanel,
theme: theme || defaultLayoutState.theme,
};
Expand Down Expand Up @@ -513,18 +588,9 @@ export const init: ModuleFn<SubAPI, SubState> = ({ store, provider, singleStory
return;
}

const updatedLayout = {
...layout,
...(options.layout || {}),
...pick(options, Object.keys(layout)),
...(singleStory && { navSize: 0 }),
};
const updatedLayout = applyLayoutOptions(layout, options, !!singleStory);

const updatedUi = {
...ui,
...options.ui,
...toMerged(options.ui || {}, pick(options, Object.keys(ui))),
};
const updatedUi = applyUiOptions(ui, options);

const updatedTheme = {
...theme,
Expand Down
199 changes: 197 additions & 2 deletions code/core/src/manager-api/tests/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { API_Provider } from 'storybook/internal/types';
import * as clientLogger from 'storybook/internal/client-logger';

import EventEmitter from 'events';
import { themes } from 'storybook/theming';
Expand Down Expand Up @@ -450,7 +451,7 @@ describe('layout API', () => {
});

it('should not change selectedPanel if it is undefined in the options, but something else has changed', () => {
layoutApi.setOptions({ panelPosition: 'right' });
layoutApi.setOptions({ layout: { panelPosition: 'right' } });

expect(getLastSetStateArgs()[0].selectedPanel).toBeUndefined();
});
Expand All @@ -467,7 +468,10 @@ describe('layout API', () => {
it('should not change selectedPanel if it is currently the same, but something else has changed', () => {
layoutApi.setOptions({});
// second call is needed to overwrite initial layout
layoutApi.setOptions({ panelPosition: 'right', selectedPanel: currentState.selectedPanel });
layoutApi.setOptions({
layout: { panelPosition: 'right' },
selectedPanel: currentState.selectedPanel,
});

expect(getLastSetStateArgs()[0].selectedPanel).toBeUndefined();
});
Expand All @@ -486,6 +490,197 @@ describe('layout API', () => {

expect(getLastSetStateArgs()[0].selectedPanel).toEqual(panelName);
});

it('should hide the panel when layout.showPanel is false', () => {
layoutApi.setSizes({
bottomPanelHeight: 200,
rightPanelWidth: 250,
});

layoutApi.setOptions({ layout: { showPanel: false } });

expect(currentState.layout.bottomPanelHeight).toBe(0);
expect(currentState.layout.rightPanelWidth).toBe(0);
expect(currentState.layout.recentVisibleSizes.bottomPanelHeight).toBe(200);
expect(currentState.layout.recentVisibleSizes.rightPanelWidth).toBe(250);

layoutApi.togglePanel(true);

expect(currentState.layout.bottomPanelHeight).toBe(200);
expect(currentState.layout.rightPanelWidth).toBe(250);
});

it('should hide nav and preserve provided navSize when layout.showNav is false', () => {
layoutApi.setOptions({ layout: { navSize: 180, showNav: false } });

expect(currentState.layout.navSize).toBe(0);
expect(currentState.layout.recentVisibleSizes.navSize).toBe(180);

layoutApi.toggleNav(true);

expect(currentState.layout.navSize).toBe(180);
});

it('should hide panel and preserve provided sizes when layout.showPanel is false', () => {
layoutApi.setOptions({
layout: { bottomPanelHeight: 210, rightPanelWidth: 260, showPanel: false },
});

expect(currentState.layout.bottomPanelHeight).toBe(0);
expect(currentState.layout.rightPanelWidth).toBe(0);
expect(currentState.layout.recentVisibleSizes.bottomPanelHeight).toBe(210);
expect(currentState.layout.recentVisibleSizes.rightPanelWidth).toBe(260);

layoutApi.togglePanel(true);

expect(currentState.layout.bottomPanelHeight).toBe(210);
expect(currentState.layout.rightPanelWidth).toBe(260);
});

it('should prioritize options.layout over top-level layout keys', () => {
const deprecateSpy = vi.spyOn(clientLogger, 'deprecate').mockImplementation(() => {});

layoutApi.setOptions({
showNav: true,
showPanel: true,
layout: { showNav: false, showPanel: false },
});

expect(currentState.layout.navSize).toBe(0);
expect(currentState.layout.bottomPanelHeight).toBe(0);
expect(currentState.layout.rightPanelWidth).toBe(0);
expect(deprecateSpy).toHaveBeenCalled();
});

it('should deprecate top-level layout keys in setOptions', () => {
const deprecateSpy = vi.spyOn(clientLogger, 'deprecate').mockImplementation(() => {});

layoutApi.setOptions({ showNav: false, panelPosition: 'right' });

expect(deprecateSpy).toHaveBeenCalledWith(
'Calling `setConfig({ showNav: ... })` is deprecated. Please call `setConfig({ layout: { showNav: ... } })` instead.'
);
expect(deprecateSpy).toHaveBeenCalledWith(
'Calling `setConfig({ panelPosition: ... })` is deprecated. Please call `setConfig({ layout: { panelPosition: ... } })` instead.'
);
});

it('should prioritize options.ui over top-level ui keys', () => {
layoutApi.setOptions({
enableShortcuts: false,
ui: { enableShortcuts: true },
});

expect(currentState.ui.enableShortcuts).toBe(true);
});

it('should deprecate top-level ui keys in setOptions', () => {
const deprecateSpy = vi.spyOn(clientLogger, 'deprecate').mockImplementation(() => {});

layoutApi.setOptions({ enableShortcuts: false });

expect(deprecateSpy).toHaveBeenCalledWith(
'Calling `setConfig({ enableShortcuts: ... })` is deprecated. Please call `setConfig({ ui: { enableShortcuts: ... } })` instead.'
);
});
});

describe('getInitialOptions', () => {
it('should apply layout.showPanel from the initial config', () => {
(provider.getConfig as Mock).mockReturnValue({
layout: { showPanel: false },
});

const storeWithoutPersistedLayout = {
...store,
getState: () => ({ selectedPanel: currentState.selectedPanel }) as unknown as State,
} as unknown as Store;

const { state } = initLayout({
store: storeWithoutPersistedLayout,
provider,
singleStory: false,
} as unknown as ModuleArgs);

expect(state.layout.bottomPanelHeight).toBe(0);
expect(state.layout.rightPanelWidth).toBe(0);
expect(state.layout.recentVisibleSizes.bottomPanelHeight).toBe(300);
expect(state.layout.recentVisibleSizes.rightPanelWidth).toBe(400);
});

it('should apply layout.showNav from the initial config', () => {
(provider.getConfig as Mock).mockReturnValue({
layout: { showNav: false },
});

const storeWithoutPersistedLayout = {
...store,
getState: () => ({ selectedPanel: currentState.selectedPanel }) as unknown as State,
} as unknown as Store;

const { state } = initLayout({
store: storeWithoutPersistedLayout,
provider,
singleStory: false,
} as unknown as ModuleArgs);

expect(state.layout.navSize).toBe(0);
expect(state.layout.recentVisibleSizes.navSize).toBe(300);
});

it('should prioritize layout over top-level config keys', () => {
const deprecateSpy = vi.spyOn(clientLogger, 'deprecate').mockImplementation(() => {});
(provider.getConfig as Mock).mockReturnValue({
showPanel: true,
showNav: true,
layout: { showPanel: false, showNav: false },
});

const storeWithoutPersistedLayout = {
...store,
getState: () => ({ selectedPanel: currentState.selectedPanel }) as unknown as State,
} as unknown as Store;

const { state } = initLayout({
store: storeWithoutPersistedLayout,
provider,
singleStory: false,
} as unknown as ModuleArgs);

expect(state.layout.navSize).toBe(0);
expect(state.layout.bottomPanelHeight).toBe(0);
expect(state.layout.rightPanelWidth).toBe(0);
expect(deprecateSpy).toHaveBeenCalledWith(
'Calling `setConfig({ showPanel: ... })` is deprecated. Please call `setConfig({ layout: { showPanel: ... } })` instead.'
);
expect(deprecateSpy).toHaveBeenCalledWith(
'Calling `setConfig({ showNav: ... })` is deprecated. Please call `setConfig({ layout: { showNav: ... } })` instead.'
);
});

it('should prioritize ui over top-level config keys', () => {
const deprecateSpy = vi.spyOn(clientLogger, 'deprecate').mockImplementation(() => {});
(provider.getConfig as Mock).mockReturnValue({
enableShortcuts: false,
ui: { enableShortcuts: true },
});

const storeWithoutPersistedLayout = {
...store,
getState: () => ({ selectedPanel: currentState.selectedPanel }) as unknown as State,
} as unknown as Store;

const { state } = initLayout({
store: storeWithoutPersistedLayout,
provider,
singleStory: false,
} as unknown as ModuleArgs);

expect(state.ui.enableShortcuts).toBe(true);
expect(deprecateSpy).toHaveBeenCalledWith(
'Calling `setConfig({ enableShortcuts: ... })` is deprecated. Please call `setConfig({ ui: { enableShortcuts: ... } })` instead.'
);
});
});

describe('state getters', () => {
Expand Down
Loading