Merge branch 'next' into shilman/rnstorybook-automigration

This commit is contained in:
Michael Shilman 2025-03-24 22:24:43 +08:00
commit f1a3442201
27 changed files with 111 additions and 938 deletions

View File

@ -0,0 +1,43 @@
---
description: Rules for consistent and type-safe mocking in Vitest tests
globs: "**/*.test.{ts,tsx,js,jsx}"
alwaysApply: true
---
# Spy Mocking Rules for Vitest Tests
## Mocking Approach
When mocking packages or files in Vitest-based tests, follow these rules:
1. Use `vi.mock()` with the `spy: true` option for all package and file mocks
2. Place all mocks at the top of the test file before any test cases
3. Use `vi.mocked()` to type and access the mocked functions
4. Implement mock behaviors in `beforeEach` blocks
5. Mock all required dependencies that the test subject uses
## Mock Implementation Rules
1. Mock implementations should be placed in `beforeEach` blocks
2. Each mock implementation should return a Promise for async functions
3. Mock implementations should match the expected return type of the original function
4. Use `vi.mocked()` to access and implement mock behaviors
5. Mock all required properties and methods that the test subject uses
## Avoided Patterns
The following mocking patterns should be avoided:
1. Direct function mocking without `vi.mocked()`
2. Mock implementations outside of `beforeEach` blocks
3. Mocking without the `spy: true` option
4. Inline mock implementations within test cases
5. Mocking only a subset of required dependencies
## Best Practices
1. Mock at the highest level of abstraction needed
2. Keep mock implementations simple and focused
3. Use type-safe mocking with `vi.mocked()`
4. Document complex mock behaviors
5. Group related mocks together

View File

@ -2,6 +2,7 @@
- [From version 8.x to 9.0.0](#from-version-8x-to-900)
- [React-Native config dir renamed](#react-native-config-dir-renamed)
- [Addon viewport and addon backgrounds synchronized configuration and use globals](#addon-viewport-and-addon-backgrounds-synchronized-configuration-and-use-globals)
- [Manager builder removed alias for `util`, `assert` and `process`](#manager-builder-removed-alias-for-util-assert-and-process)
- [Actions addon moved to core](#actions-addon-moved-to-core)
- [Dropped support for legacy packages](#dropped-support-for-legacy-packages)
@ -125,17 +126,17 @@
- [Tab addons cannot manually route, Tool addons can filter their visibility via tabId](#tab-addons-cannot-manually-route-tool-addons-can-filter-their-visibility-via-tabid)
- [Removed `config` preset](#removed-config-preset-1)
- [From version 7.5.0 to 7.6.0](#from-version-750-to-760)
- [CommonJS with Vite is deprecated](#commonjs-with-vite-is-deprecated)
- [Using implicit actions during rendering is deprecated](#using-implicit-actions-during-rendering-is-deprecated)
- [typescript.skipBabel deprecated](#typescriptskipbabel-deprecated)
- [Primary doc block accepts of prop](#primary-doc-block-accepts-of-prop)
- [Addons no longer need a peer dependency on React](#addons-no-longer-need-a-peer-dependency-on-react)
- [CommonJS with Vite is deprecated](#commonjs-with-vite-is-deprecated)
- [Using implicit actions during rendering is deprecated](#using-implicit-actions-during-rendering-is-deprecated)
- [typescript.skipBabel deprecated](#typescriptskipbabel-deprecated)
- [Primary doc block accepts of prop](#primary-doc-block-accepts-of-prop)
- [Addons no longer need a peer dependency on React](#addons-no-longer-need-a-peer-dependency-on-react)
- [From version 7.4.0 to 7.5.0](#from-version-740-to-750)
- [`storyStoreV6` and `storiesOf` is deprecated](#storystorev6-and-storiesof-is-deprecated)
- [`storyIndexers` is replaced with `experimental_indexers`](#storyindexers-is-replaced-with-experimental_indexers)
- [`storyStoreV6` and `storiesOf` is deprecated](#storystorev6-and-storiesof-is-deprecated)
- [`storyIndexers` is replaced with `experimental_indexers`](#storyindexers-is-replaced-with-experimental_indexers)
- [From version 7.0.0 to 7.2.0](#from-version-700-to-720)
- [Addon API is more type-strict](#addon-api-is-more-type-strict)
- [Addon-controls hideNoControlsWarning parameter is deprecated](#addon-controls-hidenocontrolswarning-parameter-is-deprecated)
- [Addon API is more type-strict](#addon-api-is-more-type-strict)
- [Addon-controls hideNoControlsWarning parameter is deprecated](#addon-controls-hidenocontrolswarning-parameter-is-deprecated)
- [From version 6.5.x to 7.0.0](#from-version-65x-to-700)
- [7.0 breaking changes](#70-breaking-changes)
- [Dropped support for Node 15 and below](#dropped-support-for-node-15-and-below)
@ -161,7 +162,7 @@
- [Deploying build artifacts](#deploying-build-artifacts)
- [Dropped support for file URLs](#dropped-support-for-file-urls)
- [Serving with nginx](#serving-with-nginx)
- [Ignore story files from node\_modules](#ignore-story-files-from-node_modules)
- [Ignore story files from node_modules](#ignore-story-files-from-node_modules)
- [7.0 Core changes](#70-core-changes)
- [7.0 feature flags removed](#70-feature-flags-removed)
- [Story context is prepared before for supporting fine grained updates](#story-context-is-prepared-before-for-supporting-fine-grained-updates)
@ -175,7 +176,7 @@
- [Addon-interactions: Interactions debugger is now default](#addon-interactions-interactions-debugger-is-now-default)
- [7.0 Vite changes](#70-vite-changes)
- [Vite builder uses Vite config automatically](#vite-builder-uses-vite-config-automatically)
- [Vite cache moved to node\_modules/.cache/.vite-storybook](#vite-cache-moved-to-node_modulescachevite-storybook)
- [Vite cache moved to node_modules/.cache/.vite-storybook](#vite-cache-moved-to-node_modulescachevite-storybook)
- [7.0 Webpack changes](#70-webpack-changes)
- [Webpack4 support discontinued](#webpack4-support-discontinued)
- [Babel mode v7 exclusively](#babel-mode-v7-exclusively)
@ -226,7 +227,7 @@
- [Dropped addon-docs manual babel configuration](#dropped-addon-docs-manual-babel-configuration)
- [Dropped addon-docs manual configuration](#dropped-addon-docs-manual-configuration)
- [Autoplay in docs](#autoplay-in-docs)
- [Removed STORYBOOK\_REACT\_CLASSES global](#removed-storybook_react_classes-global)
- [Removed STORYBOOK_REACT_CLASSES global](#removed-storybook_react_classes-global)
- [7.0 Deprecations and default changes](#70-deprecations-and-default-changes)
- [storyStoreV7 enabled by default](#storystorev7-enabled-by-default)
- [`Story` type deprecated](#story-type-deprecated)
@ -448,6 +449,15 @@ That makes it easier for RN and React Native Web (RNW) storybooks to co-exist in
To upgrade, either rename your `.storybook` directory to `.rnstorybook` or if you wish to continue using `.storybook` (not recommended), you can use the [`configPath`](https://github.com/storybookjs/react-native#configpath) option to specify `.storybook` manually.
### Addon viewport and addon backgrounds synchronized configuration and use globals
The feature flags: `viewportStoryGlobals` and `backgroundsStoryGlobals` have been removed, please remove these from your `.storybook/main.ts` file.
See here for the ways you have to configure addon viewports & backgrounds:
- [New parameters format for addon backgrounds](#new-parameters-format-for-addon-backgrounds)
- [New parameters format for addon viewport](#new-parameters-format-for-addon-viewport)
### Manager builder removed alias for `util`, `assert` and `process`
These dependencies (often used accidentally) were polyfilled to mocks or browser equivalents by storybook's manager builder.

View File

@ -127,8 +127,6 @@ const config = defineMain({
disableTelemetry: true,
},
features: {
viewportStoryGlobals: true,
backgroundsStoryGlobals: true,
developmentModeForBuild: true,
},
viteFinal: async (viteConfig, { configType }) => {

View File

@ -8,12 +8,12 @@ import { useGlobals, useParameter } from 'storybook/manager-api';
import { PARAM_KEY as KEY } from '../constants';
import { DEFAULT_BACKGROUNDS } from '../defaults';
import type { Background, BackgroundMap, Config, GlobalStateUpdate } from '../types';
import type { Background, BackgroundMap, BackgroundsParameters, GlobalStateUpdate } from '../types';
type Link = Parameters<typeof TooltipLinkList>['0']['links'][0];
export const BackgroundTool = memo(function BackgroundSelector() {
const config = useParameter<Config>(KEY);
const config = useParameter<BackgroundsParameters['backgrounds']>(KEY);
const [globals, updateGlobals, storyGlobals] = useGlobals();
const [isTooltipVisible, setIsTooltipVisible] = useState(false);

View File

@ -4,7 +4,7 @@ import { useEffect } from 'storybook/preview-api';
import { PARAM_KEY as KEY } from './constants';
import { DEFAULT_BACKGROUNDS } from './defaults';
import type { Config, GridConfig } from './types';
import type { BackgroundsParameters, GridConfig } from './types';
import { addBackgroundStyle, addGridStyle, clearStyles, isReduceMotionEnabled } from './utils';
const defaultGrid: GridConfig = {
@ -24,14 +24,14 @@ export const withBackgroundAndGrid: DecoratorFunction = (StoryFn, context) => {
options = DEFAULT_BACKGROUNDS,
disable,
grid = defaultGrid,
} = (parameters[KEY] || {}) as Config;
} = (parameters[KEY] || {}) as BackgroundsParameters['backgrounds'];
const data = globals[KEY] || {};
const backgroundName: string | undefined = data.value;
const backgroundName: string | undefined = typeof data === 'string' ? data : data.value;
const item = backgroundName ? options[backgroundName] : undefined;
const value = item?.value || 'transparent';
const value = typeof item === 'string' ? item : item?.value || 'transparent';
const showGrid = data.grid || false;
const showGrid = typeof data === 'string' ? false : data.grid || false;
const shownBackground = !!item && !disable;
const backgroundSelector = viewMode === 'docs' ? `#anchor--${id} .docs-story` : '.sb-show-main';

View File

@ -1,148 +0,0 @@
import type { FC, ReactElement } from 'react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { logger } from 'storybook/internal/client-logger';
import { IconButton, TooltipLinkList, WithTooltip } from 'storybook/internal/components';
import { PhotoIcon } from '@storybook/icons';
import memoize from 'memoizerific';
import { useGlobals, useParameter } from 'storybook/manager-api';
import { PARAM_KEY as BACKGROUNDS_PARAM_KEY } from '../constants';
import type { Background } from '../types';
import { ColorIcon } from './ColorIcon';
import { getBackgroundColorByName } from './getBackgroundColorByName';
export interface DeprecatedGlobalState {
name: string | undefined;
selected: string | undefined;
}
export interface BackgroundsParameter {
default?: string | null;
disable?: boolean;
values: Background[];
}
export interface BackgroundSelectorItem {
id: string;
title: string;
onClick: () => void;
value: string;
active: boolean;
right?: ReactElement;
}
const createBackgroundSelectorItem = memoize(1000)(
(
id: string | null,
name: string,
value: string,
hasSwatch: boolean | null,
change: (arg: { selected: string; name: string }) => void,
active: boolean
): BackgroundSelectorItem => ({
id: id || name,
title: name,
onClick: () => {
change({ selected: value, name });
},
value,
right: hasSwatch ? <ColorIcon background={value} /> : undefined,
active,
})
);
const getDisplayedItems = memoize(10)((
backgrounds: Background[],
selectedBackgroundColor: string | null,
change: (arg: { selected: string; name: string }) => void
) => {
const backgroundSelectorItems = backgrounds.map(({ name, value }) =>
createBackgroundSelectorItem(null, name, value, true, change, value === selectedBackgroundColor)
);
if (selectedBackgroundColor !== 'transparent') {
return [
createBackgroundSelectorItem('reset', 'Clear background', 'transparent', null, change, false),
...backgroundSelectorItems,
];
}
return backgroundSelectorItems;
});
const DEFAULT_BACKGROUNDS_CONFIG: BackgroundsParameter = {
default: null,
disable: true,
values: [],
};
export const BackgroundToolLegacy: FC = memo(function BackgroundSelector() {
const backgroundsConfig = useParameter<BackgroundsParameter>(
BACKGROUNDS_PARAM_KEY,
DEFAULT_BACKGROUNDS_CONFIG
);
const [isTooltipVisible, setIsTooltipVisible] = useState(false);
const [globals, updateGlobals] = useGlobals();
const globalsBackgroundColor = globals[BACKGROUNDS_PARAM_KEY]?.value;
const selectedBackgroundColor = useMemo(() => {
return getBackgroundColorByName(
globalsBackgroundColor,
backgroundsConfig.values,
backgroundsConfig.default
);
}, [backgroundsConfig, globalsBackgroundColor]);
if (Array.isArray(backgroundsConfig)) {
logger.warn(
'Addon Backgrounds api has changed in Storybook 6.0. Please refer to the migration guide: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md'
);
}
const onBackgroundChange = useCallback(
(value: string | undefined) => {
updateGlobals({ [BACKGROUNDS_PARAM_KEY]: { ...globals[BACKGROUNDS_PARAM_KEY], value } });
},
[backgroundsConfig, globals, updateGlobals]
);
if (backgroundsConfig.disable) {
return null;
}
return (
<WithTooltip
placement="top"
closeOnOutsideClick
tooltip={({ onHide }) => {
return (
<TooltipLinkList
links={getDisplayedItems(
backgroundsConfig.values,
selectedBackgroundColor,
({ selected }: DeprecatedGlobalState) => {
if (selectedBackgroundColor !== selected) {
onBackgroundChange(selected);
}
onHide();
}
)}
/>
);
}}
onVisibleChange={setIsTooltipVisible}
>
<IconButton
key="background"
title="Change the background of the preview"
active={selectedBackgroundColor !== 'transparent' || isTooltipVisible}
>
<PhotoIcon />
</IconButton>
</WithTooltip>
);
});

View File

@ -1,14 +0,0 @@
import { styled } from 'storybook/theming';
export const ColorIcon = styled.span(
({ background }: { background: string }) => ({
borderRadius: '1rem',
display: 'block',
height: '1rem',
width: '1rem',
background,
}),
({ theme }) => ({
boxShadow: `${theme.appBorderColor} 0 0 0 1px inset`,
})
);

View File

@ -1,39 +0,0 @@
import type { FC } from 'react';
import React, { memo } from 'react';
import { IconButton } from 'storybook/internal/components';
import { GridIcon } from '@storybook/icons';
import { useGlobals, useParameter } from 'storybook/manager-api';
import { PARAM_KEY as BACKGROUNDS_PARAM_KEY } from '../constants';
export const GridToolLegacy: FC = memo(function GridSelector() {
const [globals, updateGlobals] = useGlobals();
const { grid } = useParameter(BACKGROUNDS_PARAM_KEY, {
grid: { disable: false },
});
if (grid?.disable) {
return null;
}
const isActive = globals[BACKGROUNDS_PARAM_KEY]?.grid || false;
return (
<IconButton
key="background"
active={isActive}
title="Apply a grid to the preview"
onClick={() =>
updateGlobals({
[BACKGROUNDS_PARAM_KEY]: { ...globals[BACKGROUNDS_PARAM_KEY], grid: !isActive },
})
}
>
<GridIcon />
</IconButton>
);
});

View File

@ -1,41 +0,0 @@
import { logger } from 'storybook/internal/client-logger';
import { dedent } from 'ts-dedent';
import type { Background } from '../types';
export const getBackgroundColorByName = (
currentSelectedValue: string,
backgrounds: Background[] = [],
defaultName: string | null | undefined
): string => {
if (currentSelectedValue === 'transparent') {
return 'transparent';
}
if (backgrounds.find((background) => background.value === currentSelectedValue)) {
return currentSelectedValue;
}
if (currentSelectedValue) {
return currentSelectedValue;
}
const defaultBackground = backgrounds.find((background) => background.name === defaultName);
if (defaultBackground) {
return defaultBackground.value;
}
if (defaultName) {
const availableColors = backgrounds.map((background) => background.name).join(', ');
logger.warn(
dedent`
Backgrounds Addon: could not find the default color "${defaultName}".
These are the available colors for your story based on your configuration:
${availableColors}.
`
);
}
return 'transparent';
};

View File

@ -1,63 +0,0 @@
import type { DecoratorFunction } from 'storybook/internal/types';
import { useEffect, useMemo } from 'storybook/preview-api';
import { PARAM_KEY as BACKGROUNDS_PARAM_KEY } from '../constants';
import { addBackgroundStyle, clearStyles, isReduceMotionEnabled } from '../utils';
import { getBackgroundColorByName } from './getBackgroundColorByName';
export const withBackground: DecoratorFunction = (StoryFn, context) => {
const { globals, parameters } = context;
const globalsBackgroundColor = globals[BACKGROUNDS_PARAM_KEY]?.value;
const backgroundsConfig = parameters[BACKGROUNDS_PARAM_KEY];
const selectedBackgroundColor = useMemo(() => {
if (backgroundsConfig.disable) {
return 'transparent';
}
return getBackgroundColorByName(
globalsBackgroundColor,
backgroundsConfig.values,
backgroundsConfig.default
);
}, [backgroundsConfig, globalsBackgroundColor]);
const isActive = useMemo(
() => selectedBackgroundColor && selectedBackgroundColor !== 'transparent',
[selectedBackgroundColor]
);
const selector =
context.viewMode === 'docs' ? `#anchor--${context.id} .docs-story` : '.sb-show-main';
const backgroundStyles = useMemo(() => {
const transitionStyle = 'transition: background-color 0.3s;';
return `
${selector} {
background: ${selectedBackgroundColor} !important;
${isReduceMotionEnabled() ? '' : transitionStyle}
}
`;
}, [selectedBackgroundColor, selector]);
useEffect(() => {
const selectorId =
context.viewMode === 'docs'
? `addon-backgrounds-docs-${context.id}`
: `addon-backgrounds-color`;
if (!isActive) {
clearStyles(selectorId);
return;
}
addBackgroundStyle(
selectorId,
backgroundStyles,
context.viewMode === 'docs' ? context.id : null
);
}, [isActive, backgroundStyles, context]);
return StoryFn();
};

View File

@ -1,61 +0,0 @@
import type { DecoratorFunction } from 'storybook/internal/types';
import { useEffect, useMemo } from 'storybook/preview-api';
import { PARAM_KEY as BACKGROUNDS_PARAM_KEY } from '../constants';
import { addGridStyle, clearStyles } from '../utils';
export const withGrid: DecoratorFunction = (StoryFn, context) => {
const { globals, parameters } = context;
const gridParameters = parameters[BACKGROUNDS_PARAM_KEY].grid;
const isActive = globals[BACKGROUNDS_PARAM_KEY]?.grid === true && gridParameters.disable !== true;
const { cellAmount, cellSize, opacity } = gridParameters;
const isInDocs = context.viewMode === 'docs';
const isLayoutPadded = parameters.layout === undefined || parameters.layout === 'padded';
// 16px offset in the grid to account for padded layout
const defaultOffset = isLayoutPadded ? 16 : 0;
const offsetX = gridParameters.offsetX ?? (isInDocs ? 20 : defaultOffset);
const offsetY = gridParameters.offsetY ?? (isInDocs ? 20 : defaultOffset);
const gridStyles = useMemo(() => {
const selector =
context.viewMode === 'docs' ? `#anchor--${context.id} .docs-story` : '.sb-show-main';
const backgroundSize = [
`${cellSize * cellAmount}px ${cellSize * cellAmount}px`,
`${cellSize * cellAmount}px ${cellSize * cellAmount}px`,
`${cellSize}px ${cellSize}px`,
`${cellSize}px ${cellSize}px`,
].join(', ');
return `
${selector} {
background-size: ${backgroundSize} !important;
background-position: ${offsetX}px ${offsetY}px, ${offsetX}px ${offsetY}px, ${offsetX}px ${offsetY}px, ${offsetX}px ${offsetY}px !important;
background-blend-mode: difference !important;
background-image: linear-gradient(rgba(130, 130, 130, ${opacity}) 1px, transparent 1px),
linear-gradient(90deg, rgba(130, 130, 130, ${opacity}) 1px, transparent 1px),
linear-gradient(rgba(130, 130, 130, ${opacity / 2}) 1px, transparent 1px),
linear-gradient(90deg, rgba(130, 130, 130, ${
opacity / 2
}) 1px, transparent 1px) !important;
}
`;
}, [cellSize]);
useEffect(() => {
const selectorId =
context.viewMode === 'docs'
? `addon-backgrounds-grid-docs-${context.id}`
: `addon-backgrounds-grid`;
if (!isActive) {
clearStyles(selectorId);
return;
}
addGridStyle(selectorId, gridStyles);
}, [isActive, gridStyles, context]);
return StoryFn();
};

View File

@ -1,25 +1,15 @@
import React, { Fragment } from 'react';
import React from 'react';
import { addons, types } from 'storybook/manager-api';
import { BackgroundTool } from './components/Tool';
import { ADDON_ID } from './constants';
import { BackgroundToolLegacy } from './legacy/BackgroundSelectorLegacy';
import { GridToolLegacy } from './legacy/GridSelectorLegacy';
addons.register(ADDON_ID, () => {
addons.add(ADDON_ID, {
title: 'Backgrounds',
type: types.TOOL,
match: ({ viewMode, tabId }) => !!(viewMode && viewMode.match(/^(story|docs)$/)) && !tabId,
render: () =>
FEATURES?.backgroundsStoryGlobals ? (
<BackgroundTool />
) : (
<Fragment>
<BackgroundToolLegacy />
<GridToolLegacy />
</Fragment>
),
render: () => <BackgroundTool />,
});
});

View File

@ -1,13 +1,8 @@
import { PARAM_KEY as KEY } from './constants';
import { withBackgroundAndGrid } from './decorator';
import { DEFAULT_BACKGROUNDS } from './defaults';
import { withBackground } from './legacy/withBackgroundLegacy';
import { withGrid } from './legacy/withGridLegacy';
import type { Config, GlobalState } from './types';
import type { BackgroundsParameters, GlobalState } from './types';
export const decorators = globalThis.FEATURES?.backgroundsStoryGlobals
? [withBackgroundAndGrid]
: [withGrid, withBackground];
export const decorators = [withBackgroundAndGrid];
export const parameters = {
[KEY]: {
@ -17,17 +12,9 @@ export const parameters = {
cellAmount: 5,
},
disable: false,
// TODO: remove in 9.0
...(!globalThis.FEATURES?.backgroundsStoryGlobals && {
values: Object.values(DEFAULT_BACKGROUNDS),
}),
} satisfies Partial<Config>,
};
},
} satisfies Partial<BackgroundsParameters>;
const modern: Record<string, GlobalState> = {
export const initialGlobals: Record<string, GlobalState> = {
[KEY]: { value: undefined, grid: false },
};
export const initialGlobals = globalThis.FEATURES?.backgroundsStoryGlobals
? modern
: { [KEY]: null };

View File

@ -1,3 +1,5 @@
import type { PARAM_KEY } from './constants';
export interface Background {
name: string;
value: string;
@ -13,33 +15,19 @@ export interface GridConfig {
offsetY?: number;
}
export interface Config {
options: BackgroundMap;
disable: boolean;
grid: GridConfig;
}
export type GlobalState = { value: string | undefined; grid: boolean };
export type GlobalStateUpdate = Partial<GlobalState>;
export interface BackgroundsParameters {
/**
* Backgrounds configuration
*
* @see https://storybook.js.org/docs/essentials/backgrounds#parameters
*/
backgrounds: {
/** Default background color */
default?: string;
[PARAM_KEY]: {
/** Remove the addon panel and disable the addon's behavior */
disable?: boolean;
/** Configuration for the background grid */
grid?: Partial<GridConfig>;
grid?: GridConfig;
/** Available background colors */
values?: Array<Background>;
options?: BackgroundMap;
};
}
@ -49,5 +37,5 @@ export interface BackgroundsGlobals {
*
* @see https://storybook.js.org/docs/essentials/backgrounds#globals
*/
backgrounds: GlobalState;
[PARAM_KEY]: GlobalState | GlobalState['value'];
}

View File

@ -22,6 +22,12 @@ export const Set = {
},
};
export const Shorthand = {
globals: {
backgrounds: 'red',
},
};
export const SetAndCustom = {
parameters: {
backgrounds: {

File diff suppressed because one or more lines are too long

View File

@ -11,7 +11,7 @@ import { PARAM_KEY as KEY } from '../constants';
import { MINIMAL_VIEWPORTS } from '../defaults';
import { responsiveViewport } from '../responsiveViewport';
import { registerShortcuts } from '../shortcuts';
import type { Config, GlobalStateUpdate, Viewport, ViewportMap } from '../types';
import type { GlobalStateUpdate, Viewport, ViewportMap, ViewportParameters } from '../types';
import {
ActiveViewportLabel,
ActiveViewportSize,
@ -36,14 +36,14 @@ interface PureProps {
type Link = Parameters<typeof TooltipLinkList>['0']['links'][0];
export const ViewportTool: FC<{ api: API }> = ({ api }) => {
const config = useParameter<Config>(KEY);
const config = useParameter<ViewportParameters['viewport']>(KEY);
const [globals, updateGlobals, storyGlobals] = useGlobals();
const [isTooltipVisible, setIsTooltipVisible] = useState(false);
const { options = MINIMAL_VIEWPORTS, disable } = config || {};
const data = globals?.[KEY] || {};
const viewportName: string = data.value;
const isRotated: boolean = data.isRotated;
const viewportName: string = typeof data === 'string' ? data : data.value;
const isRotated: boolean = typeof data === 'string' ? false : data.isRotated;
const item = options[viewportName] || responsiveViewport;
const isActive = isTooltipVisible || item !== responsiveViewport;

View File

@ -1,244 +0,0 @@
import type { FC, ReactNode } from 'react';
import React, { Fragment, memo, useEffect, useRef, useState } from 'react';
import { IconButton, TooltipLinkList, WithTooltip } from 'storybook/internal/components';
import { GrowIcon, TransferIcon } from '@storybook/icons';
import memoize from 'memoizerific';
import { useGlobals, useParameter, useStorybookApi } from 'storybook/manager-api';
import { Global, styled } from 'storybook/theming';
import { PARAM_KEY } from '../constants';
import { MINIMAL_VIEWPORTS } from '../defaults';
import { registerShortcuts } from '../shortcuts';
import type { Styles, ViewportMap, ViewportStyles } from '../types';
import type { ViewportAddonParameter } from './ViewportAddonParameter';
interface ViewportItem {
id: string;
title: string;
styles: Styles;
type: 'desktop' | 'mobile' | 'tablet' | 'other';
default?: boolean;
}
const toList = memoize(50)((items: ViewportMap): ViewportItem[] => [
...baseViewports,
...Object.entries(items).map(([id, { name, ...rest }]) => ({ ...rest, id, title: name })),
]);
const responsiveViewport: ViewportItem = {
id: 'reset',
title: 'Reset viewport',
styles: null,
type: 'other',
};
const baseViewports: ViewportItem[] = [responsiveViewport];
const toLinks = memoize(50)((
list: ViewportItem[],
active: LinkBase,
updateGlobals,
close
): Link[] => {
return list
.filter((i) => i.id !== responsiveViewport.id || active.id !== i.id)
.map((i) => {
return {
...i,
onClick: () => {
updateGlobals({ viewport: i.id });
close();
},
};
});
});
interface LinkBase {
id: string;
title: string;
right?: ReactNode;
type: 'desktop' | 'mobile' | 'tablet' | 'other';
styles: ViewportStyles | ((s: ViewportStyles) => ViewportStyles) | null;
}
interface Link extends LinkBase {
onClick: () => void;
}
const flip = ({ width, height, ...styles }: ViewportStyles) => ({
...styles,
height: width,
width: height,
});
const ActiveViewportSize = styled.div({
display: 'inline-flex',
alignItems: 'center',
});
const ActiveViewportLabel = styled.div(({ theme }) => ({
display: 'inline-block',
textDecoration: 'none',
padding: 10,
fontWeight: theme.typography.weight.bold,
fontSize: theme.typography.size.s2 - 1,
lineHeight: '1',
height: 40,
border: 'none',
borderTop: '3px solid transparent',
borderBottom: '3px solid transparent',
background: 'transparent',
}));
const IconButtonWithLabel = styled(IconButton)(() => ({
display: 'inline-flex',
alignItems: 'center',
}));
const IconButtonLabel = styled.div(({ theme }) => ({
fontSize: theme.typography.size.s2 - 1,
marginLeft: 10,
}));
const getStyles = (
prevStyles: ViewportStyles | undefined,
styles: Styles,
isRotated: boolean
): ViewportStyles | undefined => {
if (styles === null) {
return undefined;
}
const result = typeof styles === 'function' ? styles(prevStyles) : styles;
return isRotated ? flip(result) : result;
};
export const ViewportToolLegacy: FC = memo(function Tool() {
const [globals, updateGlobals] = useGlobals();
const {
viewports = MINIMAL_VIEWPORTS,
defaultOrientation,
defaultViewport,
disable,
} = useParameter<ViewportAddonParameter>(PARAM_KEY, {});
const list = toList(viewports);
const api = useStorybookApi();
const [isTooltipVisible, setIsTooltipVisible] = useState(false);
if (defaultViewport && !list.find((i) => i.id === defaultViewport)) {
console.warn(
`Cannot find "defaultViewport" of "${defaultViewport}" in addon-viewport configs, please check the "viewports" setting in the configuration.`
);
}
useEffect(() => {
registerShortcuts(api, globals, updateGlobals, Object.keys(viewports));
}, [viewports, globals, globals.viewport, updateGlobals, api]);
useEffect(() => {
const defaultRotated = defaultOrientation === 'landscape';
if (
(defaultViewport && globals.viewport !== defaultViewport) ||
(defaultOrientation && globals.viewportRotated !== defaultRotated)
) {
updateGlobals({
viewport: defaultViewport,
viewportRotated: defaultRotated,
});
}
// NOTE: we don't want to re-run this effect when `globals` changes
// due to https://github.com/storybookjs/storybook/issues/26334
//
// Also, this *will* rerun every time you change story as the parameter is briefly `undefined`.
// This behavior is intentional, if a bit of a happy accident in implementation.
//
// Ultimately this process of "locking in" a parameter value should be
// replaced by https://github.com/storybookjs/storybook/discussions/23347
// or something similar.
//
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultOrientation, defaultViewport, updateGlobals]);
const item =
list.find((i) => i.id === globals.viewport) ||
list.find((i) => i.id === defaultViewport) ||
list.find((i) => i.default) ||
responsiveViewport;
const ref = useRef<ViewportStyles>();
const styles = getStyles(ref.current, item.styles, globals.viewportRotated);
useEffect(() => {
ref.current = styles;
}, [item]);
if (disable || Object.entries(viewports).length === 0) {
return null;
}
return (
<Fragment>
<WithTooltip
placement="top"
tooltip={({ onHide }) => (
<TooltipLinkList links={toLinks(list, item, updateGlobals, onHide)} />
)}
closeOnOutsideClick
onVisibleChange={setIsTooltipVisible}
>
<IconButtonWithLabel
key="viewport"
title="Change the size of the preview"
active={isTooltipVisible || !!styles}
onDoubleClick={() => {
updateGlobals({ viewport: responsiveViewport.id });
}}
>
<GrowIcon />
{styles ? (
<IconButtonLabel>
{globals.viewportRotated ? `${item.title} (L)` : `${item.title} (P)`}
</IconButtonLabel>
) : null}
</IconButtonWithLabel>
</WithTooltip>
{styles ? (
<ActiveViewportSize>
<Global
styles={{
[`iframe[data-is-storybook="true"]`]: {
...(styles || {
width: '100%',
height: '100%',
}),
},
}}
/>
<ActiveViewportLabel title="Viewport width">
{styles.width.replace('px', '')}
</ActiveViewportLabel>
<IconButton
key="viewport-rotate"
title="Rotate viewport"
onClick={() => {
updateGlobals({ viewportRotated: !globals.viewportRotated });
}}
>
<TransferIcon />
</IconButton>
<ActiveViewportLabel title="Viewport height">
{styles.height.replace('px', '')}
</ActiveViewportLabel>
</ActiveViewportSize>
) : null}
</Fragment>
);
});

View File

@ -1,9 +0,0 @@
import type { ViewportMap } from '../types';
// TODO: remove at 9.0
export interface ViewportAddonParameter {
disable?: boolean;
defaultOrientation?: 'portrait' | 'landscape';
defaultViewport?: string;
viewports?: ViewportMap;
}

View File

@ -4,14 +4,12 @@ import { addons, types } from 'storybook/manager-api';
import { ViewportTool } from './components/Tool';
import { ADDON_ID } from './constants';
import { ViewportToolLegacy } from './legacy/ToolLegacy';
addons.register(ADDON_ID, (api) => {
addons.add(ADDON_ID, {
title: 'viewport / media-queries',
type: types.TOOL,
match: ({ viewMode, tabId }) => viewMode === 'story' && !tabId,
render: () =>
FEATURES?.viewportStoryGlobals ? <ViewportTool api={api} /> : <ViewportToolLegacy />,
render: () => <ViewportTool api={api} />,
});
});

View File

@ -1,11 +1,6 @@
import { PARAM_KEY as KEY } from './constants';
import type { GlobalState } from './types';
const modern: Record<string, GlobalState> = {
export const initialGlobals: Record<string, GlobalState> = {
[KEY]: { value: undefined, isRotated: false },
};
// TODO: remove in 9.0
const legacy = { viewport: 'reset', viewportRotated: false };
export const initialGlobals = globalThis.FEATURES?.viewportStoryGlobals ? modern : legacy;

View File

@ -1,12 +1,4 @@
// TODO: remove the function type from styles in 9.0
export type Styles = ViewportStyles | ((s: ViewportStyles | undefined) => ViewportStyles) | null;
export interface Viewport {
name: string;
styles: Styles;
type: 'desktop' | 'mobile' | 'tablet' | 'other';
}
export interface ModernViewport {
name: string;
styles: ViewportStyles;
type: 'desktop' | 'mobile' | 'tablet' | 'other';
@ -19,11 +11,6 @@ export interface ViewportStyles {
export type ViewportMap = Record<string, Viewport>;
export interface Config {
options: Record<string, ModernViewport>;
disable: boolean;
}
export type GlobalState = {
/**
* When set, the viewport is applied and cannot be changed using the toolbar. Must match the key
@ -46,31 +33,19 @@ export interface ViewportParameters {
* @see https://storybook.js.org/docs/essentials/viewport#parameters
*/
viewport: {
/**
* Specifies the default orientation used when viewing a story. Only available if you haven't
* enabled the globals API.
*/
defaultOrientation?: 'landscape' | 'portrait';
/**
* Specifies the default viewport used when viewing a story. Must match a key in the viewports
* (or options) object.
*/
defaultViewport?: string;
/**
* Remove the addon panel and disable the addon's behavior . If you wish to turn off this addon
* for the entire Storybook, you should do so when registering addon-essentials
*
* @see https://storybook.js.org/docs/essentials/index#disabling-addons
*/
disabled?: boolean;
disable?: boolean;
/**
* Specify the available viewports. The width and height values must include the unit, e.g.
* '320px'.
*/
viewports?: Viewport; // TODO: use ModernViewport in 9.0
options: Record<string, Viewport>;
};
}
@ -81,6 +56,6 @@ export interface ViewportGlobals {
* @see https://storybook.js.org/docs/essentials/viewport#globals
*/
viewport: {
[key: string]: GlobalState;
[key: string]: GlobalState | GlobalState['value'];
};
}

View File

@ -45,7 +45,13 @@ export const Invalid = {
},
};
export const NoRationDefined = {
export const Shorthand = {
globals: {
viewport: first,
},
};
export const NoRatioDefined = {
globals: {
viewport: {
value: first,

View File

@ -1,83 +0,0 @@
import { global as globalThis } from '@storybook/global';
import { MINIMAL_VIEWPORTS } from '@storybook/addon-viewport';
import { expect } from 'storybook/test';
// these stories only work with `viewportStoryGlobals` set to false
// because the `default` prop is dropped and because, `values` changed to `options` and is now an object
const first = Object.keys(MINIMAL_VIEWPORTS)[0];
export default {
component: globalThis.Components.Button,
args: {
label: 'Click Me!',
},
parameters: {
viewport: {
viewports: MINIMAL_VIEWPORTS,
},
chromatic: { disable: true },
},
};
export const Basic = {
parameters: {},
};
export const Selected = {
parameters: {
viewport: {
defaultViewport: first,
},
},
play: async () => {
const viewportStyles = MINIMAL_VIEWPORTS[first].styles;
const viewportDimensions = {
width: typeof viewportStyles === 'object' && Number.parseInt(viewportStyles!.width, 10),
height: typeof viewportStyles === 'object' && Number.parseInt(viewportStyles!.height, 10),
};
const windowDimensions = {
width: window.innerWidth,
height: window.innerHeight,
};
await expect(viewportDimensions).toEqual(windowDimensions);
},
tags: ['!test'],
};
export const Orientation = {
parameters: {
viewport: {
defaultViewport: first,
defaultOrientation: 'landscape',
},
},
};
export const Custom = {
parameters: {
viewport: {
defaultViewport: 'phone',
viewports: {
phone: {
name: 'Phone Width',
styles: {
height: '600px',
width: '100vh',
},
type: 'mobile',
},
},
},
},
};
export const Disabled = {
parameters: {
viewport: { disable: true },
},
};

View File

@ -379,10 +379,6 @@ export interface StorybookConfigRaw {
/** Enable asynchronous component rendering in React renderer */
experimentalRSC?: boolean;
/** Use globals & globalTypes for configuring the viewport addon */
viewportStoryGlobals?: boolean;
/** Use globals & globalTypes for configuring the backgrounds addon */
backgroundsStoryGlobals?: boolean;
/** Set NODE_ENV to development in built Storybooks for better testability and debuggability */
developmentModeForBuild?: boolean;
};

View File

@ -42,25 +42,18 @@ test.describe('addon-viewport', () => {
await expect(adjustedDimensions?.width).not.toBe(originalDimensions?.width);
});
test('viewport should be editable when a default viewport is set', async ({ page }) => {
test('viewport should be uneditable when a viewport is set via globals', async ({ page }) => {
const sbPage = new SbPage(page, expect);
// Story parameters/selected is set to small mobile
await sbPage.navigateToStory('addons/viewport/parameters', 'selected');
await sbPage.navigateToStory('addons/viewport/globals', 'selected');
// Measure the original dimensions of previewRoot
const originalDimensions = await sbPage.getCanvasBodyElement().boundingBox();
await expect(originalDimensions?.width).toBeDefined();
// Manually select "large mobile" and give it time to adjust
await sbPage.selectToolbar('[title="Change the size of the preview"]', '#list-item-mobile2');
await new Promise((r) => setTimeout(r, 200));
const toolbar = page.getByTitle('Change the size of the preview');
// Measure the adjusted dimensions of previewRoot after clicking the mobile item.
const adjustedDimensions = await sbPage.getCanvasBodyElement().boundingBox();
await expect(adjustedDimensions?.width).toBeDefined();
// Compare the two widths
await expect(adjustedDimensions?.width).not.toBe(originalDimensions?.width);
await expect(toolbar).toBeDisabled();
});
});

View File

@ -35,18 +35,6 @@ Filter args with a "target" on the type from the render function.
{/* prettier-ignore-end */}
## `backgroundsStoryGlobals`
Type: `boolean`
Configures the [Backgrounds addon](../../essentials/backgrounds.mdx) to opt-in to the new story globals API for configuring backgrounds.
{/* prettier-ignore-start */}
<CodeSnippets path="main-config-features-backgrounds-story-globals.md" />
{/* prettier-ignore-end */}
## `legacyDecoratorFileOrder`
Type: `boolean`
@ -59,18 +47,6 @@ Apply decorators from preview.js before decorators from addons or frameworks. [M
{/* prettier-ignore-end */}
## `viewportStoryGlobals`
Type: `boolean`
Configures the [Viewports addon](../../essentials/viewport.mdx) to opt-in to the new story globals API for configuring viewports.
{/* prettier-ignore-start */}
<CodeSnippets path="main-config-features-viewport-story-globals.md" />
{/* prettier-ignore-end */}
## `developmentModeForBuild`
Type: `boolean`