diff --git a/code/e2e-tests/framework-nextjs.spec.ts b/code/e2e-tests/framework-nextjs.spec.ts new file mode 100644 index 000000000000..a40765842fe4 --- /dev/null +++ b/code/e2e-tests/framework-nextjs.spec.ts @@ -0,0 +1,90 @@ +/* eslint-disable jest/no-disabled-tests */ +import type { Locator } from '@playwright/test'; +import { test, expect } from '@playwright/test'; +import process from 'process'; +import { SbPage } from './util'; + +const storybookUrl = process.env.STORYBOOK_URL || 'http://localhost:6006'; +const templateName = process.env.STORYBOOK_TEMPLATE_NAME; + +test.describe('Next.js', () => { + test.beforeEach(async ({ page }) => { + await page.goto(storybookUrl); + await new SbPage(page).waitUntilLoaded(); + }); + + test.describe('next/navigation', () => { + test.skip( + // eslint-disable-next-line jest/valid-title + !templateName.includes('nextjs/default-js'), + 'Only run this test for the Frameworks that support next/navigation' + ); + + let root: Locator; + let sbPage: SbPage; + + function testRoutingBehaviour(buttonText: string, action: string) { + test(`should trigger ${action} action`, async ({ page }) => { + const button = root.locator('button', { hasText: buttonText }); + await button.click(); + + await sbPage.viewAddonPanel('Actions'); + const logItem = await page.locator('#storybook-panel-root #panel-tab-content', { + hasText: `nextNavigation.${action}`, + }); + await expect(logItem).toBeVisible(); + }); + } + + test.beforeEach(async ({ page }) => { + sbPage = new SbPage(page); + + await sbPage.navigateToStory('frameworks/nextjs_default-js/Navigation', 'default'); + root = sbPage.previewRoot(); + }); + + testRoutingBehaviour('Go back', 'back'); + testRoutingBehaviour('Go forward', 'forward'); + testRoutingBehaviour('Prefetch', 'prefetch'); + testRoutingBehaviour('Push HTML', 'push'); + testRoutingBehaviour('Refresh', 'refresh'); + testRoutingBehaviour('Replace', 'replace'); + }); + + test.describe('next/router', () => { + test.skip( + // eslint-disable-next-line jest/valid-title + !templateName.includes('nextjs'), + 'Only run this test for the Frameworks that support next/router' + ); + + let root: Locator; + let sbPage: SbPage; + + function testRoutingBehaviour(buttonText: string, action: string) { + test(`should trigger ${action} action`, async ({ page }) => { + const button = root.locator('button', { hasText: buttonText }); + await button.click(); + + await sbPage.viewAddonPanel('Actions'); + const logItem = await page.locator('#storybook-panel-root #panel-tab-content', { + hasText: `nextRouter.${action}`, + }); + await expect(logItem).toBeVisible(); + }); + } + + test.beforeEach(async ({ page }) => { + sbPage = new SbPage(page); + + await sbPage.navigateToStory('frameworks/nextjs_default-js/Router', 'default'); + root = sbPage.previewRoot(); + }); + + testRoutingBehaviour('Go back', 'back'); + testRoutingBehaviour('Go forward', 'forward'); + testRoutingBehaviour('Prefetch', 'prefetch'); + testRoutingBehaviour('Push HTML', 'push'); + testRoutingBehaviour('Replace', 'replace'); + }); +}); diff --git a/code/frameworks/nextjs/README.md b/code/frameworks/nextjs/README.md index b6b8ea00c0aa..38f532ed3127 100644 --- a/code/frameworks/nextjs/README.md +++ b/code/frameworks/nextjs/README.md @@ -14,6 +14,7 @@ - [Remote Images](#remote-images) - [Optimization](#optimization) - [AVIF](#avif) + - [Next.js Navigation](#nextjs-navigation) - [Next.js Routing](#nextjs-routing) - [Overriding defaults](#overriding-defaults) - [Global Defaults](#global-defaults) @@ -38,7 +39,9 @@ 👉 [Next.js's Image Component](#nextjss-image-component) -👉 [Next.js Routing](#nextjs-routing) +👉 [Next.js Routing (next/router)](#nextjs-routing) + +👉 [Next.js Navigation (next/navigation)](#nextjs-navigation) 👉 [Sass/Scss](#sassscss) @@ -58,7 +61,7 @@ ## Requirements -- [Next.js](https://nextjs.org/) >= 9.x +- [Next.js](https://nextjs.org/) >= 12.x - [Storybook](https://storybook.js.org/) >= 7.x ## Getting Started @@ -167,22 +170,176 @@ export default function Home() { } ``` -#### Optimization +#### AVIF -All Next.js `Image`s are automatically [unoptimized](https://nextjs.org/docs/api-reference/next/image#unoptimized) for you. +This format is not supported by this framework yet. Feel free to [open up an issue](https://github.com/storybookjs/storybook/issues) if this is something you want to see. -If [placeholder="blur"](https://nextjs.org/docs/api-reference/next/image#placeholder) is used, the [blurDataURL](https://nextjs.org/docs/api-reference/next/image#blurdataurl) used is the [src](https://nextjs.org/docs/api-reference/next/image#src) of the image (thus effectively disabling the placeholder). +### Next.js Navigation -See [this issue](https://github.com/vercel/next.js/issues/18393) for more discussion on how Next.js `Image`s are handled for Storybook. +Please note that [next/navigation](https://beta.nextjs.org/docs/upgrade-guide#step-5-migrating-routing-hooks) can only be used in components/pages of the `app` directory of Next.js v13 or higher. -#### AVIF +#### Set `nextjs.appDirectory` to `true` -This format is not supported by this framework yet. Feel free to [open up an issue](https://github.com/storybookjs/storybook/issues) if this is something you want to see. +If your story imports components that use `next/navigation`, you need to set the parameter `nextjs.appDirectory` to `true` in your Story: + +```js +// SomeComponentThatUsesTheRouter.stories.js +import SomeComponentThatUsesTheNavigation from './SomeComponentThatUsesTheNavigation'; + +export default { + component: SomeComponentThatUsesTheNavigation, +}; + +// if you have the actions addon +// you can click the links and see the route change events there +export const Example = { + parameters: { + nextjs: { + appDirectory: true, + }, + }, +}, +``` + +If your Next.js project uses the `app` directory for every page (in other words, it does not have a `pages` directory), you can set the parameter `nextjs.appDirectory` to `true` in the [preview.js](https://storybook.js.org/docs/react/configure/overview#configure-story-rendering) file to apply it to all stories. + +```js +// .storybook/preview.js + +export const parameters = { + nextjs: { + appDirectory: true, + }, +}; +``` + +The parameter `nextjs.appDirectory` defaults to `false` if not set. + +#### Overriding defaults + +Per-story overrides can be done by adding a `nextjs.navigation` property onto the story [parameters](https://storybook.js.org/docs/react/writing-stories/parameters). The framework will shallowly merge whatever you put here into the router. + +```js +// SomeComponentThatUsesTheNavigation.stories.js +import SomeComponentThatUsesTheNavigation from './SomeComponentThatUsesTheNavigation'; + +export default { + component: SomeComponentThatUsesTheNavigation, +}; + +// if you have the actions addon +// you can click the links and see the route change events there +export const Example = { + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: '/some-default-path', + query: { + foo: 'bar', + }, + }, + }, + }, +}; +``` + +#### Global Defaults + +Global defaults can be set in [preview.js](https://storybook.js.org/docs/react/configure/overview#configure-story-rendering) and will be shallowly merged with the default router. + +```js +// .storybook/preview.js + +export const parameters = { + nextjs: { + appDirectory: true, + navigation: { + pathname: '/some-default-path', + query: { + foo: 'bar', + }, + }, + }, +}; +``` + +#### Default Navigation Context + +The default values on the stubbed navigation context are as follows: + +```ts +const defaultNavigationContext = { + push(...args) { + action('nextNavigation.push')(...args); + }, + replace(...args) { + action('nextNavigation.replace')(...args); + }, + forward(...args) { + action('nextNavigation.forward')(...args); + }, + back(...args) { + action('nextNavigation.back')(...args); + }, + prefetch(...args) { + action('nextNavigation.prefetch')(...args); + }, + refresh: () => { + action('nextNavigation.refresh')(); + }, + pathname: '/', + query: {}, +}; +``` + +#### Actions Integration Caveats + +If you override a function, you lose the automatic action tab integration and have to build it out yourself. + +```js +// .storybook/preview.js + +export const parameters = { + nextjs: { + appDirectory: true, + navigation: { + push() { + // The default implementation that logs the action into the action tab is lost + }, + }, + }, +}; +``` + +Doing this yourself looks something like this (make sure you install the `@storybook/addon-actions` package): + +```js +// .storybook/preview.js +import { action } from '@storybook/addon-actions'; + +export const parameters = { + nextjs: { + appDirectory: true, + navigation: { + push(...args) { + // custom logic can go here + // this logs to the actions tab + action('nextNavigation.push')(...args); + // return whatever you want here + return Promise.resolve(true); + }, + }, + }, +}; +``` ### Next.js Routing [Next.js's router](https://nextjs.org/docs/routing/introduction) is automatically stubbed for you so that when the router is interacted with, all of its interactions are automatically logged to the [Storybook actions tab](https://storybook.js.org/docs/react/essentials/actions) if you have the actions addon. +You should only use `next/router` in the `pages` directory of Next.js v13 or higher. In the `app` directory, it is necessary to use `next/navigation`. + #### Overriding defaults Per-story overrides can be done by adding a `nextRouter` property onto the story [parameters](https://storybook.js.org/docs/react/writing-stories/parameters). The framework will shallowly merge whatever you put here into the router. @@ -199,11 +356,13 @@ export default { // you can click the links and see the route change events there export const Example = { parameters: { - nextRouter: { - path: '/profile/[id]', - asPath: '/profile/ryanclementshax', - query: { - id: 'ryanclementshax', + nextjs: { + router: { + path: '/profile/[id]', + asPath: '/profile/ryanclementshax', + query: { + id: 'ryanclementshax', + }, }, }, }, @@ -215,13 +374,15 @@ export const Example = { Global defaults can be set in [preview.js](https://storybook.js.org/docs/react/configure/overview#configure-story-rendering) and will be shallowly merged with the default router. ```js -// .storybook/main.js +// .storybook/preview.js export const parameters = { - nextRouter: { - path: '/some-default-path', - asPath: '/some-default-path', - query: {}, + nextjs: { + router: { + path: '/some-default-path', + asPath: '/some-default-path', + query: {}, + }, }, }; ``` @@ -232,44 +393,52 @@ The default values on the stubbed router are as follows (see [globals](https://s ```ts const defaultRouter = { - locale: context?.globals?.locale, - route: '/', - pathname: '/', - query: {}, - asPath: '/', - push(...args: unknown[]) { + push(...args) { action('nextRouter.push')(...args); return Promise.resolve(true); }, - replace(...args: unknown[]) { + replace(...args) { action('nextRouter.replace')(...args); return Promise.resolve(true); }, - reload(...args: unknown[]) { + reload(...args) { action('nextRouter.reload')(...args); }, - back(...args: unknown[]) { + back(...args) { action('nextRouter.back')(...args); }, - prefetch(...args: unknown[]) { + forward() { + action('nextRouter.forward')(); + }, + prefetch(...args) { action('nextRouter.prefetch')(...args); return Promise.resolve(); }, - beforePopState(...args: unknown[]) { + beforePopState(...args) { action('nextRouter.beforePopState')(...args); }, events: { - on(...args: unknown[]) { + on(...args) { action('nextRouter.events.on')(...args); }, - off(...args: unknown[]) { + off(...args) { action('nextRouter.events.off')(...args); }, - emit(...args: unknown[]) { + emit(...args) { action('nextRouter.events.emit')(...args); }, }, + // The locale should be configured [globally](https://storybook.js.org/docs/react/essentials/toolbars-and-globals#globals) + locale: globals?.locale, + asPath: '/', + basePath: '/', isFallback: false, + isLocaleDomain: false, + isReady: true, + isPreview: false, + route: '/', + pathname: '/', + query: {}, }; ``` @@ -278,12 +447,14 @@ const defaultRouter = { If you override a function, you lose the automatic action tab integration and have to build it out yourself. ```js -// .storybook/main.js +// .storybook/preview.js export const parameters = { - nextRouter: { - push() { - // The default implementation that logs the action into the action tab is lost + nextjs: { + router: { + push() { + // The default implementation that logs the action into the action tab is lost + }, }, }, }; @@ -292,17 +463,19 @@ export const parameters = { Doing this yourself looks something like this (make sure you install the `@storybook/addon-actions` package): ```js -// .storybook/main.js +// .storybook/preview.js import { action } from '@storybook/addon-actions'; export const parameters = { - nextRouter: { - push(...args) { - // custom logic can go here - // this logs to the actions tab - action('nextRouter.push')(...args); - // return whatever you want here - return Promise.resolve(true); + nextjs: { + router: { + push(...args) { + // custom logic can go here + // this logs to the actions tab + action('nextRouter.push')(...args); + // return whatever you want here + return Promise.resolve(true); + }, }, }, }; @@ -404,14 +577,6 @@ You can use your own babel config too. This is an example of how you can customi } ``` -If you use a monorepo, you may need to add the babel config yourself to your storybook project. Just add a babel config to your storybook project with the following contents to get started. - -```json -{ - "presets": ["next/babel"] -} -``` - ### Postcss Next.js lets you [customize postcss config](https://nextjs.org/docs/advanced-features/customizing-postcss-config#default-behavior). Thus this framework will automatically handle your postcss config for you. diff --git a/code/frameworks/nextjs/src/config/webpack.ts b/code/frameworks/nextjs/src/config/webpack.ts index 799f95a73df0..a5f99fd51864 100644 --- a/code/frameworks/nextjs/src/config/webpack.ts +++ b/code/frameworks/nextjs/src/config/webpack.ts @@ -34,21 +34,22 @@ const setupRuntimeConfig = (baseConfig: WebpackConfig, nextConfig: NextConfig): }), }; + const newNextLinkBehavior = nextConfig.experimental?.newNextLinkBehavior; + /** - * In Next12.2, the `newNextLinkBehavior` option was introduced, defaulted to - * falsy in the Next app (`undefined` in the config itself), and `next/link` - * was engineered to opt *in* to it - * - * In Next13, the `newNextLinkBehavior` option now defaults to truthy (still + * In Next 13.0.0 - 13.0.5, the `newNextLinkBehavior` option now defaults to truthy (still * `undefined` in the config), and `next/link` was engineered to opt *out* * of it + * */ - const newNextLinkBehavior = nextConfig.experimental?.newNextLinkBehavior; if ( - (semver.gte(version, '13.0.0') && newNextLinkBehavior !== false) || - (semver.gte(version, '12.2.0') && newNextLinkBehavior) + semver.gte(version, '13.0.0') && + semver.lt(version, '13.0.6') && + newNextLinkBehavior !== false ) { definePluginConfig['process.env.__NEXT_NEW_LINK_BEHAVIOR'] = true; + } else { + definePluginConfig['process.env.__NEXT_NEW_LINK_BEHAVIOR'] = newNextLinkBehavior; } baseConfig.plugins?.push(new DefinePlugin(definePluginConfig)); diff --git a/code/frameworks/nextjs/src/nextImport/webpack.ts b/code/frameworks/nextjs/src/nextImport/webpack.ts index b0ff1c5175cd..848904c900af 100644 --- a/code/frameworks/nextjs/src/nextImport/webpack.ts +++ b/code/frameworks/nextjs/src/nextImport/webpack.ts @@ -8,6 +8,8 @@ export function configureNextImport(baseConfig: WebpackConfig) { const isNext12 = semver.satisfies(nextJSVersion, '~12'); const isNext13 = semver.satisfies(nextJSVersion, '~13'); + const isNextVersionSmallerThan13 = semver.lt(nextJSVersion, '13.0.0'); + const isNextVersionSmallerThan12 = semver.lt(nextJSVersion, '12.0.0'); baseConfig.plugins = baseConfig.plugins ?? []; @@ -26,4 +28,20 @@ export function configureNextImport(baseConfig: WebpackConfig) { }) ); } + + if (isNextVersionSmallerThan13) { + baseConfig.plugins.push( + new IgnorePlugin({ + resourceRegExp: /next\/dist\/shared\/lib\/hooks-client-context$/, + }) + ); + } + + if (isNextVersionSmallerThan12) { + baseConfig.plugins.push( + new IgnorePlugin({ + resourceRegExp: /next\/dist\/shared\/lib\/app-router-context$/, + }) + ); + } } diff --git a/code/frameworks/nextjs/src/routing/app-router-provider.tsx b/code/frameworks/nextjs/src/routing/app-router-provider.tsx new file mode 100644 index 000000000000..ec1a1e023c48 --- /dev/null +++ b/code/frameworks/nextjs/src/routing/app-router-provider.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { AppRouterContext } from 'next/dist/shared/lib/app-router-context'; +import { PathnameContext, SearchParamsContext } from 'next/dist/shared/lib/hooks-client-context'; +import type { RouteParams } from './types'; + +type AppRouterProviderProps = { + action: (name: string) => (...args: any[]) => void; + routeParams: RouteParams; +}; + +const AppRouterProvider: React.FC = ({ children, action, routeParams }) => { + const { pathname, query, ...restRouteParams } = routeParams; + return ( + { + action('nextNavigation.refresh')(); + }, + ...restRouteParams, + }} + > + + {children} + + + ); +}; + +export default AppRouterProvider; diff --git a/code/frameworks/nextjs/src/routing/decorator.tsx b/code/frameworks/nextjs/src/routing/decorator.tsx index a9701894cf91..4e5d9e2390e5 100644 --- a/code/frameworks/nextjs/src/routing/decorator.tsx +++ b/code/frameworks/nextjs/src/routing/decorator.tsx @@ -2,8 +2,17 @@ import * as React from 'react'; // this will be aliased by webpack at runtime (this is just for typing) import type { action as originalAction } from '@storybook/addon-actions'; import type { Addon_StoryContext } from '@storybook/types'; -import { RouterContext } from 'next/dist/shared/lib/router-context'; -import Router from 'next/router'; + +import PageRouterProvider from './page-router-provider'; +import type { RouteParams, NextAppDirectory } from './types'; + +/** + * Dynamic import necessary because otherwise + * older versions of Next.js will throw an error + * because some imports in './app-router-provider' only exists + * in Next.js > v13 + */ +const AppRouterProvider = React.lazy(() => import('./app-router-provider')); let action: typeof originalAction; @@ -13,61 +22,42 @@ try { action = () => () => {}; } -const defaultRouter = { - route: '/', +const defaultRouterParams: RouteParams = { pathname: '/', query: {}, - asPath: '/', - push(...args: unknown[]): Promise { - action('nextRouter.push')(...args); - return Promise.resolve(true); - }, - replace(...args: unknown[]): Promise { - action('nextRouter.replace')(...args); - return Promise.resolve(true); - }, - reload(...args: unknown[]): void { - action('nextRouter.reload')(...args); - }, - back(...args: unknown[]): void { - action('nextRouter.back')(...args); - }, - prefetch(...args: unknown[]): Promise { - action('nextRouter.prefetch')(...args); - return Promise.resolve(); - }, - beforePopState(...args: unknown[]): void { - action('nextRouter.beforePopState')(...args); - }, - events: { - on(...args: unknown[]): void { - action('nextRouter.events.on')(...args); - }, - off(...args: unknown[]): void { - action('nextRouter.events.off')(...args); - }, - emit(...args: unknown[]): void { - action('nextRouter.events.emit')(...args); - }, - }, - isFallback: false, }; export const RouterDecorator = ( Story: React.FC, { globals, parameters }: Addon_StoryContext ): React.ReactNode => { - const nextRouterParams = parameters.nextRouter ?? {}; + const nextAppDirectory = + (parameters.nextjs?.appDirectory as NextAppDirectory | undefined) ?? false; - Router.router = { - ...defaultRouter, - locale: globals?.locale, - ...nextRouterParams, - } as NonNullable; + if (nextAppDirectory) { + return ( + + + + ); + } return ( - + - + ); }; diff --git a/code/frameworks/nextjs/src/routing/page-router-provider.tsx b/code/frameworks/nextjs/src/routing/page-router-provider.tsx new file mode 100644 index 000000000000..c882e3bee5b3 --- /dev/null +++ b/code/frameworks/nextjs/src/routing/page-router-provider.tsx @@ -0,0 +1,70 @@ +import type { Globals } from '@storybook/csf'; +import { RouterContext } from 'next/dist/shared/lib/router-context'; +import React from 'react'; +import type { RouteParams } from './types'; + +type PageRouterProviderProps = { + action: (name: string) => (...args: any[]) => void; + routeParams: RouteParams; + globals: Globals; +}; + +const PageRouterProvider: React.FC = ({ + children, + action, + routeParams, + globals, +}) => ( + + {children} + +); + +export default PageRouterProvider; diff --git a/code/frameworks/nextjs/src/routing/types.tsx b/code/frameworks/nextjs/src/routing/types.tsx new file mode 100644 index 000000000000..e80b0413260f --- /dev/null +++ b/code/frameworks/nextjs/src/routing/types.tsx @@ -0,0 +1,7 @@ +export type RouteParams = { + pathname: string; + query: Record; + [key: string]: any; +}; + +export type NextAppDirectory = boolean; diff --git a/code/frameworks/nextjs/template/stories/DynamicImport.stories.jsx b/code/frameworks/nextjs/template/stories/DynamicImport.stories.jsx new file mode 100644 index 000000000000..3c177d670737 --- /dev/null +++ b/code/frameworks/nextjs/template/stories/DynamicImport.stories.jsx @@ -0,0 +1,20 @@ +import dynamic from 'next/dynamic'; +import React, { Suspense } from 'react'; + +const DynamicComponent = dynamic(() => import('./dynamic-component'), { + ssr: false, +}); + +function Component() { + return ( + + + + ); +} + +export default { + component: Component, +}; + +export const Default = {}; diff --git a/code/frameworks/nextjs/template/stories/dynamic-component.js b/code/frameworks/nextjs/template/stories/dynamic-component.js new file mode 100644 index 000000000000..4863633033f3 --- /dev/null +++ b/code/frameworks/nextjs/template/stories/dynamic-component.js @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function DynamicComponent() { + return
I am a dynamically loaded component
; +} diff --git a/code/frameworks/nextjs/template/stories/Link.stories.jsx b/code/frameworks/nextjs/template/stories_12-js/Link.stories.jsx similarity index 100% rename from code/frameworks/nextjs/template/stories/Link.stories.jsx rename to code/frameworks/nextjs/template/stories_12-js/Link.stories.jsx diff --git a/code/frameworks/nextjs/template/stories_default-js/Link.stories.jsx b/code/frameworks/nextjs/template/stories_default-js/Link.stories.jsx new file mode 100644 index 000000000000..f9fcb28b6253 --- /dev/null +++ b/code/frameworks/nextjs/template/stories_default-js/Link.stories.jsx @@ -0,0 +1,82 @@ +/* eslint-disable react/prop-types */ +import React from 'react'; +import Link from 'next/link'; + +import style from './Link.stories.module.css'; + +// `onClick`, `href`, and `ref` need to be passed to the DOM element +// for proper handling +const MyButton = React.forwardRef(function Button({ onClick, href, children }, ref) { + return ( + + {children} + + ); +}); + +const Component = () => ( +
    +
  • + Normal Link +
  • +
  • + + With URL Object + +
  • +
  • + + Replace the URL instead of push + +
  • +
  • + + Legacy behavior + +
  • +
  • + + child is a functional component + +
  • +
  • + + Disables scrolling to the top + +
  • +
  • + + No Prefetching + +
  • +
  • + + With style + +
  • +
  • + + With className + +
  • +
+); + +export default { + component: Component, +}; + +export const Default = {}; + +export const InAppDir = { + parameters: { + nextjs: { + appDirectory: true, + }, + }, +}; diff --git a/code/frameworks/nextjs/template/stories_default-js/Link.stories.module.css b/code/frameworks/nextjs/template/stories_default-js/Link.stories.module.css new file mode 100644 index 000000000000..9edb616226d0 --- /dev/null +++ b/code/frameworks/nextjs/template/stories_default-js/Link.stories.module.css @@ -0,0 +1,3 @@ +.link { + color: green; +} diff --git a/code/frameworks/nextjs/template/stories_default-js/Navigation.stories.jsx b/code/frameworks/nextjs/template/stories_default-js/Navigation.stories.jsx new file mode 100644 index 000000000000..5335fa098ae6 --- /dev/null +++ b/code/frameworks/nextjs/template/stories_default-js/Navigation.stories.jsx @@ -0,0 +1,77 @@ +import { useRouter, usePathname, useSearchParams } from 'next/navigation'; +import React from 'react'; + +function Component() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const searchParamsList = Array.from(searchParams.entries()); + + const routerActions = [ + { + cb: () => router.back(), + name: 'Go back', + }, + { + cb: () => router.forward(), + name: 'Go forward', + }, + { + cb: () => router.prefetch('/prefetched-html'), + name: 'Prefetch', + }, + { + cb: () => router.push('/push-html', { forceOptimisticNavigation: true }), + name: 'Push HTML', + }, + { + cb: () => router.refresh(), + name: 'Refresh', + }, + { + cb: () => router.replace('/replaced-html', { forceOptimisticNavigation: true }), + name: 'Replace', + }, + ]; + + return ( +
+
pathname: {pathname}
+
+ searchparams:{' '} +
    + {searchParamsList.map(([key, value]) => ( +
  • + {key}: {value} +
  • + ))} +
+
+ {routerActions.map(({ cb, name }) => ( +
+ +
+ ))} +
+ ); +} + +export default { + component: Component, + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: '/hello', + query: { + foo: 'bar', + }, + }, + }, + }, +}; + +export const Default = {}; diff --git a/code/frameworks/nextjs/template/stories_default-js/Router.stories.jsx b/code/frameworks/nextjs/template/stories_default-js/Router.stories.jsx new file mode 100644 index 000000000000..2ea7511a2f4e --- /dev/null +++ b/code/frameworks/nextjs/template/stories_default-js/Router.stories.jsx @@ -0,0 +1,69 @@ +import { useRouter } from 'next/router'; +import React from 'react'; + +function Component() { + const router = useRouter(); + const searchParams = router.query; + + const routerActions = [ + { + cb: () => router.back(), + name: 'Go back', + }, + { + cb: () => router.forward(), + name: 'Go forward', + }, + { + cb: () => router.prefetch('/prefetched-html'), + name: 'Prefetch', + }, + { + cb: () => router.push('/push-html', { forceOptimisticNavigation: true }), + name: 'Push HTML', + }, + { + cb: () => router.replace('/replaced-html', { forceOptimisticNavigation: true }), + name: 'Replace', + }, + ]; + + return ( +
+
pathname: {router.pathname}
+
+ searchparams:{' '} +
    + {Object.entries(searchParams).map(([key, value]) => ( +
  • + {key}: {value} +
  • + ))} +
+
+ {routerActions.map(({ cb, name }) => ( +
+ +
+ ))} +
+ ); +} + +export default { + component: Component, + parameters: { + nextjs: { + router: { + pathname: '/hello', + query: { + foo: 'bar', + }, + }, + }, + }, +}; + +export const Default = {}; diff --git a/code/frameworks/nextjs/template/stories_default-ts/Link.stories.module.css b/code/frameworks/nextjs/template/stories_default-ts/Link.stories.module.css new file mode 100644 index 000000000000..9edb616226d0 --- /dev/null +++ b/code/frameworks/nextjs/template/stories_default-ts/Link.stories.module.css @@ -0,0 +1,3 @@ +.link { + color: green; +} diff --git a/code/frameworks/nextjs/template/stories_default-ts/Link.stories.tsx b/code/frameworks/nextjs/template/stories_default-ts/Link.stories.tsx new file mode 100644 index 000000000000..1bc99d187e3d --- /dev/null +++ b/code/frameworks/nextjs/template/stories_default-ts/Link.stories.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import Link from 'next/link'; +import type { Meta, StoryObj } from '@storybook/react'; + +import style from './Link.stories.module.css'; + +// `onClick`, `href`, and `ref` need to be passed to the DOM element +// for proper handling +const MyButton = React.forwardRef< + HTMLAnchorElement, + React.DetailedHTMLProps, HTMLAnchorElement> +>(function Button({ onClick, href, children }, ref) { + return ( + + {children} + + ); +}); + +const Component = () => ( +
    +
  • + Normal Link +
  • +
  • + + With URL Object + +
  • +
  • + + Replace the URL instead of push + +
  • +
  • + + Legacy behavior + +
  • +
  • + + child is a functional component + +
  • +
  • + + Disables scrolling to the top + +
  • +
  • + + No Prefetching + +
  • +
  • + + With style + +
  • +
  • + + With className + +
  • +
+); + +export default { + component: Component, +} as Meta; + +export const Default: StoryObj = {}; + +export const InAppDir: StoryObj = { + parameters: { + nextjs: { + appDirectory: true, + }, + }, +}; diff --git a/code/frameworks/nextjs/template/stories_default-ts/Navigation.stories.tsx b/code/frameworks/nextjs/template/stories_default-ts/Navigation.stories.tsx new file mode 100644 index 000000000000..a4c2f2d27758 --- /dev/null +++ b/code/frameworks/nextjs/template/stories_default-ts/Navigation.stories.tsx @@ -0,0 +1,78 @@ +import { useRouter, usePathname, useSearchParams } from 'next/navigation'; +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; + +function Component() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const searchParamsList = Array.from(searchParams.entries()); + + const routerActions = [ + { + cb: () => router.back(), + name: 'Go back', + }, + { + cb: () => router.forward(), + name: 'Go forward', + }, + { + cb: () => router.prefetch('/prefetched-html'), + name: 'Prefetch', + }, + { + cb: () => router.push('/push-html', { forceOptimisticNavigation: true }), + name: 'Push HTML', + }, + { + cb: () => router.refresh(), + name: 'Refresh', + }, + { + cb: () => router.replace('/replaced-html', { forceOptimisticNavigation: true }), + name: 'Replace', + }, + ]; + + return ( +
+
pathname: {pathname}
+
+ searchparams:{' '} +
    + {searchParamsList.map(([key, value]) => ( +
  • + {key}: {value} +
  • + ))} +
+
+ {routerActions.map(({ cb, name }) => ( +
+ +
+ ))} +
+ ); +} + +export default { + component: Component, + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: '/hello', + query: { + foo: 'bar', + }, + }, + }, + }, +} as Meta; + +export const Default: StoryObj = {};