diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index 27ea0639fc40..ffbe5227a5f4 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -1,3 +1,10 @@ +## 10.2.0-beta.3 + +- Core: Support defineConfig when setting up ESLint plugin - [#32878](https://github.com/storybookjs/storybook/pull/32878), thanks @copilot-swe-agent! +- Core: Viewport UX fixes - [#33557](https://github.com/storybookjs/storybook/pull/33557), thanks @ghengeveld! +- NextJSVite: Add `@opentelemetry/api` to `optimizeDeps` - [#33577](https://github.com/storybookjs/storybook/pull/33577), thanks @ndelangen! +- TypeScript: Reduce `cannot be named` errors - [#33344](https://github.com/storybookjs/storybook/pull/33344), thanks @icopp! + ## 10.2.0-beta.2 - CSF-Factories: Skip non-factory exports instead of throwing error - [#33550](https://github.com/storybookjs/storybook/pull/33550), thanks @kasperpeulen! diff --git a/code/core/src/cli/eslintPlugin.test.ts b/code/core/src/cli/eslintPlugin.test.ts index 793b17f86a17..d265bc298d1f 100644 --- a/code/core/src/cli/eslintPlugin.test.ts +++ b/code/core/src/cli/eslintPlugin.test.ts @@ -430,6 +430,135 @@ describe('configureEslintPlugin', () => { `); }); + it('should configure ESLint plugin correctly with Next.js defineConfig style', async () => { + const mockPackageManager = { + getAllDependencies: vi.fn(), + } satisfies Partial; + + const mockConfigFile = dedent`import { defineConfig, globalIgnores } from "eslint/config"; + import nextVitals from "eslint-config-next/core-web-vitals"; + import nextTs from "eslint-config-next/typescript"; + + const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + globalIgnores([ + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), + ]); + + export default eslintConfig;`; + + vi.mocked(readFile).mockResolvedValue(mockConfigFile); + + await configureEslintPlugin({ + eslintConfigFile: 'eslint.config.mjs', + packageManager: mockPackageManager as any, + isFlatConfig: true, + }); + const [, content] = vi.mocked(writeFile).mock.calls[0]; + expect(content).toMatchInlineSnapshot(` + "// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format + import storybook from "eslint-plugin-storybook"; + + import { defineConfig, globalIgnores } from "eslint/config"; + import nextVitals from "eslint-config-next/core-web-vitals"; + import nextTs from "eslint-config-next/typescript"; + + const eslintConfig = defineConfig([...nextVitals, ...nextTs, globalIgnores([ + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), ...storybook.configs["flat/recommended"]]); + + export default eslintConfig;" + `); + }); + + it('should configure ESLint plugin correctly with direct export default defineConfig', async () => { + const mockPackageManager = { + getAllDependencies: vi.fn(), + } satisfies Partial; + + const mockConfigFile = dedent`import { defineConfig, globalIgnores } from "eslint/config"; + import nextVitals from "eslint-config-next/core-web-vitals"; + import nextTs from "eslint-config-next/typescript"; + + export default defineConfig([ + ...nextVitals, + ...nextTs, + globalIgnores([ + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), + ]);`; + + vi.mocked(readFile).mockResolvedValue(mockConfigFile); + + await configureEslintPlugin({ + eslintConfigFile: 'eslint.config.mjs', + packageManager: mockPackageManager as any, + isFlatConfig: true, + }); + const [, content] = vi.mocked(writeFile).mock.calls[0]; + expect(content).toMatchInlineSnapshot(` + "// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format + import storybook from "eslint-plugin-storybook"; + + import { defineConfig, globalIgnores } from "eslint/config"; + import nextVitals from "eslint-config-next/core-web-vitals"; + import nextTs from "eslint-config-next/typescript"; + + export default defineConfig([...nextVitals, ...nextTs, globalIgnores([ + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), ...storybook.configs["flat/recommended"]]);" + `); + }); + + it('should just add an import if config uses defineConfig from non-eslint/config source', async () => { + const mockPackageManager = { + getAllDependencies: vi.fn(), + } satisfies Partial; + + const mockConfigFile = dedent`import { defineConfig } from "some-other-config-lib"; + + const eslintConfig = defineConfig([ + { rules: { "no-console": "error" } }, + ]); + + export default eslintConfig;`; + + vi.mocked(readFile).mockResolvedValue(mockConfigFile); + + await configureEslintPlugin({ + eslintConfigFile: 'eslint.config.js', + packageManager: mockPackageManager as any, + isFlatConfig: true, + }); + const [, content] = vi.mocked(writeFile).mock.calls[0]; + expect(content).toMatchInlineSnapshot(` + "// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format + import storybook from "eslint-plugin-storybook"; + + import { defineConfig } from "some-other-config-lib"; + + const eslintConfig = defineConfig([ + { rules: { "no-console": "error" } }, + ]); + + export default eslintConfig;" + `); + }); + it('should just add an import if config is of custom unknown format', async () => { const mockPackageManager = { getAllDependencies: vi.fn(), diff --git a/code/core/src/cli/eslintPlugin.ts b/code/core/src/cli/eslintPlugin.ts index f660e9e39e87..107239b31403 100644 --- a/code/core/src/cli/eslintPlugin.ts +++ b/code/core/src/cli/eslintPlugin.ts @@ -56,6 +56,7 @@ export const configureFlatConfig = async (code: string) => { const ast = babelParse(code); let tsEslintLocalName = ''; + let eslintDefineConfigLocalName = ''; let eslintConfigExpression: any = null; /** @@ -77,6 +78,14 @@ export const configureFlatConfig = async (code: string) => { tsEslintLocalName = defaultSpecifier.local.name; } } + if (path.node.source.value === 'eslint/config') { + const defineConfigSpecifier = path.node.specifiers.find( + (s) => t.isImportSpecifier(s) && t.isIdentifier(s.imported, { name: 'defineConfig' }) + ); + if (defineConfigSpecifier && t.isImportSpecifier(defineConfigSpecifier)) { + eslintDefineConfigLocalName = defineConfigSpecifier.local.name; + } + } }, ExportDefaultDeclaration(path) { @@ -105,7 +114,24 @@ export const configureFlatConfig = async (code: string) => { eslintConfigExpression.arguments.push(storybookConfig); } - // Case 3: export default config (resolve to array) + // Case 2b: export default defineConfig([...]) from "eslint/config" + if ( + t.isCallExpression(eslintConfigExpression) && + t.isIdentifier(eslintConfigExpression.callee) && + eslintDefineConfigLocalName && + eslintConfigExpression.callee.name === eslintDefineConfigLocalName && + eslintConfigExpression.arguments.length > 0 + ) { + const firstArg = eslintConfigExpression.arguments[0]; + if (t.isExpression(firstArg)) { + const unwrappedArg = unwrapTSExpression(firstArg); + if (unwrappedArg && t.isArrayExpression(unwrappedArg)) { + unwrappedArg.elements.push(t.spreadElement(storybookConfig)); + } + } + } + + // Case 3: export default config (resolve to array or call expression with array) if (t.isIdentifier(eslintConfigExpression)) { const binding = path.scope.getBinding(eslintConfigExpression.name); if (binding && t.isVariableDeclarator(binding.path.node)) { @@ -113,6 +139,21 @@ export const configureFlatConfig = async (code: string) => { if (t.isArrayExpression(init)) { init.elements.push(t.spreadElement(storybookConfig)); + } else if ( + t.isCallExpression(init) && + init.arguments.length > 0 && + t.isIdentifier(init.callee) && + eslintDefineConfigLocalName && + init.callee.name === eslintDefineConfigLocalName + ) { + // Handle cases like defineConfig([...]) from "eslint/config" + const firstArg = init.arguments[0]; + if (t.isExpression(firstArg)) { + const unwrappedArg = unwrapTSExpression(firstArg); + if (unwrappedArg && t.isArrayExpression(unwrappedArg)) { + unwrappedArg.elements.push(t.spreadElement(storybookConfig)); + } + } } } } diff --git a/code/core/src/csf/core-annotations.ts b/code/core/src/csf/core-annotations.ts index 50787695c78c..0d3da53f42ae 100644 --- a/code/core/src/csf/core-annotations.ts +++ b/code/core/src/csf/core-annotations.ts @@ -11,6 +11,15 @@ import outlineAnnotations, { type OutlineTypes } from '../outline/preview'; import testAnnotations, { type TestTypes } from '../test/preview'; import viewportAnnotations, { type ViewportTypes } from '../viewport/preview'; +export type { ActionsTypes } from '../actions/preview'; +export type { BackgroundsGlobals, BackgroundTypes } from '../backgrounds/preview'; +export type { ControlsTypes } from '../controls/preview'; +export type { HighlightTypes } from '../highlight/preview'; +export type { MeasureTypes } from '../measure/preview'; +export type { OutlineTypes } from '../outline/preview'; +export type { TestTypes } from '../test/preview'; +export type { ViewportGlobals, ViewportTypes } from '../viewport/preview'; + export type CoreTypes = StorybookTypes & ActionsTypes & BackgroundTypes & diff --git a/code/core/src/manager/components/layout/Layout.tsx b/code/core/src/manager/components/layout/Layout.tsx index 32bc1e009326..20c120169944 100644 --- a/code/core/src/manager/components/layout/Layout.tsx +++ b/code/core/src/manager/components/layout/Layout.tsx @@ -1,3 +1,4 @@ +import type { CSSProperties } from 'react'; import React, { useEffect, useLayoutEffect, useState } from 'react'; import { Match } from 'storybook/internal/router'; @@ -164,13 +165,15 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s return ( {showPages && {slots.slotPages}} <> @@ -194,6 +197,7 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s @@ -206,38 +210,38 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s ); }; -const LayoutContainer = styled.div( - ({ navSize, rightPanelWidth, bottomPanelHeight, viewMode, panelPosition, showPanel }) => { - return { - width: '100%', - height: ['100vh', '100dvh'], // This array is a special Emotion syntax to set a fallback if 100dvh is not supported - overflow: 'hidden', - display: 'flex', - flexDirection: 'column', - colorScheme: 'light dark', - - [MEDIA_DESKTOP_BREAKPOINT]: { - display: 'grid', - gap: 0, - gridTemplateColumns: `minmax(0, ${navSize}px) minmax(${MINIMUM_CONTENT_WIDTH_PX}px, 1fr) minmax(0, ${rightPanelWidth}px)`, - gridTemplateRows: `1fr minmax(0, ${bottomPanelHeight}px)`, - gridTemplateAreas: (() => { - if (!showPanel) { - // showPanel is false by default when viewMode is not 'story', but can be overridden by the user - return `"sidebar content content" +const LayoutContainer = styled.div<{ + panelPosition: LayoutState['panelPosition']; + showPanel: boolean; +}>(({ panelPosition, showPanel }) => ({ + width: '100%', + height: ['100vh', '100dvh'], + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + colorScheme: 'light dark', + + [MEDIA_DESKTOP_BREAKPOINT]: { + display: 'grid', + gap: 0, + // This uses CSS variables to prevent Emotion from generating a new CSS className for every possible value + gridTemplateColumns: `minmax(0, var(--nav-width)) minmax(${MINIMUM_CONTENT_WIDTH_PX}px, 1fr) minmax(0, var(--right-panel-width))`, + gridTemplateRows: `1fr minmax(0, var(--bottom-panel-height))`, + gridTemplateAreas: (() => { + if (!showPanel) { + // showPanel is false by default when viewMode is not 'story', but can be overridden by the user + return `"sidebar content content" "sidebar content content"`; - } - if (panelPosition === 'right') { - return `"sidebar content panel" + } + if (panelPosition === 'right') { + return `"sidebar content panel" "sidebar content panel"`; - } - return `"sidebar content content" + } + return `"sidebar content content" "sidebar panel panel"`; - })(), - }, - }; - } -); + })(), + }, +})); const SidebarContainer = styled.div(({ theme }) => ({ backgroundColor: theme.appBg, @@ -277,10 +281,23 @@ const PanelContainer = styled.div<{ position: LayoutState['panelPosition'] }>( backgroundColor: theme.appContentBg, borderTop: position === 'bottom' ? `1px solid ${theme.appBorderColor}` : undefined, borderLeft: position === 'right' ? `1px solid ${theme.appBorderColor}` : undefined, + '& > aside': { + overflow: 'hidden', + }, }) ); -const Drag = styled.div<{ orientation?: 'horizontal' | 'vertical'; position?: 'left' | 'right' }>( +/** + * Drag handle for the sidebar and panel resizers. Can be horizontal (bottom panel) or vertical + * (sidebar or right panel). Can optionally be set to not overlap the content area (only render + * outside of it), which is necessary when the panel is collapsed to prevent a layout shift when + * scrollIntoView is used. + */ +const Drag = styled.div<{ + orientation?: 'horizontal' | 'vertical'; + overlapping?: boolean; + position?: 'left' | 'right'; +}>( ({ theme }) => ({ position: 'absolute', opacity: 0, @@ -297,41 +314,39 @@ const Drag = styled.div<{ orientation?: 'horizontal' | 'vertical'; position?: 'l opacity: 1, }, }), - ({ orientation = 'vertical', position = 'left' }) => { - if (orientation === 'vertical') { - return { - width: position === 'left' ? 10 : 13, - height: '100%', - top: 0, - right: position === 'left' ? '-7px' : undefined, - left: position === 'right' ? '-7px' : undefined, - - '&:after': { - width: 1, + ({ orientation = 'vertical', overlapping = true, position = 'left' }) => + orientation === 'vertical' + ? { + width: overlapping ? (position === 'left' ? 10 : 13) : 7, height: '100%', - marginLeft: position === 'left' ? 3 : 6, - }, - - '&:hover': { - cursor: 'col-resize', - }, - }; - } - return { - width: '100%', - height: '13px', - top: '-7px', - left: 0, - - '&:after': { - width: '100%', - height: 1, - marginTop: 6, - }, - - '&:hover': { - cursor: 'row-resize', - }, - }; - } + top: 0, + right: position === 'left' ? -7 : undefined, + left: position === 'right' ? -7 : undefined, + + '&:after': { + width: 1, + height: '100%', + marginLeft: position === 'left' ? 3 : 6, + }, + + '&:hover': { + cursor: 'col-resize', + }, + } + : { + width: '100%', + height: overlapping ? 13 : 7, + top: -7, + left: 0, + + '&:after': { + width: '100%', + height: 1, + marginTop: 6, + }, + + '&:hover': { + cursor: 'row-resize', + }, + } ); diff --git a/code/core/src/manager/components/preview/Viewport.tsx b/code/core/src/manager/components/preview/Viewport.tsx index 9064fbf95763..9c0fa7f46975 100644 --- a/code/core/src/manager/components/preview/Viewport.tsx +++ b/code/core/src/manager/components/preview/Viewport.tsx @@ -223,29 +223,25 @@ export const Viewport = ({ const dragRefXY = useRef(null); const dragSide = useRef('none'); const dragStart = useRef<[number, number] | undefined>(); + const dragScrollTarget = useRef(null); useEffect(() => { - const scrollRight = targetRef.current?.querySelector('[data-edge="right"]'); - const scrollBottom = targetRef.current?.querySelector('[data-edge="bottom"]'); - const scrollBoth = targetRef.current?.querySelector('[data-edge="both"]'); - const onDrag = (e: MouseEvent) => { - if (dragSide.current === 'both' || dragSide.current === 'right') { - targetRef.current!.style.width = `${dragStart.current![0] + e.clientX}px`; - dragRefX.current!.dataset.value = `${dragStart.current![0] + e.clientX}`; - } - if (dragSide.current === 'both' || dragSide.current === 'bottom') { - targetRef.current!.style.height = `${dragStart.current![1] + e.clientY}px`; - dragRefY.current!.dataset.value = `${dragStart.current![1] + e.clientY}`; + if (!targetRef.current || !dragStart.current) { + return; } - if (dragSide.current === 'both') { - scrollBoth?.scrollIntoView({ block: 'center', inline: 'center' }); + if (dragRefX.current && (dragSide.current === 'both' || dragSide.current === 'right')) { + const newWidth = Math.max(VIEWPORT_MIN_WIDTH, dragStart.current[0] + e.clientX); + targetRef.current.style.width = `${newWidth}px`; + dragRefX.current.dataset.value = `${Math.round(newWidth / scale)}`; } - if (dragSide.current === 'right') { - scrollRight?.scrollIntoView({ block: 'center', inline: 'center' }); + if (dragRefY.current && (dragSide.current === 'both' || dragSide.current === 'bottom')) { + const newHeight = Math.max(VIEWPORT_MIN_HEIGHT, dragStart.current[1] + e.clientY); + targetRef.current.style.height = `${newHeight}px`; + dragRefY.current.dataset.value = `${Math.round(newHeight / scale)}`; } - if (dragSide.current === 'bottom') { - scrollBottom?.scrollIntoView({ block: 'center', inline: 'center' }); + if (dragScrollTarget.current) { + dragScrollTarget.current.scrollIntoView({ block: 'center', inline: 'center' }); } }; @@ -253,10 +249,12 @@ export const Viewport = ({ window.removeEventListener('mouseup', onEnd); window.removeEventListener('mousemove', onDrag); setDragging('none'); - const { clientWidth, clientHeight } = targetRef.current!; - const scale = Number(targetRef.current!.dataset.scale) || 1; - resize(`${Math.round(clientWidth / scale)}px`, `${Math.round(clientHeight / scale)}px`); dragStart.current = undefined; + if (targetRef.current) { + const { clientWidth, clientHeight, dataset } = targetRef.current; + const scale = Number(dataset.scale) || 1; + resize(`${Math.round(clientWidth / scale)}px`, `${Math.round(clientHeight / scale)}px`); + } }; const onStart = (e: MouseEvent) => { @@ -268,20 +266,31 @@ export const Viewport = ({ (targetRef.current?.clientWidth ?? 0) - e.clientX, (targetRef.current?.clientHeight ?? 0) - e.clientY, ]; + dragScrollTarget.current = targetRef.current?.querySelector( + `[data-edge="${dragSide.current}"]` + ); setDragging(dragSide.current); }; const handles = [dragRefX.current, dragRefY.current, dragRefXY.current]; handles.forEach((el) => el?.addEventListener('mousedown', onStart)); return () => handles.forEach((el) => el?.removeEventListener('mousedown', onStart)); - }, [resize]); + }, [resize, scale]); - const frameStyles = useMemo(() => { + const dimensions = useMemo(() => { const { number: nx, unit: ux = 'px' } = parseNumber(width) ?? { number: 0, unit: 'px' }; const { number: ny, unit: uy = 'px' } = parseNumber(height) ?? { number: 0, unit: 'px' }; + const frameWidth = Math.max(VIEWPORT_MIN_WIDTH, nx * scale); + const frameHeight = Math.max(VIEWPORT_MIN_HEIGHT, ny * scale); return { - width: `${nx * scale}${ux}`, - height: `${ny * scale}${uy}`, + frame: { + width: `${frameWidth}${ux}`, + height: `${frameHeight}${uy}`, + }, + display: { + width: `${nx}${ux === 'px' ? '' : ux}`, + height: `${ny}${uy === 'px' ? '' : uy}`, + }, }; }, [width, height, scale]); @@ -343,7 +352,7 @@ export const Viewport = ({ isDefault={isDefault} data-dragging={dragging} data-scale={scale} - style={isDefault ? { height: '100%', width: '100%' } : frameStyles} + style={isDefault ? { height: '100%', width: '100%' } : dimensions.frame} ref={targetRef} >
diff --git a/code/frameworks/nextjs-vite/src/preset.ts b/code/frameworks/nextjs-vite/src/preset.ts index e99f379437a5..6e355e5b7263 100644 --- a/code/frameworks/nextjs-vite/src/preset.ts +++ b/code/frameworks/nextjs-vite/src/preset.ts @@ -56,6 +56,7 @@ export const optimizeViteDeps = [ '@storybook/nextjs-vite/router.mock', '@storybook/nextjs-vite > styled-jsx', '@storybook/nextjs-vite > styled-jsx/style', + '@opentelemetry/api', ]; export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, options) => { diff --git a/code/package.json b/code/package.json index 3d6852f17dd7..3424f50af9fa 100644 --- a/code/package.json +++ b/code/package.json @@ -220,5 +220,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "10.2.0-beta.3" } diff --git a/docs/versions/next.json b/docs/versions/next.json index 75be8b03ab0b..36ed10b90512 100644 --- a/docs/versions/next.json +++ b/docs/versions/next.json @@ -1 +1 @@ -{"version":"10.2.0-beta.2","info":{"plain":"- CSF-Factories: Skip non-factory exports instead of throwing error - [#33550](https://github.com/storybookjs/storybook/pull/33550), thanks @kasperpeulen!\n- Core: Add zoom level 8 and limit manual input to 800% - [#33561](https://github.com/storybookjs/storybook/pull/33561), thanks @ghengeveld!\n- Core: Fix Checklist behavior with hidden sidebar - [#33556](https://github.com/storybookjs/storybook/pull/33556), thanks @ghengeveld!\n- Core: Fix viewport args handling and reset option - [#33560](https://github.com/storybookjs/storybook/pull/33560), thanks @ghengeveld!\n- Dependencies: Update `baseline-browser-mapping` - [#33576](https://github.com/storybookjs/storybook/pull/33576), thanks @ndelangen!\n- Onboarding: Fix navigation to first story when configure-your-project entry missing - [#33559](https://github.com/storybookjs/storybook/pull/33559), thanks @copilot-swe-agent!\n- Zoom: Keyboardshortcut for the `plus` key - [#33565](https://github.com/storybookjs/storybook/pull/33565), thanks @ndelangen!"}} \ No newline at end of file +{"version":"10.2.0-beta.3","info":{"plain":"- Core: Support defineConfig when setting up ESLint plugin - [#32878](https://github.com/storybookjs/storybook/pull/32878), thanks @copilot-swe-agent!\n- Core: Viewport UX fixes - [#33557](https://github.com/storybookjs/storybook/pull/33557), thanks @ghengeveld!\n- NextJSVite: Add `@opentelemetry/api` to `optimizeDeps` - [#33577](https://github.com/storybookjs/storybook/pull/33577), thanks @ndelangen!\n- TypeScript: Reduce `cannot be named` errors - [#33344](https://github.com/storybookjs/storybook/pull/33344), thanks @icopp!"}} \ No newline at end of file