diff --git a/code/core/src/manager-api/modules/layout.ts b/code/core/src/manager-api/modules/layout.ts index f8d053773926..5fdaf61d99b3 100644 --- a/code/core/src/manager-api/modules/layout.ts +++ b/code/core/src/manager-api/modules/layout.ts @@ -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'; @@ -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, @@ -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, }, @@ -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; [key: string]: any }, + singleStory: boolean +) => { + const layoutKeys = Object.keys(layoutState); + const layoutAtTopLevel = pick(options, layoutKeys); + + for (const key of Object.keys(layoutAtTopLevel)) { + 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; [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 = ({ store, provider, singleStory }) => { const api = { toggleFullscreen(nextState?: boolean) { @@ -439,23 +518,19 @@ export const init: ModuleFn = ({ 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, }; @@ -513,18 +588,9 @@ export const init: ModuleFn = ({ 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, diff --git a/code/core/src/manager-api/tests/layout.test.ts b/code/core/src/manager-api/tests/layout.test.ts index 30fd16e0e4ad..f8c8c0b45e56 100644 --- a/code/core/src/manager-api/tests/layout.test.ts +++ b/code/core/src/manager-api/tests/layout.test.ts @@ -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'; @@ -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(); }); @@ -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(); }); @@ -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', () => { diff --git a/code/core/src/types/modules/addons.ts b/code/core/src/types/modules/addons.ts index 31af4e84b326..c2abc0a134c1 100644 --- a/code/core/src/types/modules/addons.ts +++ b/code/core/src/types/modules/addons.ts @@ -2,7 +2,7 @@ import type { FC, PropsWithChildren, ReactElement, ReactNode } from 'react'; import type { RenderData as RouterData } from '../../router/types.ts'; import type { ThemeVars } from '../../theming/types.ts'; -import type { API_LayoutCustomisations, API_SidebarOptions } from './api.ts'; +import type { API_Layout, API_LayoutCustomisations, API_SidebarOptions, API_UI } from './api.ts'; import type { API_HashEntry, API_StoryEntry } from './api-stories.ts'; import type { Args, @@ -479,15 +479,13 @@ export interface Addon_ToolbarConfig { } export interface Addon_Config { theme?: ThemeVars; - layoutCustomisations?: { - showPanel?: API_LayoutCustomisations['showPanel']; - showSidebar?: API_LayoutCustomisations['showSidebar']; - showToolbar?: API_LayoutCustomisations['showToolbar']; - }; + layout?: Partial; + layoutCustomisations?: Partial; toolbar?: { [id: string]: Addon_ToolbarConfig; }; sidebar?: API_SidebarOptions; + ui?: Partial; [key: string]: any; } diff --git a/code/core/src/types/modules/api.ts b/code/core/src/types/modules/api.ts index 0e94d96699b6..2efb562885be 100644 --- a/code/core/src/types/modules/api.ts +++ b/code/core/src/types/modules/api.ts @@ -48,9 +48,10 @@ export interface API_Provider { getConfig(): { sidebar?: API_SidebarOptions; theme?: ThemeVars; + selectedPanel?: string; StoryMapper?: API_StoryMapper; [k: string]: any; - } & Partial; + }; [key: string]: any; } @@ -63,17 +64,6 @@ export type API_IframeRenderer = ( queryParams: Record ) => ReactElement | null; -export interface API_UIOptions { - name?: string; - url?: string; - goFullScreen: boolean; - showStoriesPanel: boolean; - showAddonPanel: boolean; - addonPanelInRight: boolean; - theme?: ThemeVars; - selectedPanel?: string; -} - export type FilterFunction = (entry: API_PreparedIndexEntry, excluded?: boolean) => boolean; export interface API_Layout { @@ -91,6 +81,8 @@ export interface API_Layout { rightPanelWidth: number; }; panelPosition: API_PanelPositions; + showNav: boolean; + showPanel: boolean; showTabs: boolean; showToolbar: boolean; }