diff --git a/packages/eui/.prettierrc.js b/packages/eui/.prettierrc.js new file mode 100644 index 00000000000..c88902fac9b --- /dev/null +++ b/packages/eui/.prettierrc.js @@ -0,0 +1,7 @@ +module.exports = { + parser: "typescript", + printWidth: 80, + semi: true, + singleQuote: true, + trailingComma: "es5" +} diff --git a/packages/eui/.storybook/addons/code-snippet/README.md b/packages/eui/.storybook/addons/code-snippet/README.md new file mode 100644 index 00000000000..6a32ada5883 --- /dev/null +++ b/packages/eui/.storybook/addons/code-snippet/README.md @@ -0,0 +1,227 @@ +# Storybook code-snippet addon + +## Description + +> This is an internal EUI Storybook addon which adds code snippets to EUI stories. + +The purpose of this addon is to improve the developer experience by providing code snippets with dynamically updated props based on the story controls. + +This addon is provided as additional story panel next to the available panels for "Controls", "Actions" and "Interactions". + +The basis for the code snippet generation is based on Storybooks [`Source`](https://storybook.js.org/docs/writing-docs/doc-blocks#source) block. The internally used [`jsxDecorator`](https://github.com/storybookjs/storybook/blob/2bff7a1c156bbd42ab381f84b8a55a07694e7e53/code/renderers/react/src/docs/jsxDecorator.tsx) file was copied and then adjusted and extended to fit the specific needs for EUI. The main functionality to generate a jsx string from react elements comes from the [`react-element-to-jsx-string`](https://github.com/algolia/react-element-to-jsx-strin) package. + +## Concept + +The `code-snippet` addon follows the [official guides](https://storybook.js.org/docs/addons/writing-addons) to create a Storybook addon. The only real difference is that this addon is not released separately but simply added and used internally. + +The addon is defined and registered in `manager.ts` this ensures it's available in Storybook. Storybook handles most of the rendered output (e.g. tab list and tab buttons), the only custom content is what is passed via the `render` key on the addon config. This content will be output as child of the addon panel that Storybook renders. + +```ts +// Register a addon +addons.register(ADDON_ID, (api: API) => { + // Register a panel + addons.add(PANEL_ID, { + type: types.PANEL, + title: 'Code Snippet', + match: ({ viewMode }) => viewMode === 'story', + render: Panel, + }); +}); +``` + +The main code snippet generation functionality is done in `jsx_decorator.tsx`. It's used as a decorator for every story in `preview.tsx`. + + +```ts +import { customJsxDecorator } from './addons/code-snippet/decorators/jsx_decorator'; + +const preview: Preview = { + decorators: [ + customJsxDecorator, + ] +} +``` + +This decorator generates the code snippet as a `string` and sends it via Storybooks [Channel events](https://storybook.js.org/docs/addons/addons-api#usechannel) to the custom addon panel which outputs the code string to the panel which updates its state on receiving the event ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/components/panel.tsx#L58)). + +```ts +channel.emit(EVENTS.SNIPPET_RENDERED, { + id, + source: jsx, + args: unmappedArgs, +}); +``` + +![Dimensions](https://github.com/elastic/eui/assets/44670957/9bb087f5-82bd-4b55-8264-5decc0a36cff) + +## Differences to the Storybook `jsxDecorator` + +The main changes/additions to the original `jsxDecorator` from Storybook are to ensure the generator outputs clean and EUI relevant code snippets. + +Additional features added: +- renames Emotion wrappers to the actual component name (whenever we use `css` on a component in a story it will be an Emotion-wrapped component) +- renames stateful wrappers that start with the wording Stateful (requires us to follow an agreed naming convention) +- removes obsolete fragment wrappers (but keeps required ones) +- removes story specific wrappers (e.g. layout or styling) +- keep related wrappers (e.g. parent & subcomponent or related by name) +- resolves any other unexpected wrapper we might add to structure complex stories +- renames internal component names that start with _underscore (e.g. `<_Component>` is changed to ``) +- ensures `css` attribute is output properly and not as resolved Emotion object +- ensures boolean props are output in a meaningful way (generally as shorthand but it keeps specifically defined `false` values where `false` has a meaning) +- ensures project specific formatting via `prettier` +- supports adding manual code snippets + + +## How it works + +The generation happens in different stages: + +1. `pre-conversion`: determine what react element should be passed to react-element-to-jsx-string and with which options +2. `conversion`: pass react elements to react-element-to-jsx-string +3. `post-conversion`: do additional replacements on the returned string +4. `formatting`: format the result using prettier + +### 1. Pre-conversion + +Before passing a React element to the `react-element-to-jsx-string` package functionality, we first determine: + +1. Should a story be skipped? ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts#L196)) + - a story may be skipped: + - by using `parameters.codeSnippet.skip` ([example](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/src/components/drag_and_drop/drag_drop_context.stories.tsx#L31)) + - by returning an anonymous function without `args` from story `render` +2. Is a manual code snippet provided? ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx#L100)) ([example](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/src/components/text_diff/text_diff.stories.tsx#L24)) + +3. What React element should be used? (only a single React element can be passed to `react-element-to-jsx-string`) ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx#L146)) + + 1. Check if the outer element should be resolved due to manual flagging via `parameters.codeSnippet.resolveChildren`. The children would be used instead. ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L214)). + 2. We check the story react element for some base conditions ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L226)) for which we return the current element. Otherwise we move to the elements `children`: + - Is the element the story component? + - Is the element the stories parent? (We usually want to show Parent & subcomponents together) + - Is the element a subcomponent? + - Is the element a stateful wrapper? (To add interactivity we usually wrap stories in stateful wrappers that are not relevant for the snippet) + - Is the element a React.Fragment? (where obsolete we would want to remove wrapping fragments) + 3. If the element is an array we resolve for the children ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L235)). + +4. Once a single React element is determine the node and all its props (+ children) are recursively checked and resolved to ensure expected output: + + - skip any obsolete React.Fragments (returning children instead) ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L330)) + - ensure Emotion `css` is resolved and reversed as Emotion transforms the input syntax to an Emotion style object. (e.g. resolve `css={({ euiTheme }) => ({})}`) ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L357)) + - ensure euiTheme tokens are output as variables (e.g. `someProp=euiTheme.colors.lightShade`) - This step adds the variable in special markes that are removed later. This is to prevent `react-element-to-jsx-string` from assuming a type and formatting unexpectedly ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L400)) + - ensure `style` attribute is applied ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L468)) + - resolve arrays (this outputs e.g. `someProp={[, ]}` instead of `[]`) ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L495)) + - resolve objects (e.g. ensures output like `{ text: 'foobar' color: 'green' }`) ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L504)) + - resolve class instances used as values to functions ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L518)) + - [_todo_] resolve render functions + +### 2. Conversion from React element to string + +Once the React element is properly checked and resolved according to expected output needs, it can be passed to the functionality from `react-element-to-jsx-string` which will generate a jsx string based on the React element. ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L249)) + +```tsx +// example output + + Flex item + +``` + +### 3. Post-conversion cleanup + +The returned string of the conversion is then cleaned to ensure: + +- rename internal Components (e.g. `<_Component>` to ``) ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L256)) +- rename necessary React.Fragment to shorthand (e.g. `` to `<>`) [code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L277) +- ensure boolean value shorthand by manually filtering out values of `true` ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L290)) + - this is manually handled and not by `react-element-to-jsx-string` because we want to keep some occurrences of `false` values when they have meaning (e.g. ``) +- replace variable markers that were added in "1: Pre-conversion" ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L301)) +- remove obsolete function naming ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L314)) + + +### 4. Final Formatting + +To ensure the formatting is correct after adjusting the string returned from `react-element-to-jsx-string` and to align it with the EUI projects formatting rules, we run `prettier` on the string as a final step. ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts#L207)) + +## Options + +Currently there are a few addon specific parameter options added that can be used under the key `codeSnippet` in the parameters config key. + +```ts +// meta or story config +const meta = { + title: 'Navigation/EuiButton', + component: EuiButton, + parameters: { + codeSnippet: { + // Optional way to override selected story args with manual values. + // This is useful when the story arg would render unreadable or not useful output. + // You can use interpolation markers #{} to ensure the value is output as is, this + // is useful for e.g. functions to prevent them from being called. + args: { + propA: 'new value for propA', + propB: "#{someFunctionCall('inputValue')}" // returns: propB={someFunctionCall('inputValue')} + }, + // will skip code snippet generation for the component or story + // @default false + skip: true, + // Useful for complex story composition wrappers (using the story component as + // nested child and not as direct return for `render`). + // It will skip the outer story wrapper and return the code snippet for its children + // instead. See the story for `EuiHeader/Multiple Fixed Headers` as an example. + // @default false + resolveChildren: true, + // Useful when the story outputs additional content that should not be included in the + // snippet and instead only the actual story component should be output as snippet. + // @default false + resolveStoryElementOnly: true, + // The jsx renderer removes the story components default props. In case that they should + // be added to a specific code snippet it can be enabled by setting this option to `false`. + // @default true + removeDefaultProps: false, + } + } +} +``` + +## Additional functionality + +### Manual code snippets + +Instead of using the automatic code snippet generation, you can also provide a manual snippet which will be output instead. This is especially useful when the story content is not actually a component (e.g. a hook). You can see an example of this for the story of `useEuiTextDiff`. + +To add the story args to the code snippet, add the defined marker `{{STORY_ARGS}}` to the snippet string. If the args should be spread on the root component use `{{...STORY_ARGS}}` instead. +These markers will be replaced automatically with the current story args. It's important to note that the `children` prop is removed and it should be manually added to the snippet input instead. Additionally the story `args` are filtered to remove the default props. This can be changed via the `removeDefaultProps` option. + + +```ts +// {{STORY_ARGS}} +parameters: { + codeSnippet: { + snippet: ` + const [rendered, textDiffObject] = useTextDiff({{STORY_ARGS}}) + `, + }, +} + +// {{...STORY_ARGS}} +parameters: { + codeSnippet: { + snippet: ` + + `, + }, +} +``` + +🚧 More will follow soon 🚧 + + +## Limitations + +1. Currently it's not yet supported to resolve `"render functions"` (either used as children or as any prop value). Components that make use of render functions (specifically for children) are currently (manually) skipped via `parameters.codeSnippet.skip: true` until support is added. + +2. Currently the addon uses Storybooks `SyntaxHighlighter` component to output the code snippets. This works generally well but seems to have trouble properly detecting and styling code parts for large snippets. This results in some partially uncolored snippets. Using EUI components does currently not work just out of the box as there seem to be issues with applying Emotion correctly. \ No newline at end of file diff --git a/packages/eui/.storybook/addons/code-snippet/components/panel.tsx b/packages/eui/.storybook/addons/code-snippet/components/panel.tsx new file mode 100644 index 00000000000..21283970dbd --- /dev/null +++ b/packages/eui/.storybook/addons/code-snippet/components/panel.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useEffect, FunctionComponent } from 'react'; +import { + useAddonState, + useChannel, + useStorybookApi, +} from '@storybook/manager-api'; +import { AddonPanel, SyntaxHighlighter } from '@storybook/components'; +import { styled } from '@storybook/theming'; +import { STORY_RENDERED } from '@storybook/core-events'; + +import { ADDON_ID, ADDON_PARAMETER_KEY, EVENTS } from '../constants'; +import { addHiddenStyle, clearHiddenStyle } from '../utils/addon_visibility'; + +const addonTabStyles = (selector: string) => ` + ${selector} { + display: none; + } + `; + +interface PanelProps { + active?: boolean; +} + +export const Panel: FunctionComponent = ({ active, ...rest }) => { + const [addonState, setAddonState] = useAddonState(ADDON_ID, { + code: '', + isLoaded: false, + isSkipped: true, + }); + const { code, isLoaded, isSkipped } = addonState; + const storybookApi = useStorybookApi(); + + useEffect(() => { + const addonTabId = `#tabbutton-${ADDON_ID.split('/').join('-')}-panel`; + + /** + * we manually hide the addon tab element initially and show it only if it's not skipped. + * This uses style element injection over classes as we don't have access to the actual elements. + * We would need to wait for the elements to be rendered by Storybook to get them which is less + * consistent as controlling the styles. + * reference: https://storybook.js.org/docs/addons/writing-addons#style-the-addon + */ + if (isSkipped) { + addHiddenStyle(ADDON_ID, addonTabStyles(addonTabId)); + } else { + clearHiddenStyle(ADDON_ID); + } + }, [isSkipped]); + + const emit = useChannel({ + [EVENTS.SNIPPET_RENDERED]: (args) => { + setAddonState((prevState) => ({ ...prevState, code: args.source ?? '' })); + }, + [STORY_RENDERED]: (id: string) => { + const parameters = storybookApi.getParameters(id); + const isStorySkipped = parameters?.[ADDON_PARAMETER_KEY]?.skip ?? false; + + setAddonState((prevState) => ({ + ...prevState, + isLoaded: true, + isSkipped: isStorySkipped, + })); + }, + }); + + useEffect(() => { + if (isSkipped || !isLoaded || !active) return; + + // emit OPENED event + emit(EVENTS.SNIPPET_PANEL_OPENED); + + return () => { + // emit CLOSED event + emit(EVENTS.SNIPPET_PANEL_CLOSED); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSkipped, isLoaded, active]); + + if (isSkipped) return null; + + const emptyState = No code snippet available; + const loadingState = Loading...; + + return ( + + {code ? ( + + {code} + + ) : ( + {isLoaded ? emptyState : loadingState} + )} + + ); +}; + +const Container = styled.div(({ theme }) => ({ + display: 'flex', + justifyContent: 'flex-start', + margin: 0, + padding: theme.layoutMargin, +})); diff --git a/packages/eui/.storybook/addons/code-snippet/constants.ts b/packages/eui/.storybook/addons/code-snippet/constants.ts new file mode 100644 index 00000000000..09a917a5489 --- /dev/null +++ b/packages/eui/.storybook/addons/code-snippet/constants.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * Addon specific constants + */ +export const ADDON_ID = 'storybook/code-snippet'; +export const PANEL_ID = `${ADDON_ID}/panel`; + +export const EVENTS = { + SNIPPET_RENDERED: `${ADDON_ID}/snippet-rendered`, + SNIPPET_PANEL_OPENED: `${ADDON_ID}/snippet-panel-opened`, + SNIPPET_PANEL_CLOSED: `${ADDON_ID}/snippet-panel-closed`, +}; + +export const ADDON_PARAMETER_KEY = 'codeSnippet'; +export const QUERY_PARAMS = { + SHOW_SNIPPET: 'showSnippet', +}; + +export const STORY_ARGS_MARKER = '{{STORY_ARGS}}'; +export const SPREAD_STORY_ARGS_MARKER = '{{...STORY_ARGS}}'; + +/** + * JSX snippet generation constants + */ +export const EMOTION_TYPE_KEY = '__EMOTION_TYPE_PLEASE_DO_NOT_USE__'; +export const EMOTION_LABEL_KEY = '__EMOTION_LABEL_PLEASE_DO_NOT_USE__'; + +// excluded props to not be shown in the code snippet +export const EXCLUDED_PROPS = new Set([ + EMOTION_TYPE_KEY, + EMOTION_LABEL_KEY, + 'key', +]); +// props with 'false' value that should not be removed but shown in the code snippet +export const PRESERVED_FALSE_VALUE_PROPS = new Set(['grow']); diff --git a/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx b/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx new file mode 100644 index 00000000000..1d332279a20 --- /dev/null +++ b/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* DISCLAIMER: This file was originally copied from Storybook jsxDecorator and then adjusted for more specific needs. +https://github.com/storybookjs/storybook/blob/2bff7a1c156bbd42ab381f84b8a55a07694e7e53/code/renderers/react/src/docs/jsxDecorator.tsx#L224 */ + +import type { ReactRenderer } from '@storybook/react'; +import type { + StoryContext, + ArgsStoryFn, + PartialStoryFn, +} from '@storybook/types'; +import { addons, useEffect, useCallback } from '@storybook/preview-api'; +import { logger } from '@storybook/client-logger'; + +import { useEuiTheme } from '../../../../src/services'; +import { + EVENTS, + SPREAD_STORY_ARGS_MARKER, + STORY_ARGS_MARKER, +} from '../constants'; + +import { + getDefaultPropsfromDocgenInfo, + getFormattedCode, + skipJsxRender, +} from './utils'; +import { JSXOptions, renderJsx } from './render_jsx'; + +const defaultJsxOptions = { + skip: 0, + showFunctions: false, + enableBeautify: true, + showDefaultProps: false, +}; + +/** + * main jsx decorator function that transforms the story react element to a jsx string + * - checks if a manual code snippet is provided or code snippet should be generated + * - if a snippet is available it replaces args and returns the snippet + * - if a snippet should be generated, it: + * - determines what should be used as story element (e.g. skip wrappers and resolve elements) + * - adds displayName overwrites (e.g. for Emotion or Stateful wrappers) + * - passes story react element to reactElementToJSXString + * - filters the returned string from reactElementToJSXString for expected formatting + * - runs prettier on the output for expected formatting + */ +export const customJsxDecorator = ( + storyFn: PartialStoryFn, + context: StoryContext +) => { + const story = storyFn(); + const channel = addons.getChannel(); + const codeSnippet: string = context?.parameters?.codeSnippet?.snippet; + const skip = skipJsxRender(context) && !codeSnippet; + + let jsx = ''; + + // using Storybook Channel events to send the code string + // to the addon panel to output + // uses Storybook useCallback hook not React one + // eslint-disable-next-line react-hooks/rules-of-hooks + const emitChannel = useCallback( + (jsx: string, skip: boolean, shouldSkip = false) => { + const { id, unmappedArgs } = context; + if (skip || shouldSkip) { + channel.emit(EVENTS.SNIPPET_RENDERED, { + id, + source: '', + args: unmappedArgs, + }); + } else { + channel.emit(EVENTS.SNIPPET_RENDERED, { + id, + source: jsx, + args: unmappedArgs, + }); + } + }, + [context, channel] + ); + + // disabling this rule as this is how Storybook handles it + // they export their own hook wrappers and have the eslint rule disabled completely + // https://github.com/storybookjs/storybook/blob/2bff7a1c156bbd42ab381f84b8a55a07694e7e53/code/renderers/react/src/docs/jsxDecorator.tsx#L233 + // https://github.com/storybookjs/storybook/blob/4c1d585ca07db5097f01a84bc6a4092ada33629b/code/lib/preview-api/src/modules/addons/hooks.ts#L474 + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (jsx) { + emitChannel(jsx, skip); + } + if (skip) { + emitChannel(jsx, skip, true); + } + }, [jsx, skip, emitChannel]); + + // We only need to render JSX if the source block is actually going to + // consume it. Otherwise it's just slowing us down. + if (skip) { + return story; + } + + // use manually provided code snippet and replace args if available + if (codeSnippet) { + const args: typeof context.args = { ...context.args }; + const defaultProps = getDefaultPropsfromDocgenInfo(story, context); + + for (const key of Object.keys(context.args)) { + // checks story args for: + // - remove if no value + // - remove if `chidlren` + // - remove if arg is a default prop + if ( + !context.args[key] || + key === 'children' || + defaultProps?.includes(key) + ) { + delete args[key]; + } + } + + // add the story args/props to the manual code snippet + // by replacing the {{STORY_ARGS}} || {{...STORY_ARGS}} marker + let outputArgs = JSON.stringify(args); + const shouldSpread = codeSnippet.includes(SPREAD_STORY_ARGS_MARKER); + const argsMarker = shouldSpread + ? SPREAD_STORY_ARGS_MARKER + : STORY_ARGS_MARKER; + + // if the spread marker is used, resolve the props object to the first level values + // e.g. { foo: 'bar' } => foo="bar" + // { a: { b: 'B' } } => a={{ b: 'B' }} + if (shouldSpread) { + outputArgs = Object.entries(args) + .map(([key, value]) => { + const formattedValue = + typeof value === 'function' ? `() => {}` : JSON.stringify(value); + const formattedOutput = + typeof value === 'string' + ? `${[key, formattedValue].join('=')}` + : `${[key, formattedValue].join('={')}}`; + + return formattedOutput; + }) + .join(' '); + } + + const code = codeSnippet.replace(argsMarker, outputArgs); + + getFormattedCode(code) + .then((res: string) => { + jsx = res.replace(';\n', '\n'); + }) + .catch((error: Error): void => { + logger.error( + 'An error occurred and no formatted code was provided. Falling back to pre-formatted code.', + error + ); + jsx = code; + }); + + // return story from decorator to be rendered + return story; + } + + const options = { + ...defaultJsxOptions, + ...(context?.parameters.jsx || {}), + } as Required; + + // Exclude decorators from source code snippet by default + const storyJsx = context?.parameters.docs?.source?.excludeDecorators + ? (context.originalStoryFn as ArgsStoryFn)( + context.args, + context + ) + : story; + + // NOTE: euiTheme is defined on global level to prevent errors on conditionally rendered hooks + // when stories have conditionally rendered components (via mapping) that rely on euiTheme + // eslint-disable-next-line react-hooks/rules-of-hooks + const euiTheme = useEuiTheme(); + + // generate JSX from the story + const renderedJsx = renderJsx(storyJsx, context, options, euiTheme); + if (renderedJsx) { + getFormattedCode(renderedJsx) + .then((res: string) => { + // prettier adds a semicolon due to semi: true but semi:false adds one at the beginning ¯\_(ツ)_/¯ + jsx = res.replace(';\n', '\n'); + }) + .catch((error: Error): void => { + logger.error( + 'An error occurred and no formatted code was provided. Falling back to pre-formatted code.', + error + ); + jsx = renderedJsx; + }); + } + + // return story from decorator to be rendered + return story; +}; diff --git a/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx new file mode 100644 index 00000000000..6a9c8225792 --- /dev/null +++ b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx @@ -0,0 +1,555 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* DISCLAIMER: Parts of this file were originally copied from Storybook jsxDecorator and then adjusted for more specific needs. +https://github.com/storybookjs/storybook/blob/2bff7a1c156bbd42ab381f84b8a55a07694e7e53/code/renderers/react/src/docs/jsxDecorator.tsx#L224 */ + +import type { ReactElement, ReactNode } from 'react'; +import React, { isValidElement } from 'react'; +import type { Options } from 'react-element-to-jsx-string'; +import reactElementToJSXString from 'react-element-to-jsx-string'; +import { camelCase, isEmpty } from 'lodash'; +import type { ReactRenderer, Args } from '@storybook/react'; +import type { StoryContext } from '@storybook/types'; +import { getDocgenSection } from '@storybook/docs-tools'; +import { logger } from '@storybook/client-logger'; + +import { UseEuiTheme } from '../../../../src/services'; +import { + ADDON_PARAMETER_KEY, + EXCLUDED_PROPS, + PRESERVED_FALSE_VALUE_PROPS, +} from '../constants'; +import { + getStoryComponentDisplayName, + getEmotionComponentDisplayName, + getReactSymbolName, + isForwardRef, + isFragment, + isMemo, + isEmotionComponent, + isStatefulComponent, + isStoryComponent, + isStoryParent, + isSubcomponent, + getStoryComponent, + getResolvedStoryChild, + getDefaultPropsfromDocgenInfo, + isStoryWrapper, +} from './utils'; + +export type JSXOptions = Options & { + /** Whether to show the function in the jsx tab */ + showFunctions?: boolean; + /** Whether to format HTML or Vue markup */ + enableBeautify?: boolean; + /** Override the display name used for a component */ + displayName?: string | Options['displayName']; +}; + +/** + * Apply the users parameters, apply filtering and render the jsx for a story + */ +export const renderJsx = ( + code: React.ReactElement, + context: StoryContext, + options?: JSXOptions, + euiTheme?: UseEuiTheme +): string | null => { + if (typeof code === 'undefined') { + logger.warn('Too many skip or undefined component'); + return null; + } + + let displayNameDefaults; + + // NOTE: This code is from the original Storybook jsxDecorator + // and was enhanced to add additional checks for EUI Emotion + // component usages to ensure resolving the proper displayName + if (typeof options?.displayName === 'string') { + displayNameDefaults = { + showFunctions: true, + displayName: () => options.displayName, + }; + } else { + displayNameDefaults = { + // To get exotic component names resolving properly + displayName: (el: any): string => { + if (el.type.displayName) { + let displayName = el.type.displayName; + // rename Emotion elements + if (isEmotionComponent(el)) { + // NOTE: overwriting el.type.displayName here and returning it + // causes some stale value for Emotion components + displayName = getEmotionComponentDisplayName(el) ?? displayName; + } + return displayName; + } else if (getDocgenSection(el.type, 'displayName')) { + return getDocgenSection(el.type, 'displayName'); + } else if (el.type.render?.displayName) { + return el.type.render.displayName; + } else if ( + typeof el.type === 'symbol' || + (el.type.$$typeof && typeof el.type.$$typeof === 'symbol') + ) { + // check if it's an emotion component and we have a displayName available on it + const displayName = getEmotionComponentDisplayName(el); + return displayName ?? getReactSymbolName(el.type); + } else if (el.type.name && el.type.name !== '_default') { + // rename stateful wrappers + // naming convention: `Stateful{COMPONENT_NAME}` + if (isStatefulComponent(el)) { + const displayName = + getStoryComponentDisplayName(context) ?? + context?.title.split('/').pop() ?? + el.type.name; + el.type.displayName = displayName; + return displayName; + } + return el.type.name; + } else if (typeof el.type === 'function') { + // this happens e.g. when using decorators where the is wrapped + return getStoryComponentDisplayName(context) ?? 'No Display Name'; + } else if (isForwardRef(el.type)) { + return el.type.render.name; + } else if (isMemo(el.type)) { + return el.type.type.name; + } else { + return el.type; + } + }, + }; + } + + // react-element-to-jsx-string options + const opts = { + ...displayNameDefaults, + ...options, + useBooleanShorthandSyntax: false, // disabled in favor of manual filtering + useFragmentShortSyntax: true, + sortProps: true, + // using any type here as component props can have any type + filterProps: (value: any, key: string) => { + if ( + EXCLUDED_PROPS.has(key) || + value == null || + value === '' || + // empty objects/arrays that we set up for easier testing + (typeof value === 'object' && isEmpty(value)) + ) { + return false; + } + + // manually filter props with `false` values as this allows us to preserve + // `false` values where required e.g. grow={false} + if (value === false && !PRESERVED_FALSE_VALUE_PROPS.has(key)) { + return false; + } + + return true; + }, + }; + + const result = React.Children.map(code, (c) => { + // @ts-expect-error FIXME: workaround react-element-to-jsx-string + const child = typeof c === 'number' ? (c.toString() as string) : c; + const toJSXString: typeof reactElementToJSXString = + typeof reactElementToJSXString === 'function' + ? reactElementToJSXString + : // @ts-expect-error (Converted from ts-ignore) + reactElementToJSXString.default; + + const shouldResolveStoryElementOnly = + context?.parameters?.codeSnippet?.resolveStoryElementOnly === true; + + let node = child; + + if (typeof child !== 'string') { + // manual flag to remove an outer story wrapper and resolve its children instead + // useful when complex custom stories are build where the actual story component is + // not the outer component but part of a composition within another wrapper + if (shouldResolveStoryElementOnly) { + const storyNode = getStoryComponent(child, context); + + if (storyNode) { + node = storyNode; + } + // manual flag to remove an outer story wrapper and resolve its children instead + // useful when complex custom stories are build where the actual story component is + // not the outer component but part of a composition within another wrapper + } else if (isStoryWrapper(child, context)) { + node = getResolvedStoryChild(child, context); + } else { + // removes outer wrapper components but leaves: + // - stateful wrappers (kept and renamed later via displayName) + // - fragments (needed for reactElementToJSXString to work initially but skipped later) + // - parent and subcomponents components (subcomponents likely require their parent to display) + // - default fallback: if the children are an array we resolve for the story component within the wrapper + // (this prevents errors with reactElementToJSXString which can't handle array children) + node = + isStoryComponent(child, context) || + isStoryParent(child, context) || + isSubcomponent(child, context) || + isStatefulComponent(child) || + isFragment(child) + ? child + : child.props.children; + + if (Array.isArray(node)) { + const children = node as ReactElement[]; + + for (const child of children) { + const displayName = getEmotionComponentDisplayName(child); + if (displayName === context?.component?.displayName) { + node = child; + } + } + } + } + } + + // convert node to jsx string + let string: string = toJSXString( + _simplifyNodeForStringify({ + node, + context, + euiTheme, + argsOverride: context?.parameters[ADDON_PARAMETER_KEY]?.args, + }), + opts as Options + ); + + /** Start of filtering the generated jsx string */ + // renaming internal components + if (string.indexOf('<_') > -1) { + const regexStart = new RegExp(/<_/g); + const regexEnd = new RegExp(/<\/_/g); + const matchesStart = string.match(regexStart); + const matchesEnd = string.match(regexEnd); + + // renaming internal component opening tags that start with _underscore + if (matchesStart) { + matchesStart.forEach((match) => { + string = string.replace(match, match.replace(regexStart, '<')); + }); + } + + // renaming internal component closing tags that start with _underscore + if (matchesEnd) { + matchesEnd.forEach((match) => { + string = string.replace(match, match.replace(regexEnd, ' (react-element-to-jsx-string outputs ) + if (string.indexOf('React.Fragment') > -1) { + const regex = new RegExp(/React.Fragment/g); + const matches = string.match(regex); + if (matches) { + matches.forEach((match) => { + string = string.replace(match, match.replace(regex, '')); + }); + } + } + + // manually filter out ={true} to achieve shorthand syntax for boolean values as we're + // not using the global option `useBooleanShorthandSyntax` to have more control + if (string.indexOf('={true}') > -1) { + const regex = new RegExp(/={true}/g); + const matches = string.match(regex); + if (matches) { + matches.forEach((match) => { + string = string.replace(match, match.replace(regex, '')); + }); + } + } + + // ensure tokens are output properly by removing added variable markers + if (string.indexOf('{{') > -1) { + const regex = new RegExp(/'{{|"{{|}}'|}}"/g); + const matches = string.match(regex); + if (matches) { + matches.forEach((match) => { + string = string.replace(match, ''); + }); + } + } + + // remove arg value override markers #{} and replace them with their content + // example: + // in: selected: "#{moment('Tue Mar 19 2024 18:54:51 GMT+0100')}" + // out: selected={moment('Tue Mar 19 2024 18:54:51 GMT+0100')} + if (string.indexOf('#{') > -1) { + const variableRegex = new RegExp(/("|')#{.*?}("|')/g); + const variableContentRegex = new RegExp(/(?<="#{).*?(?=}")/g); + const variableMatch = string.match(variableRegex); + + if (variableMatch) { + variableMatch.forEach((match) => { + const content = match.match(variableContentRegex)!; + string = string.replace(match, `{${content[0]}}`); + }); + } + } + + return string; + }).join('\n'); + + return result.replace(/function\s+noRefCheck\(\)\s*\{\}/g, '() => {}'); +}; + +/** + * recursively resolves ReactElement nodes: + * - removes obsolete outer and inner wrappers + * - resolves Emotion css prop back to its input state + * - resolves arrays and objects to single elements + */ +const _simplifyNodeForStringify = ({ + node, + context, + euiTheme, + argsOverride, +}: { + node: ReactNode; + context: StoryContext; + euiTheme?: UseEuiTheme; + argsOverride?: Args; +}): ReactNode => { + if (isValidElement(node)) { + let updatedNode = node; + const shouldRemoveDefaultProps = + context?.parameters?.codeSnippet?.removeDefaultProps !== false; + // default props for the current node + const defaultProps = shouldRemoveDefaultProps + ? getDefaultPropsfromDocgenInfo(node, context) + : []; + + // remove outer fragments + if (isFragment(updatedNode) && !Array.isArray(updatedNode.props.children)) { + updatedNode = node.props.children; + } + + // remove inner fragments + if ( + updatedNode.props?.children && + !Array.isArray(updatedNode.props.children) && + isFragment(updatedNode.props.children) + ) { + updatedNode = { + ...updatedNode, + props: { + ...updatedNode.props, + children: updatedNode.props.children.props.children, + }, + }; + } + + // check and resolve props recursively + // NOTE: we're using any types here as component props can have any type + const updatedProps = updatedNode.props + ? Object.keys(updatedNode.props).reduce<{ + [key: string]: any; + }>((acc, cur) => { + // filter out default props + if (defaultProps?.includes(cur)) { + return acc; + } + // check if the story has manual prop overrides that should be + // used instead of the original value + if (argsOverride?.[cur]) { + acc[cur] = argsOverride?.[cur]; + + return acc; + } + // resolve css Emotion object back to css prop + // ensures tokens are output as is and not its resolved value + if (cur === 'css') { + // example: + // css={({ euiTheme }) => ({})} + if ( + typeof updatedNode.props[cur] === 'function' && + euiTheme != null + ) { + const styles = updatedNode.props[cur]?.(euiTheme); + const fnString = String(updatedNode.props[cur]); + + /** + * get the style definitions from the function body + * example: + * "return { + * backgroundColor: euiTheme.colors.emptyShade, + * minBlockSize: '150vh' + * };" + */ + const regex = /return([\S\s]*?)\{([\S\s]*?)(};?)$/gm; + const matches = fnString.match(regex); + + if (matches) { + const rules = matches[0] + .replace('return {\n', '') + .replace('return{', '') + .replace(/(\/\/)([\S\s]*?)$/g, '') + .replaceAll(' ', '') + .replaceAll('\n', '') + .replace(/}}/g, '') + .split(','); + + // transform string to styles object + const cssStyles = rules.reduce((acc, cur) => { + const [property, value] = cur.split(':'); + const isToken = value.includes('euiTheme'); + const cleanedValue = isToken + ? value.replace(/.+?(?=euiTheme)/g, '') + : value.replaceAll("'", '').replaceAll('"', ''); + + // if the value is a token, we pass the token name with variable + // markers which are removed in a later step. + // this way the value won't be coerced to another type when + // transforming the element to a jsx string + acc[property] = isToken + ? `{{${cleanedValue}}}` + : cleanedValue; + + return acc; + }, {} as Record); + + acc[cur] = { + ...acc.style, + ...cssStyles, + }; + + return acc; + } + + acc[cur] = { + ...acc.style, + ...styles, + }; + } + + /** resolves Emotion css object styles string to a styles object + * example: + * styles: "background-color:rgba(0, 119, 204, 0.1);:first-child{min-height:5em;};label:flexItem;" + * returns: + * { + * "backgroundColor": "rgba(0, 119, 204, 0.1)", + * ":first-child": { + * "min-height": "5em" + * } + * } + */ + if ( + typeof updatedNode.props[cur] === 'object' && + !Array.isArray(cur) + ) { + const styles: string[] = updatedNode.props[cur].styles + .replace(';};', '};') + .split(';'); + + const styleRules = styles.reduce((acc, cur) => { + if (cur && !cur.startsWith(':') && !cur.startsWith('label')) { + const [property, value] = cur.split(':'); + const propertyName = camelCase(property); + + acc[propertyName] = value; + } else if (cur.startsWith(':')) { + const string = cur.replace('{', ';').replace('}', ''); + const [property, propertyValue] = string.split(';'); + const [key, value] = propertyValue.split(':'); + + acc[property] = { + [key]: value, + }; + } + + return acc; + }, {} as Record); + + acc[cur] = { + ...acc.style, + ...styleRules, + }; + } + + return acc; + } + + if (cur === 'style') { + return (acc[cur] = { + // prevent resolving style attribute + style: { + ...acc[cur], + ...updatedNode.props[cur], + }, + }); + } + + acc[cur] = _simplifyNodeForStringify({ + node: updatedNode.props[cur], + context, + }); + + return acc; + }, {} as Record) + : {}; + + return { + ...updatedNode, + props: updatedProps, + // Recursively remove "_owner" property from elements to avoid crash on docs page when + // passing components as an array prop (#17482) + // Note: It may be better to use this function only in development environment. + // @ts-expect-error (this is an internal or removed api) + _owner: null, + }; + } + // recursively resolve array or object nodes (e.g. props) + if (Array.isArray(node)) { + const children = node.map((child) => + _simplifyNodeForStringify({ node: child, context, euiTheme }) + ); + return children.flat(); + } + + // e.g. props of object shape + // props = { text: 'foobar' color: 'green' } + if (node && !Array.isArray(node) && typeof node === 'object') { + const updatedChildren: Record = { + ...node, + }; + let objectValue: ReactElement | undefined; + const childrenKeys = Object.keys(updatedChildren); + const childrenValues = Object.values(updatedChildren); + + for (const [i, n] of childrenValues.entries()) { + const hasConstructor = + updatedChildren.hasOwnProperty('_constructor-name_'); + + // resolves a prop value that is a class method to a function + // e.g. query={Query.parse('')} + if (hasConstructor) { + objectValue = (() => {}) as unknown as ReactElement; + break; + } else { + updatedChildren[childrenKeys[i]] = _simplifyNodeForStringify({ + node: n, + context, + }); + } + } + + return typeof objectValue === 'function' + ? (objectValue as ReactNode) + : (updatedChildren as ReactNode); + } + + // TODO: handle the case when a prop is a render function + + return node; +}; diff --git a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts new file mode 100644 index 00000000000..c4bc080ddba --- /dev/null +++ b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts @@ -0,0 +1,424 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* DISCLAIMER: Parts of this file were copied from Storybook jsxDecorator and adjusted for more specific needs. +https://github.com/storybookjs/storybook/blob/2bff7a1c156bbd42ab381f84b8a55a07694e7e53/code/renderers/react/src/docs/jsxDecorator.tsx#L224 */ + +import { + ReactElement, + FunctionComponent, + ComponentType, + ExoticComponent, + isValidElement, + Component, +} from 'react'; +import { Args, StoryContext } from '@storybook/react'; +import * as prettier from 'prettier'; +import tsParser from 'prettier/parser-typescript'; + +// @ts-ignore - config import +import basePrettierConfig from '../../../../.prettierrc'; +import { + ADDON_PARAMETER_KEY, + EMOTION_LABEL_KEY, + EMOTION_TYPE_KEY, +} from '../constants'; + +export const toPascalCase = (str: string) => + str.charAt(0).toUpperCase() + str.slice(1); + +/* Helpers for React specific checks */ +const isReactElement = (el: any): el is ReactElement => el.type !== undefined; +const isExoticComponent = (el: any): el is ExoticComponent => + el.$$typeof !== undefined; + +export const isMemo = (component: ExoticComponent) => + component.$$typeof === Symbol.for('react.memo'); +export const isForwardRef = (component: ReactElement | ExoticComponent) => { + // use type guards to ensure keys are available + return isReactElement(component) && isExoticComponent(component.type) + ? component.type?.$$typeof === Symbol.for('react.forward_ref') + : isExoticComponent(component) + ? component.$$typeof === Symbol.for('react.forward_ref') + : false; +}; +export const isFragment = (component: ReactElement | ExoticComponent) => { + // use type guards to ensure keys are available + return isReactElement(component) + ? component.type?.toString().includes('fragment') + : isExoticComponent(component) + ? component.$$typeof?.toString().includes('fragment') + : false; +}; + +/* Helpers */ +// returns the displayName and handles typing as +// otherwise `type` would not be typed +export const getElementDisplayName = ( + node: ReactElement +): string | undefined => { + let displayName; + const isClassComponent = node instanceof Component; + + if (isClassComponent) { + displayName = node.constructor.name; + } else if (typeof node.type === 'function' || typeof node.type === 'object') { + const component = node.type as FunctionComponent; + displayName = component.displayName ?? component.name ?? undefined; + } + + return displayName; +}; + +// returns the displayName after resolving Emotion wrappers +export const getEmotionComponentDisplayName = ( + node: ReactElement +): string | undefined => { + const displayName = getElementDisplayName(node); + + if ( + (typeof displayName === 'string' && displayName.startsWith('Emotion')) || + node.props?.[EMOTION_TYPE_KEY] != null + ) { + const { + [EMOTION_TYPE_KEY]: emotionTypeData, + [EMOTION_LABEL_KEY]: emotionLabelData, + } = node.props; + const isForwardRefComponent = isForwardRef(emotionTypeData); + + const emotionTypeName = + emotionTypeData.__docgenInfo?.displayName ?? + emotionTypeData.render?.displayName; + // we need to rely here on the reference Emotion stores to know what component this actually is + const replacementName: string | undefined = isForwardRefComponent + ? emotionTypeName + : typeof emotionTypeData === 'string' + ? emotionTypeData ?? emotionLabelData + : emotionTypeName ?? emotionLabelData?.displayName; + + // remove internal component underscore markings + return replacementName ?? displayName; + } + + return displayName ? displayName.replace('_', '') : displayName; +}; + +export const getStoryComponentDisplayName = ( + context: StoryContext | undefined +): string | undefined => { + if (!context) return; + + const component = context.component as ComponentType & { + __docgenInfo?: { displayName?: string }; + }; + + return component?.displayName ?? component.__docgenInfo?.displayName; +}; + +/** Determine if a component is an Emotion component based on displayName. + * Emotion components are renamed 'EmotionCssPropInternal' + */ +export const isEmotionComponent = (node: ReactElement): boolean => { + const displayName = getElementDisplayName(node); + const matches = + typeof displayName === 'string' ? displayName.startsWith('Emotion') : false; + + return !!matches; +}; + +/* Story specific checks */ +export const isStoryComponent = ( + node: ReactElement, + context: StoryContext | undefined +): boolean => { + if (!context) return false; + + const isClassComponent = node instanceof Component; + const displayName = isClassComponent + ? node.constructor?.name + : getEmotionComponentDisplayName(node)?.replace(/^_/, ''); + const storyDisplayName = getStoryComponentDisplayName(context); + const isCurrentStory = + displayName && storyDisplayName ? displayName === storyDisplayName : false; + + return isCurrentStory; +}; + +export const isStoryWrapper = (node: ReactElement, context: StoryContext) => { + const displayName = getEmotionComponentDisplayName(node); + const isStoryWrapper = + (typeof displayName === 'string' && displayName.startsWith('Story')) || + context.parameters?.[ADDON_PARAMETER_KEY]?.resolveChildren === true; + + return isStoryWrapper; +}; + +/** + * checks if the outer most component is a parent of the actual story component + */ +export const isStoryParent = ( + node: ReactElement, + context: StoryContext | undefined +): boolean => { + if (!context) return false; + + const displayName = getEmotionComponentDisplayName(node); + + if (!displayName) return false; + + const parentComponents = context.title.split('/'); + parentComponents.shift(); + parentComponents.pop(); + + return parentComponents.includes(displayName); +}; + +export const isSubcomponent = ( + node: ReactElement, + context: StoryContext | undefined +): boolean => { + if (!context) return false; + + const parentComponents = context.title.split('/'); + parentComponents.shift(); + const displayName = getEmotionComponentDisplayName(node); + + if (!displayName || !parentComponents || parentComponents.length === 0) { + return false; + } + + let isSub = false; + + for (const parent of parentComponents) { + if (typeof displayName === 'string' && displayName.includes(parent)) { + isSub = true; + } + } + + return isSub; +}; + +export const isStatefulComponent = (node: ReactElement): boolean => { + const displayName = getEmotionComponentDisplayName(node); + const isStateful = + typeof displayName === 'string' && + (displayName.startsWith('Stateful') || displayName.startsWith('Component')); + + return isStateful; +}; + +/** + * Helper to resolve components that are wrapped. + * It's a bit hacky way to return the children of a story wrapper component + * by calling the component first. This way we ensure to get the right information + * for the story. + * (e.g. when resolving from a story decorator or when + * resolving the children of a wrapper component) + */ +export const getResolvedStoryChild = ( + child: ReactElement, + context: StoryContext +) => { + if (!child.type) return child; + if (typeof child.type !== 'function') return child; + + const isClassComponent = child.type.prototype instanceof Component; + const resolvedChild = isClassComponent + ? child + : (child.type as (args: Args) => ReactElement)(context?.args); + + return resolvedChild; +}; + +/** + * Helper to resolve the current story element from a composition preview, + * e.g. when the story element is a child of a wrapper and only the story + * should be determined without wrappers or siblings. + * (e.g. for singular output or for getting defaultProps or the story element) + * + * It checks the passed story node recursively until it finds the current + * story element and returns it. + */ +export const getStoryComponent = ( + node: ReactElement, + context: StoryContext +): ReactElement | undefined => { + let storyNode: ReactElement | undefined; + + const resolveChildren = (childNode: ReactElement) => { + if (isStoryComponent(childNode, context)) { + storyNode = childNode; + return; + } else if (isValidElement(childNode) && !storyNode) { + // CASE: array of children + if (Array.isArray(childNode.props?.children)) { + const { children } = childNode.props; + + for (const child of children) { + // break out of the loop early if possible + if (child == null || storyNode != null) break; + // skip non-ReactElement children + if (!isValidElement(child)) continue; + + const displayName = getEmotionComponentDisplayName(child); + // Story wrappers need to be resolved first to ensure the right data + const resolvedChild = getResolvedStoryChild(child, context); + const resolvedDisplayName = + getEmotionComponentDisplayName(resolvedChild); + + if ( + (resolvedDisplayName && resolvedDisplayName !== displayName) || + isForwardRef(resolvedChild) + ) { + resolveChildren(resolvedChild); + } else if (typeof resolvedChild.type !== 'string') { + storyNode = resolvedChild; + } + } + } else if ( + // CASE: story wrapper; no children + childNode.props?.children == null && + (isStoryWrapper(childNode, context) || isStatefulComponent(childNode)) + ) { + const displayName = getEmotionComponentDisplayName(childNode); + // Story wrappers need to be resolved first to ensure the right data + const resolvedChild = getResolvedStoryChild(childNode, context); + const resolvedDisplayName = + getEmotionComponentDisplayName(resolvedChild); + + if (resolvedDisplayName !== displayName) { + resolveChildren(resolvedChild); + } + } else if ( + // CASE: single child element + childNode.props?.children && + typeof childNode.props?.children === 'object' && + !Array.isArray(childNode.props?.children) + ) { + const { children } = childNode.props; + + if (isStoryComponent(children, context)) { + storyNode = children; + } + } + } + }; + + resolveChildren(node); + + return storyNode; +}; + +type ReactElementWithDocgenInfo = ReactElement & { + type?: { __docgenInfo?: { props: { [key: string]: any } } }; +}; + +/** + * Helper to retrieve a story components default props. + * Only returns props that have the default prop value; + * any prop value that's changed in the story is not + * considered a default prop in this context + */ +export const getDefaultPropsfromDocgenInfo = ( + component: ReactElementWithDocgenInfo, + context: StoryContext +): Record | undefined => { + if (typeof component.type === 'string') return; + + // determine the story element first + // this is required because the story might be wrapped and + // only the story element has the required docgenInfo + let storyComponent: ReactElementWithDocgenInfo | undefined = + getStoryComponent(component, context); + + if (!storyComponent) return; + + let propsInfo = + isEmotionComponent(storyComponent) && + typeof storyComponent.props?.[EMOTION_TYPE_KEY] !== 'string' + ? storyComponent.props?.[EMOTION_TYPE_KEY]?.__docgenInfo.props + : storyComponent.type?.__docgenInfo?.props; + + const args = context.args; + + const defaultProps = propsInfo + ? Object.keys(propsInfo).filter((key) => { + if (propsInfo[key].defaultValue == null) return false; + + const defaultValue = propsInfo[key].defaultValue.value; + // clean added string (e.g. done by EuiI18n or inline type casting with 'as') + // checks if the string starts with wrapping quotes, then matches tonly the quoted string + // to remove access content (e.g. "'div' as TComponent" => 'div') + const cleanedDefaultValue = defaultValue.startsWith("'") + ? defaultValue.match(/^'.*'/)[0].replace(/^\'/, '').replace(/\'$/, '') + : propsInfo[key].defaultValue?.value; + + // check that the prop value is not the default value + return cleanedDefaultValue === args[key]?.toString(); + }) + : undefined; + + // if available, returns an array of prop names + return defaultProps; +}; + +/** + * NOTE: This code is from the original Storybook jsxDecorator + * Converts a React symbol to a React-like displayName + * + * Symbols come from here + * https://github.com/facebook/react/blob/338dddc089d5865761219f02b5175db85c54c489/packages/react-devtools-shared/src/backend/ReactSymbols.js + * + * E.g. + * Symbol(react.suspense) -> React.Suspense + * Symbol(react.strict_mode) -> React.StrictMode + * Symbol(react.server_context.defaultValue) -> React.ServerContext.DefaultValue + * + * @param {Symbol} elementType - The symbol to convert + * @returns {string | null} A displayName for the Symbol in case elementType is a Symbol; otherwise, null. + */ +export const getReactSymbolName = (elementType: any): string => { + const elementName = elementType.$$typeof || elementType; + const symbolDescription: string = elementName + .toString() + .replace(/^Symbol\((.*)\)$/, '$1'); + + const reactComponentName = symbolDescription + .split('.') + .map((segment) => { + // Split segment by underscore to handle cases like 'strict_mode' separately, and PascalCase them + return segment.split('_').map(toPascalCase).join(''); + }) + .join('.'); + + return reactComponentName; +}; + +export const skipJsxRender = (context: StoryContext): boolean => { + const isArgsStory = context?.parameters.__isArgsStory; + const isManuallySkipped = context?.parameters?.codeSnippet?.skip === true; + + // never render if the user is skipping it manually or if it's not an args story. + return !isArgsStory || isManuallySkipped; +}; + +/** + * runs prettier (ts) on a code string to apply code formatting + */ +export const getFormattedCode = async (code: string) => { + const prettierConfig = { + ...basePrettierConfig, + trailingComma: 'none' as const, + parser: 'typescript', + plugins: [tsParser], + }; + + const formattedCode = await prettier.format(code, prettierConfig); + + return formattedCode; +}; diff --git a/packages/eui/.storybook/addons/code-snippet/event-handlers/query_params.ts b/packages/eui/.storybook/addons/code-snippet/event-handlers/query_params.ts new file mode 100644 index 00000000000..25b4aaa9a8c --- /dev/null +++ b/packages/eui/.storybook/addons/code-snippet/event-handlers/query_params.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { StoryContext } from '@storybook/react'; +import { API } from '@storybook/manager-api'; + +import { ADDON_PARAMETER_KEY, PANEL_ID, QUERY_PARAMS } from '../constants'; + +export const updateQueryParamsOnStoryPrepared = ( + api: API, + context: StoryContext +) => { + const selectedPanel = api.getSelectedPanel(); + const isCodeSnippetSkipped = + context.parameters[ADDON_PARAMETER_KEY]?.skip ?? false; + const showSnippetEnabled = + api.getQueryParam(QUERY_PARAMS.SHOW_SNIPPET) === 'true'; + + if (showSnippetEnabled && !isCodeSnippetSkipped) { + if (selectedPanel !== PANEL_ID) { + _updateSnippetQueryParam(api, 'true'); + api.setSelectedPanel(PANEL_ID); + } + } else { + const resetPanelId = + selectedPanel !== PANEL_ID ? selectedPanel : 'storybook/controls'; // fallback to intial addon panel + + _updateSnippetQueryParam(api, undefined); + api.setSelectedPanel(resetPanelId); + } +}; + +export const updateQueryParamsOnAddonOpened = (api: API) => { + const selectedPanel = api.getSelectedPanel(); + const showSnippetEnabled = + api.getQueryParam(QUERY_PARAMS.SHOW_SNIPPET) === 'true'; + + if (selectedPanel === PANEL_ID && !showSnippetEnabled) { + _updateSnippetQueryParam(api, 'true'); + } +}; + +export const updateQueryParamsOnAddonClosed = (api: API) => { + const showSnippetEnabled = + api.getQueryParam(QUERY_PARAMS.SHOW_SNIPPET) === 'true'; + + if (showSnippetEnabled) { + _updateSnippetQueryParam(api, undefined); + } +}; + +/* Helper function to handle updating code snippet storybook query param */ +const _updateSnippetQueryParam = (api: API, value: 'true' | undefined) => { + const params = { + [QUERY_PARAMS.SHOW_SNIPPET]: value, + }; + // set internal state + api.setQueryParams(params); + // apply state to url + api.applyQueryParams(params); +}; diff --git a/packages/eui/.storybook/addons/code-snippet/event-handlers/setup.ts b/packages/eui/.storybook/addons/code-snippet/event-handlers/setup.ts new file mode 100644 index 00000000000..e46b9aee915 --- /dev/null +++ b/packages/eui/.storybook/addons/code-snippet/event-handlers/setup.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { StoryContext } from '@storybook/react'; +import type { API } from '@storybook/manager-api'; +import { STORY_PREPARED } from '@storybook/core-events'; + +import { EVENTS } from '../constants'; +import { + updateQueryParamsOnAddonClosed, + updateQueryParamsOnAddonOpened, + updateQueryParamsOnStoryPrepared, +} from './query_params'; + +export const setupCodeSnippetEvents = (api: API) => { + // set up channel event listeners + api.on(STORY_PREPARED, (context: StoryContext) => { + updateQueryParamsOnStoryPrepared(api, context); + }); + + api.on(EVENTS.SNIPPET_PANEL_OPENED, () => { + updateQueryParamsOnAddonOpened(api); + }); + + api.on(EVENTS.SNIPPET_PANEL_CLOSED, () => { + updateQueryParamsOnAddonClosed(api); + }); +}; diff --git a/packages/eui/.storybook/addons/code-snippet/utils/addon_visibility.ts b/packages/eui/.storybook/addons/code-snippet/utils/addon_visibility.ts new file mode 100644 index 00000000000..fdbcd19e970 --- /dev/null +++ b/packages/eui/.storybook/addons/code-snippet/utils/addon_visibility.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const clearHiddenStyle = (id: string) => { + const styleElement = document.getElementById(id); + + if (styleElement && styleElement.parentElement) { + styleElement.parentElement.removeChild(styleElement); + } +}; + +export const addHiddenStyle = (id: string, css: string) => { + const existingStyle = document.getElementById(id); + + if (existingStyle) { + if (existingStyle.innerHTML !== css) { + existingStyle.innerHTML = css; + } + } else { + const style = global.document.createElement('style'); + + style.setAttribute('id', id); + style.innerHTML = css; + document.head.appendChild(style); + } +}; diff --git a/packages/eui/.storybook/manager.ts b/packages/eui/.storybook/manager.ts index 7fabcc77163..8409388157c 100644 --- a/packages/eui/.storybook/manager.ts +++ b/packages/eui/.storybook/manager.ts @@ -6,7 +6,11 @@ * Side Public License, v 1. */ -import { addons } from '@storybook/manager-api'; +import { addons, API, types } from '@storybook/manager-api'; + +import { ADDON_ID, PANEL_ID } from './addons/code-snippet/constants'; +import { Panel } from './addons/code-snippet/components/panel'; +import { setupCodeSnippetEvents } from './addons/code-snippet/event-handlers/setup'; // filter out stories based on tags that should not // be shown in the Storybook sidebar menu @@ -21,3 +25,16 @@ addons.setConfig({ }, }, }); + +// Register a addon +addons.register(ADDON_ID, (api: API) => { + setupCodeSnippetEvents(api); + + // Register a panel + addons.add(PANEL_ID, { + type: types.PANEL, + title: 'Code Snippet', + match: ({ viewMode }) => viewMode === 'story', + render: Panel, + }); +}); diff --git a/packages/eui/.storybook/preview.tsx b/packages/eui/.storybook/preview.tsx index 78dd319a6b0..d8be14a6169 100644 --- a/packages/eui/.storybook/preview.tsx +++ b/packages/eui/.storybook/preview.tsx @@ -50,10 +50,13 @@ setEuiDevProviderWarning('error'); */ import type { CommonProps } from '../src/components/common'; + +import { customJsxDecorator } from './addons/code-snippet/decorators/jsx_decorator'; import { hideStorybookControls } from './utils'; const preview: Preview = { decorators: [ + customJsxDecorator, (Story, context) => ( = { isSelected: false, }, }; +enableFunctionToggleControls(meta, ['onClick']); export default meta; type Story = StoryObj; diff --git a/packages/eui/src/components/button/button_group/button_group.stories.tsx b/packages/eui/src/components/button/button_group/button_group.stories.tsx index 144851fd22e..9f9a3fe1bd4 100644 --- a/packages/eui/src/components/button/button_group/button_group.stories.tsx +++ b/packages/eui/src/components/button/button_group/button_group.stories.tsx @@ -67,7 +67,7 @@ const options: EuiButtonGroupOptionProps[] = [ }, ]; -const EuiButtonGroupSingle = (props: any) => { +const StatefulEuiButtonGroupSingle = (props: any) => { const [idSelected, setIdSelected] = useState(props.idSelected); return ( @@ -80,7 +80,7 @@ const EuiButtonGroupSingle = (props: any) => { }; export const SingleSelection: Story = { - render: ({ ...args }) => , + render: ({ ...args }) => , args: { legend: 'EuiButtonGroup - single selection', options, @@ -89,7 +89,7 @@ export const SingleSelection: Story = { }, }; -const EuiButtonGroupMulti = (props: any) => { +const StatefulEuiButtonGroupMulti = (props: any) => { const [idToSelectedMap, setIdToSelectedMap] = useState< Record >(props.idToSelectedMap); @@ -113,7 +113,7 @@ const EuiButtonGroupMulti = (props: any) => { }; export const MultiSelection: Story = { - render: ({ ...args }) => , + render: ({ ...args }) => , args: { legend: 'EuiButtonGroup - multiple selections', options, @@ -123,7 +123,7 @@ export const MultiSelection: Story = { }; export const WithTooltips: Story = { - render: ({ ...args }) => , + render: ({ ...args }) => , args: { legend: 'EuiButtonGroup - tooltip UI testing', isIconOnly: true, // Start example with icons to demonstrate usefulness of tooltips diff --git a/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_beta.stories.tsx b/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_beta.stories.tsx index ae847270f47..f5d82b24b48 100644 --- a/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_beta.stories.tsx +++ b/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_beta.stories.tsx @@ -48,7 +48,7 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const OpenCollapsibleNav: FunctionComponent< +const StatefulCollapsibleNav: FunctionComponent< PropsWithChildren & Partial > = (props) => { return ( @@ -91,7 +91,7 @@ const renderGroup = ( export const Playground: Story = { render: ({ ...args }) => ( - + - + ), }; diff --git a/packages/eui/src/components/combo_box/combo_box.stories.tsx b/packages/eui/src/components/combo_box/combo_box.stories.tsx index 9d404ce9079..061e120c6f4 100644 --- a/packages/eui/src/components/combo_box/combo_box.stories.tsx +++ b/packages/eui/src/components/combo_box/combo_box.stories.tsx @@ -8,9 +8,12 @@ import React, { useCallback, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import { action } from '@storybook/addon-actions'; import { userEvent, waitFor, within, expect } from '@storybook/test'; +import { + enableFunctionToggleControls, + hideStorybookControls, +} from '../../../.storybook/utils'; import { LOKI_SELECTORS, lokiPlayDecorator } from '../../../.storybook/loki'; import { EuiCode } from '../code'; import { EuiFlexItem } from '../flex'; @@ -72,6 +75,7 @@ const meta: Meta> = { onCreateOption: undefined, // Override Storybook's default callback }, }; +enableFunctionToggleControls(meta, ['onChange', 'onCreateOption']); export default meta; type Story = StoryObj>; @@ -83,7 +87,7 @@ export const Playground: Story = { export const WithTooltip: Story = { parameters: { controls: { - include: ['fullWidth', 'options', 'selectedOptions'], + include: ['fullWidth', 'options', 'selectedOptions', 'onChange'], }, loki: { // popover and tooltip are rendered in a portal @@ -92,7 +96,11 @@ export const WithTooltip: Story = { }, }, args: { - options: options.map((option) => ({ ...option, ...toolTipProps })), + options: options.map((option, idx) => ({ + ...option, + ...toolTipProps, + value: idx, + })), }, render: (args) => , play: lokiPlayDecorator(async (context) => { @@ -120,47 +128,17 @@ export const WithTooltip: Story = { ); }), }; +// manually hide onChange as it's not important as control but needs to be included +// to use the defined control (via enableFunctionToggleControls) in the stateful wrapper +hideStorybookControls(WithTooltip, ['onChange']); export const CustomMatcher: Story = { - render: function Render({ singleSelection, onCreateOption, ...args }) { - const [selectedOptions, setSelectedOptions] = useState( - args.selectedOptions - ); - const onChange: EuiComboBoxProps<{}>['onChange'] = (options, ...args) => { - setSelectedOptions(options); - action('onChange')(options, ...args); - }; - - const optionMatcher = useCallback>( - ({ option, searchValue }) => { - return option.label.startsWith(searchValue); - }, - [] - ); - - return ( - <> -

- This matcher example uses option.label.startsWith() - . Only options that start exactly like the given search string will be - matched. -

-
- - - ); + parameters: { + codeSnippet: { + resolveStoryElementOnly: true, + }, }, + render: (args) => , }; export const Groups: Story = { @@ -225,12 +203,16 @@ export const NestedOptionsGroups: Story = { const StatefulComboBox = ({ singleSelection, onCreateOption, + onChange, ...args }: EuiComboBoxProps<{}>) => { const [selectedOptions, setSelectedOptions] = useState(args.selectedOptions); - const onChange: EuiComboBoxProps<{}>['onChange'] = (options, ...args) => { + const handleOnChange: EuiComboBoxProps<{}>['onChange'] = ( + options, + ...args + ) => { setSelectedOptions(options); - action('onChange')(options, ...args); + onChange?.(options, ...args); }; const _onCreateOption: EuiComboBoxProps<{}>['onCreateOption'] = ( searchValue, @@ -242,7 +224,7 @@ const StatefulComboBox = ({ ? [createdOption] : [...prevState, createdOption] ); - action('onCreateOption')(searchValue, ...args); + onCreateOption?.(searchValue, ...args); }; return ( ); }; + +const StoryCustomMatcher = ({ + singleSelection, + onChange, + ...args +}: EuiComboBoxProps<{}>) => { + const [selectedOptions, setSelectedOptions] = useState(args.selectedOptions); + const handleOnChange: EuiComboBoxProps<{}>['onChange'] = ( + options, + ...args + ) => { + setSelectedOptions(options); + onChange?.(options, ...args); + }; + + const optionMatcher = useCallback>( + ({ option, searchValue }) => { + return option.label.startsWith(searchValue); + }, + [] + ); + + return ( + <> +

+ This matcher example uses option.label.startsWith(). + Only options that start exactly like the given search string will be + matched. +

+
+ + + ); +}; diff --git a/packages/eui/src/components/datagrid/data_grid.stories.tsx b/packages/eui/src/components/datagrid/data_grid.stories.tsx index 590bba21055..dc909f42f12 100644 --- a/packages/eui/src/components/datagrid/data_grid.stories.tsx +++ b/packages/eui/src/components/datagrid/data_grid.stories.tsx @@ -205,6 +205,12 @@ const RenderCellValue = ({ const meta: Meta = { title: 'Tabular Content/EuiDataGrid', component: EuiDataGrid, + parameters: { + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, + }, argTypes: { width: { control: 'text' }, height: { control: 'text' }, diff --git a/packages/eui/src/components/date_picker/date_picker.stories.tsx b/packages/eui/src/components/date_picker/date_picker.stories.tsx index 294cafc703c..bfe89b5868e 100644 --- a/packages/eui/src/components/date_picker/date_picker.stories.tsx +++ b/packages/eui/src/components/date_picker/date_picker.stories.tsx @@ -125,6 +125,13 @@ export default meta; type Story = StoryObj; export const Playground: Story = { + parameters: { + codeSnippet: { + args: { + selected: "#{moment('Tue Mar 19 2024 18:54:51 GMT+0100')}", + }, + }, + }, args: { // NOTE: loki play interactions won't work in CLI somehow // TODO: exchange with loki play() interactions once fixed diff --git a/packages/eui/src/components/drag_and_drop/drag_drop_context.stories.tsx b/packages/eui/src/components/drag_and_drop/drag_drop_context.stories.tsx index 88e46a5470b..f9e0f09918f 100644 --- a/packages/eui/src/components/drag_and_drop/drag_drop_context.stories.tsx +++ b/packages/eui/src/components/drag_and_drop/drag_drop_context.stories.tsx @@ -26,6 +26,10 @@ const meta: Meta = { // visual parts with the Drag and Drop components separately skip: true, }, + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, }, }; enableFunctionToggleControls(meta, [ diff --git a/packages/eui/src/components/drag_and_drop/draggable.stories.tsx b/packages/eui/src/components/drag_and_drop/draggable.stories.tsx index 64f81162767..b8a08fca957 100644 --- a/packages/eui/src/components/drag_and_drop/draggable.stories.tsx +++ b/packages/eui/src/components/drag_and_drop/draggable.stories.tsx @@ -77,6 +77,10 @@ export const Interactive: Story = { 'customDragHandle', ], }, + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, }, args: { draggableId: 'draggable-item', diff --git a/packages/eui/src/components/drag_and_drop/droppable.stories.tsx b/packages/eui/src/components/drag_and_drop/droppable.stories.tsx index 3391599fabd..72151a5af42 100644 --- a/packages/eui/src/components/drag_and_drop/droppable.stories.tsx +++ b/packages/eui/src/components/drag_and_drop/droppable.stories.tsx @@ -27,6 +27,12 @@ const makeId = htmlIdGenerator(); const meta: Meta = { title: 'Display/EuiDroppable', component: EuiDroppable, + parameters: { + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, + }, argTypes: { droppableId: { type: { diff --git a/packages/eui/src/components/form/validatable_control/validatable_control.stories.tsx b/packages/eui/src/components/form/validatable_control/validatable_control.stories.tsx index 87626c82b65..821a47facf8 100644 --- a/packages/eui/src/components/form/validatable_control/validatable_control.stories.tsx +++ b/packages/eui/src/components/form/validatable_control/validatable_control.stories.tsx @@ -26,6 +26,9 @@ const meta: Meta = { // it only adds attributes in the DOM skip: true, }, + codeSnippet: { + resolveStoryElementOnly: true, + }, }, decorators: [ (Story, { args }) => ( diff --git a/packages/eui/src/components/header/header.stories.tsx b/packages/eui/src/components/header/header.stories.tsx index 2ee3e036e95..2e8e23e0d0b 100644 --- a/packages/eui/src/components/header/header.stories.tsx +++ b/packages/eui/src/components/header/header.stories.tsx @@ -84,74 +84,79 @@ export const Sections: Story = { }, }; +const MultipleFixedHeadersExample = () => { + const [fixedHeadersCount, setFixedHeadersCount] = useState(3); // eslint-disable-line react-hooks/rules-of-hooks + const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); // eslint-disable-line react-hooks/rules-of-hooks + + const sections = [ + { + items: [ + , + ], + }, + { + items: [ + setIsFlyoutOpen(!isFlyoutOpen)}> + Toggle flyout + , + ], + }, + ]; + + return ( + + + The page template and flyout should automatically adjust dynamically to + the number of fixed headers on the page. + {isFlyoutOpen && ( + setIsFlyoutOpen(false)}> + The flyout position and mask should automatically adjust dynamically + to the number of fixed headers on the page. + + )} +
+
+ setFixedHeadersCount((count) => count - 1)} + > + Remove a fixed header + +   + setFixedHeadersCount((count) => count + 1)} + > + Add a fixed header + +
+
+ {/* Always render at least one static header so we can toggle/test the flyout */} + + {/* Conditionally render additional fixed headers */} + {Array.from({ length: fixedHeadersCount - 1 }).map((_, i) => ( + + ))} +
+
+ ); +}; + export const MultipleFixedHeaders: Story = { parameters: { layout: 'fullscreen', + codeSnippet: { + resolveChildren: true, + }, }, - render: () => { - const [fixedHeadersCount, setFixedHeadersCount] = useState(3); // eslint-disable-line react-hooks/rules-of-hooks - const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); // eslint-disable-line react-hooks/rules-of-hooks - - const sections = [ - { - items: [ - , - ], - }, - { - items: [ - setIsFlyoutOpen(!isFlyoutOpen)}> - Toggle flyout - , - ], - }, - ]; - - return ( - - - The page template and flyout should automatically adjust dynamically - to the number of fixed headers on the page. - {isFlyoutOpen && ( - setIsFlyoutOpen(false)}> - The flyout position and mask should automatically adjust - dynamically to the number of fixed headers on the page. - - )} -
-
- setFixedHeadersCount((count) => count - 1)} - > - Remove a fixed header - -   - setFixedHeadersCount((count) => count + 1)} - > - Add a fixed header - -
-
- {/* Always render at least one static header so we can toggle/test the flyout */} - - {/* Conditionally render additional fixed headers */} - {Array.from({ length: fixedHeadersCount - 1 }).map((_, i) => ( - - ))} -
-
- ); - }, + render: (args) => , }; diff --git a/packages/eui/src/components/header/header_alert/header_alert.stories.tsx b/packages/eui/src/components/header/header_alert/header_alert.stories.tsx index 1ad16621999..2ffdfe3501f 100644 --- a/packages/eui/src/components/header/header_alert/header_alert.stories.tsx +++ b/packages/eui/src/components/header/header_alert/header_alert.stories.tsx @@ -8,7 +8,6 @@ import React, { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; - import { EuiLink, EuiBadge, @@ -63,7 +62,7 @@ export const Playground: Story = {}; * Flyout example */ const Flyout = (props: EuiHeaderAlertProps) => { - const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + const [isFlyoutVisible, setIsFlyoutVisible] = useState(true); const closeFlyout = () => setIsFlyoutVisible(false); const flyout = isFlyoutVisible && ( @@ -118,7 +117,12 @@ const Flyout = (props: EuiHeaderAlertProps) => { ); }; export const FlyoutExample: Story = { - render: ({ ...args }) => , + parameters: { + codeSnippet: { + resolveChildren: true, + }, + }, + render: (args) => , }; /** @@ -178,5 +182,10 @@ const Popover = (props: any) => { ); }; export const PopoverExample: Story = { - render: ({ ...args }) => , + parameters: { + codeSnippet: { + resolveChildren: true, + }, + }, + render: (args) => , }; diff --git a/packages/eui/src/components/i18n/i18n_number.stories.tsx b/packages/eui/src/components/i18n/i18n_number.stories.tsx index 2cad9a29bfd..67edd2e2aa5 100644 --- a/packages/eui/src/components/i18n/i18n_number.stories.tsx +++ b/packages/eui/src/components/i18n/i18n_number.stories.tsx @@ -48,8 +48,8 @@ export const MultipleValues: Story = { values: [0, 1, 2], children: (values: ReactChild[]) => ( <> - {values.map((value) => ( - + {values.map((value, index) => ( + Formatted number: {value} ))} diff --git a/packages/eui/src/components/inner_text/inner_text.stories.tsx b/packages/eui/src/components/inner_text/inner_text.stories.tsx index 0a299d70821..ebe97f0d5c9 100644 --- a/packages/eui/src/components/inner_text/inner_text.stories.tsx +++ b/packages/eui/src/components/inner_text/inner_text.stories.tsx @@ -26,6 +26,9 @@ export const Playground: Story = { docs: { source: { language: 'tsx' }, }, + codeSnippet: { + skip: true, + }, }, argTypes: { children: { control: { type: 'text' } }, diff --git a/packages/eui/src/components/markdown_editor/markdown_editor.stories.tsx b/packages/eui/src/components/markdown_editor/markdown_editor.stories.tsx index 9de45b03d2c..fa7e8a8ca81 100644 --- a/packages/eui/src/components/markdown_editor/markdown_editor.stories.tsx +++ b/packages/eui/src/components/markdown_editor/markdown_editor.stories.tsx @@ -9,11 +9,6 @@ import type { Meta, StoryObj } from '@storybook/react'; import { EuiMarkdownEditor, EuiMarkdownEditorProps } from './markdown_editor'; -import { - defaultParsingPlugins, - defaultProcessingPlugins, - defaultUiPlugins, -} from './plugins/markdown_default_plugins'; import { MODE_EDITING, MODE_VIEWING } from './markdown_modes'; const initialContent = `## Hello world! @@ -39,9 +34,6 @@ const meta: Meta = { height: 250, maxHeight: 500, autoExpandPreview: true, - parsingPluginList: defaultParsingPlugins, - processingPluginList: defaultProcessingPlugins, - uiPlugins: defaultUiPlugins, errors: [], initialViewMode: MODE_EDITING, dropHandlers: [], diff --git a/packages/eui/src/components/markdown_editor/markdown_format.stories.tsx b/packages/eui/src/components/markdown_editor/markdown_format.stories.tsx index 6957ea14b1f..6581e71af85 100644 --- a/packages/eui/src/components/markdown_editor/markdown_format.stories.tsx +++ b/packages/eui/src/components/markdown_editor/markdown_format.stories.tsx @@ -13,10 +13,6 @@ import { moveStorybookControlsToCategory, } from '../../../.storybook/utils'; import { EuiMarkdownFormat, EuiMarkdownFormatProps } from './markdown_format'; -import { - defaultParsingPlugins, - defaultProcessingPlugins, -} from './plugins/markdown_default_plugins'; import { ALIGNMENTS } from '../text/text_align'; const initialContent = `## Hello world! @@ -48,8 +44,6 @@ const meta: Meta = { // Component defaults args: { textSize: 'm', - parsingPluginList: defaultParsingPlugins, - processingPluginList: defaultProcessingPlugins, }, }; moveStorybookControlsToCategory( diff --git a/packages/eui/src/components/modal/modal_body.stories.tsx b/packages/eui/src/components/modal/modal_body.stories.tsx index 8669a9dafd7..1388e04f691 100644 --- a/packages/eui/src/components/modal/modal_body.stories.tsx +++ b/packages/eui/src/components/modal/modal_body.stories.tsx @@ -18,9 +18,9 @@ const meta: Meta = { title: 'Layout/EuiModal/EuiModalBody', component: EuiModalBody, decorators: [ - (Story) => ( + (Story, { args }) => ( - + ), ], diff --git a/packages/eui/src/components/modal/modal_footer.stories.tsx b/packages/eui/src/components/modal/modal_footer.stories.tsx index e22ecb3ad2b..af5cb4aa03d 100644 --- a/packages/eui/src/components/modal/modal_footer.stories.tsx +++ b/packages/eui/src/components/modal/modal_footer.stories.tsx @@ -18,9 +18,9 @@ const meta: Meta = { title: 'Layout/EuiModal/EuiModalFooter', component: EuiModalFooter, decorators: [ - (Story) => ( + (Story, { args }) => ( - + ), ], diff --git a/packages/eui/src/components/observer/mutation_observer/mutation_observer.stories.tsx b/packages/eui/src/components/observer/mutation_observer/mutation_observer.stories.tsx index 51f7394b875..7ed7692db66 100644 --- a/packages/eui/src/components/observer/mutation_observer/mutation_observer.stories.tsx +++ b/packages/eui/src/components/observer/mutation_observer/mutation_observer.stories.tsx @@ -22,6 +22,12 @@ import { const meta: Meta = { title: 'Utilities/EuiMutationObserver', component: EuiMutationObserver, + parameters: { + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, + }, }; export default meta; diff --git a/packages/eui/src/components/observer/resize_observer/resize_observer.stories.tsx b/packages/eui/src/components/observer/resize_observer/resize_observer.stories.tsx index f2baa73e127..85019d099e7 100644 --- a/packages/eui/src/components/observer/resize_observer/resize_observer.stories.tsx +++ b/packages/eui/src/components/observer/resize_observer/resize_observer.stories.tsx @@ -20,6 +20,12 @@ import { EuiResizeObserver, EuiResizeObserverProps } from './resize_observer'; const meta: Meta = { title: 'Utilities/EuiResizeObserver', component: EuiResizeObserver, + parameters: { + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, + }, }; export default meta; diff --git a/packages/eui/src/components/popover/input_popover.stories.tsx b/packages/eui/src/components/popover/input_popover.stories.tsx index 0a466e613d7..f2c4c4b53d4 100644 --- a/packages/eui/src/components/popover/input_popover.stories.tsx +++ b/packages/eui/src/components/popover/input_popover.stories.tsx @@ -67,6 +67,12 @@ export const Playground: Story = { args: { children: 'Popover content', isOpen: true, + input: ( + + ), }, render: (args) => , }; @@ -90,17 +96,18 @@ const StatefulInputPopover = ({ closePopover?.(); }; + const connectedInput = React.isValidElement(input) + ? React.cloneElement(input, { + ...input.props, + onFocus: () => setOpen(true), + }) + : input; + return ( setOpen(true)} - placeholder="Focus me to toggle an input popover" - aria-label="Popover attached to input element" - /> - } + input={connectedInput} {...rest} > {children} diff --git a/packages/eui/src/components/popover/popover.stories.tsx b/packages/eui/src/components/popover/popover.stories.tsx index 22833f0ba73..45c47dc62c4 100644 --- a/packages/eui/src/components/popover/popover.stories.tsx +++ b/packages/eui/src/components/popover/popover.stories.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { css } from '@emotion/react'; import type { Meta, StoryObj } from '@storybook/react'; @@ -72,6 +72,10 @@ const StatefulPopover = ({ }: EuiPopoverProps) => { const [isOpen, setOpen] = useState(_isOpen); + useEffect(() => { + setOpen(_isOpen); + }, [_isOpen]); + const handleOnClose = () => { setOpen(false); closePopover?.(); diff --git a/packages/eui/src/components/popover/popover_footer.stories.tsx b/packages/eui/src/components/popover/popover_footer.stories.tsx index 0f7eba4ba9e..9ddb92a8a7e 100644 --- a/packages/eui/src/components/popover/popover_footer.stories.tsx +++ b/packages/eui/src/components/popover/popover_footer.stories.tsx @@ -18,9 +18,9 @@ const meta: Meta = { title: 'Layout/EuiPopover/EuiPopoverFooter', component: EuiPopoverFooter, decorators: [ - (Story) => ( + (Story, { args }) => ( trigger}> - + ), ], diff --git a/packages/eui/src/components/popover/popover_title.stories.tsx b/packages/eui/src/components/popover/popover_title.stories.tsx index 0885d7e3214..357c987f98b 100644 --- a/packages/eui/src/components/popover/popover_title.stories.tsx +++ b/packages/eui/src/components/popover/popover_title.stories.tsx @@ -18,9 +18,9 @@ const meta: Meta = { title: 'Layout/EuiPopover/EuiPopoverTitle', component: EuiPopoverTitle, decorators: [ - (Story) => ( + (Story, { args }) => ( trigger}> - + ), ], diff --git a/packages/eui/src/components/popover/wrapping_popover.stories.tsx b/packages/eui/src/components/popover/wrapping_popover.stories.tsx index a827e29b2b8..238429e63d6 100644 --- a/packages/eui/src/components/popover/wrapping_popover.stories.tsx +++ b/packages/eui/src/components/popover/wrapping_popover.stories.tsx @@ -11,6 +11,7 @@ import { css } from '@emotion/react'; import type { Meta, StoryObj } from '@storybook/react'; import { + disableStorybookControls, enableFunctionToggleControls, hideStorybookControls, moveStorybookControlsToCategory, @@ -50,7 +51,8 @@ const meta: Meta = { buffer: 16, }, }; -enableFunctionToggleControls(meta, ['closePopover', 'onPositionChange']); +disableStorybookControls(meta, ['closePopover']); +enableFunctionToggleControls(meta, ['onPositionChange']); moveStorybookControlsToCategory( meta, [ diff --git a/packages/eui/src/components/provider/provider.stories.tsx b/packages/eui/src/components/provider/provider.stories.tsx index 6c84468bda0..08115a748b9 100644 --- a/packages/eui/src/components/provider/provider.stories.tsx +++ b/packages/eui/src/components/provider/provider.stories.tsx @@ -9,6 +9,7 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; +import { SPREAD_STORY_ARGS_MARKER } from '../../../.storybook/addons/code-snippet/constants'; import { EuiProvider, EuiProviderProps } from './provider'; const meta: Meta> = { @@ -30,6 +31,16 @@ export default meta; type Story = StoryObj>; export const FontDefaultUnits: Story = { + parameters: { + codeSnippet: { + snippet: ` + + `, + }, + }, + args: { + modify: { font: { defaultUnits: 'rem' } }, + }, render: () => ( <> Change `modify.font.defaultUnits` to{' '} @@ -37,7 +48,4 @@ export const FontDefaultUnits: Story = { CSS ), - args: { - modify: { font: { defaultUnits: 'rem' } }, - }, }; diff --git a/packages/eui/src/components/resizable_container/resizable_container.stories.tsx b/packages/eui/src/components/resizable_container/resizable_container.stories.tsx index 9162db19b46..491fd88c187 100644 --- a/packages/eui/src/components/resizable_container/resizable_container.stories.tsx +++ b/packages/eui/src/components/resizable_container/resizable_container.stories.tsx @@ -155,6 +155,12 @@ const MultiCollapsible: EuiResizableContainerProps['children'] = ( const meta: Meta = { title: 'Layout/EuiResizableContainer/EuiResizableContainer', component: EuiResizableContainer, + parameters: { + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, + }, args: { direction: 'horizontal', }, diff --git a/packages/eui/src/components/resizable_container/resizable_panel.stories.tsx b/packages/eui/src/components/resizable_container/resizable_panel.stories.tsx index 9b7cd67e850..c13f5bf230d 100644 --- a/packages/eui/src/components/resizable_container/resizable_panel.stories.tsx +++ b/packages/eui/src/components/resizable_container/resizable_panel.stories.tsx @@ -23,6 +23,12 @@ faker.seed(42); const meta: Meta = { title: 'Layout/EuiResizableContainer/Subcomponents/EuiResizablePanel', component: EuiResizablePanel, + parameters: { + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, + }, argTypes: { mode: { control: 'radio', diff --git a/packages/eui/src/components/selectable/selectable.stories.tsx b/packages/eui/src/components/selectable/selectable.stories.tsx index 467189b76bb..ae549dfacff 100644 --- a/packages/eui/src/components/selectable/selectable.stories.tsx +++ b/packages/eui/src/components/selectable/selectable.stories.tsx @@ -68,6 +68,12 @@ const options: EuiSelectableOption[] = [ const meta: Meta = { title: 'Forms/EuiSelectable', component: EuiSelectable, + parameters: { + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, + }, argTypes: { singleSelection: { control: 'radio', options: [true, false, 'always'] }, emptyMessage: { control: 'text' }, @@ -111,7 +117,11 @@ export const WithTooltip: Story = { }, }, args: { - options: options.map((option) => ({ ...option, ...toolTipProps })), + options: options.map((option, idx) => ({ + ...option, + ...toolTipProps, + value: idx, + })), searchable: false, }, render: ({ ...args }: EuiSelectableProps) => , diff --git a/packages/eui/src/components/side_nav/side_nav.stories.tsx b/packages/eui/src/components/side_nav/side_nav.stories.tsx index dd716f5e257..da991080f05 100644 --- a/packages/eui/src/components/side_nav/side_nav.stories.tsx +++ b/packages/eui/src/components/side_nav/side_nav.stories.tsx @@ -28,10 +28,10 @@ const meta: Meta = { isOpenOnMobile: false, }, decorators: [ - (Story) => ( + (Story, { args }) => (
{/* The side nav is visually easier to see with the width set */} - +
), ], diff --git a/packages/eui/src/components/spacer/spacer.stories.tsx b/packages/eui/src/components/spacer/spacer.stories.tsx index 3d8a18fd918..e8a18cffff9 100644 --- a/packages/eui/src/components/spacer/spacer.stories.tsx +++ b/packages/eui/src/components/spacer/spacer.stories.tsx @@ -15,13 +15,18 @@ import { EuiSpacer, EuiSpacerProps } from './spacer'; const meta: Meta = { title: 'Layout/EuiSpacer', component: EuiSpacer, + parameters: { + codeSnippet: { + resolveStoryElementOnly: true, + }, + }, decorators: [ - (Story) => ( + (Story, { args }) => ( <>

Observe the space created between this and the next text block.

- +

Observe the space created between this and the previous text block. diff --git a/packages/eui/src/components/text_diff/text_diff.stories.tsx b/packages/eui/src/components/text_diff/text_diff.stories.tsx index 474583bd937..11b016b27df 100644 --- a/packages/eui/src/components/text_diff/text_diff.stories.tsx +++ b/packages/eui/src/components/text_diff/text_diff.stories.tsx @@ -10,12 +10,22 @@ import React, { ReactElement } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { hideStorybookControls } from '../../../.storybook/utils'; +import { STORY_ARGS_MARKER } from '../../../.storybook/addons/code-snippet/constants'; import { useEuiTextDiff, EuiTextDiffProps } from './text_diff'; const meta: Meta = { title: 'Utilities/useEuiTextDiff', // casting here to match story output while preserving component docgen information component: useEuiTextDiff as unknown as () => ReactElement, + parameters: { + codeSnippet: { + // the story returns a component but the actual code is a hook pattern + // we can provide a manual snippet instead + snippet: ` + const [rendered, textDiffObject] = useTextDiff(${STORY_ARGS_MARKER}) + `, + }, + }, argTypes: { insertComponent: { control: 'text' }, deleteComponent: { control: 'text' }, diff --git a/packages/eui/src/components/text_truncate/text_block_truncate.stories.tsx b/packages/eui/src/components/text_truncate/text_block_truncate.stories.tsx index 6b2c6ff68df..60238aab07b 100644 --- a/packages/eui/src/components/text_truncate/text_block_truncate.stories.tsx +++ b/packages/eui/src/components/text_truncate/text_block_truncate.stories.tsx @@ -20,9 +20,9 @@ const meta: Meta = { title: 'Utilities/EuiTextBlockTruncate', component: EuiTextBlockTruncate, decorators: [ - (Story) => ( + (Story, { args }) => ( - + ), ], diff --git a/packages/eui/src/components/text_truncate/text_truncate.stories.tsx b/packages/eui/src/components/text_truncate/text_truncate.stories.tsx index b7c36f51a43..28cd979ee22 100644 --- a/packages/eui/src/components/text_truncate/text_truncate.stories.tsx +++ b/packages/eui/src/components/text_truncate/text_truncate.stories.tsx @@ -74,6 +74,9 @@ enableFunctionToggleControls(ResizeObserver, ['onResize']); export const StartEndAnchorForSearch: Story = { parameters: { controls: { include: ['text', 'calculationDelayMs', 'ellipsis', 'width'] }, + codeSnippet: { + resolveStoryElementOnly: true, + }, }, render: function Render(props) { const [highlight, setHighlight] = useState(''); diff --git a/packages/eui/src/components/tour/tour.stories.tsx b/packages/eui/src/components/tour/tour.stories.tsx index 47c1bdd8e5f..e100b9f95c5 100644 --- a/packages/eui/src/components/tour/tour.stories.tsx +++ b/packages/eui/src/components/tour/tour.stories.tsx @@ -20,6 +20,10 @@ const meta: Meta = { component: EuiTour, parameters: { layout: 'fullscreen', + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, }, decorators: [ (Story, { args }) => ( diff --git a/packages/eui/src/services/theme/provider.stories.tsx b/packages/eui/src/services/theme/provider.stories.tsx index dfeff43e2a3..7ce87e1b4d8 100644 --- a/packages/eui/src/services/theme/provider.stories.tsx +++ b/packages/eui/src/services/theme/provider.stories.tsx @@ -21,9 +21,14 @@ export default meta; type Story = StoryObj>; export const WrapperCloneElement: Story = { - render: () => ( + args: { + wrapperProps: { + cloneElement: true, + }, + }, + render: (args) => ( <> - +

This example should only have 1 main wrapper rendered.
@@ -33,14 +38,14 @@ export const WrapperCloneElement: Story = { }; export const CSSVariablesNearest: Story = { - render: () => ( + render: (args) => ( <> This component sets the nearest theme provider (the global theme) with a red CSS variable color. Inspect the `:root` styles to see the variable set. - + This component sets the nearest local theme provider with a blue CSS variable color. Inspect the parent theme wrapper to see the variable @@ -52,14 +57,14 @@ export const CSSVariablesNearest: Story = { }; export const CSSVariablesGlobal: Story = { - render: () => ( + render: (args) => ( <> This component sets the nearest theme provider (the global theme) with a red CSS variable color. However, it should be overridden by the next component. - + This component sets the global theme with a blue CSS variable color. It should override the previous component. Inspect the `:root` styles diff --git a/yarn.lock b/yarn.lock index 52939cbd4d3..6e8bdd9b103 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5827,7 +5827,8 @@ __metadata: "@storybook/addon-links": "npm:^8.0.5" "@storybook/addon-webpack5-compiler-babel": "npm:^3.0.3" "@storybook/blocks": "npm:^8.0.5" - "@storybook/manager-api": "npm:^8.1.2" + "@storybook/manager-api": "npm:^8.1.3" + "@storybook/preview-api": "npm:^8.1.3" "@storybook/react": "npm:^8.0.5" "@storybook/react-webpack5": "npm:^8.0.5" "@storybook/test": "npm:^8.0.5" @@ -5846,6 +5847,7 @@ __metadata: "@types/jest": "npm:^29.5.12" "@types/lodash": "npm:^4.14.202" "@types/numeral": "npm:^2.0.5" + "@types/prettier": "npm:2.7.3" "@types/react": "npm:^18.2.14" "@types/react-dom": "npm:^18.2.6" "@types/react-is": "npm:^17.0.3" @@ -8128,16 +8130,16 @@ __metadata: languageName: node linkType: hard -"@storybook/channels@npm:8.1.2": - version: 8.1.2 - resolution: "@storybook/channels@npm:8.1.2" +"@storybook/channels@npm:8.1.6": + version: 8.1.6 + resolution: "@storybook/channels@npm:8.1.6" dependencies: - "@storybook/client-logger": "npm:8.1.2" - "@storybook/core-events": "npm:8.1.2" + "@storybook/client-logger": "npm:8.1.6" + "@storybook/core-events": "npm:8.1.6" "@storybook/global": "npm:^5.0.0" telejson: "npm:^7.2.0" tiny-invariant: "npm:^1.3.1" - checksum: 10c0/19b8a373dc080b3c3514f6e1e3f6c47b94f5d1e8b603d92dd9c5d7528a4dda291ab68b26b026eae809ac53b25a88e183079e39edbb7fb08a920657f8639c7632 + checksum: 10c0/57304d9091b24104bb8cb0d8a87cc4c0096772cea5542da8f9cf58454fe83c480f06c33ff4489e2530b8f75f576c9d4237fffde106a72663859a9d58666a257d languageName: node linkType: hard @@ -8197,12 +8199,12 @@ __metadata: languageName: node linkType: hard -"@storybook/client-logger@npm:8.1.2": - version: 8.1.2 - resolution: "@storybook/client-logger@npm:8.1.2" +"@storybook/client-logger@npm:8.1.6": + version: 8.1.6 + resolution: "@storybook/client-logger@npm:8.1.6" dependencies: "@storybook/global": "npm:^5.0.0" - checksum: 10c0/f4cc40be7ad0dbc970e1339872b6be4a5925008eed1f6596206737905240b2271d4fd4abf9efe418704df6a63caaa0bf1527d26eba95a152d7f206c5e565bbdc + checksum: 10c0/09de69bb2526a7c717b7a522bc984dd4913372e2b9a75d321222af9675201eff32213aaf18b7ced1baf2e9c93b944a841207895f293842fa8aac313e18caa182 languageName: node linkType: hard @@ -8294,13 +8296,13 @@ __metadata: languageName: node linkType: hard -"@storybook/core-events@npm:8.1.2": - version: 8.1.2 - resolution: "@storybook/core-events@npm:8.1.2" +"@storybook/core-events@npm:8.1.6": + version: 8.1.6 + resolution: "@storybook/core-events@npm:8.1.6" dependencies: "@storybook/csf": "npm:^0.1.7" ts-dedent: "npm:^2.0.0" - checksum: 10c0/fb479fc9b7dcf625ed522b8d476739caff7fcfbcb92dd674c37469f2c840c9f1958df2fca62a2d74a9898a095450f4428697ec9ce28058c36c95ec98d6e587a4 + checksum: 10c0/3bf5d43040c66eb6af7048f87ae925605ed4914f9776eaeb2dae4713995ef6a0838c5bae72333d1f3457081fad438c3dbc72cb723f1c08896599c5290004f3b5 languageName: node linkType: hard @@ -8499,26 +8501,26 @@ __metadata: languageName: node linkType: hard -"@storybook/manager-api@npm:^8.1.2": - version: 8.1.2 - resolution: "@storybook/manager-api@npm:8.1.2" +"@storybook/manager-api@npm:^8.1.3": + version: 8.1.6 + resolution: "@storybook/manager-api@npm:8.1.6" dependencies: - "@storybook/channels": "npm:8.1.2" - "@storybook/client-logger": "npm:8.1.2" - "@storybook/core-events": "npm:8.1.2" + "@storybook/channels": "npm:8.1.6" + "@storybook/client-logger": "npm:8.1.6" + "@storybook/core-events": "npm:8.1.6" "@storybook/csf": "npm:^0.1.7" "@storybook/global": "npm:^5.0.0" "@storybook/icons": "npm:^1.2.5" - "@storybook/router": "npm:8.1.2" - "@storybook/theming": "npm:8.1.2" - "@storybook/types": "npm:8.1.2" + "@storybook/router": "npm:8.1.6" + "@storybook/theming": "npm:8.1.6" + "@storybook/types": "npm:8.1.6" dequal: "npm:^2.0.2" lodash: "npm:^4.17.21" memoizerific: "npm:^1.11.3" store2: "npm:^2.14.2" telejson: "npm:^7.2.0" ts-dedent: "npm:^2.0.0" - checksum: 10c0/15c7a62377ff6d7469b96fbb4a0010f0af25aaaa66b6f958664b803d48fd95f0eeb34dff88e12761525bde779cc22021aa8db55bab1d1856c54441aaefe159c9 + checksum: 10c0/4967126179d71cb3eae490e6bc7c05a616d561302d6d9970ea4af44c7a2e64f6fdf41d0b050073344a24dee07201ea9aef012b70dc623cc56777a8a2204af4ca languageName: node linkType: hard @@ -8587,6 +8589,28 @@ __metadata: languageName: node linkType: hard +"@storybook/preview-api@npm:^8.1.3": + version: 8.1.6 + resolution: "@storybook/preview-api@npm:8.1.6" + dependencies: + "@storybook/channels": "npm:8.1.6" + "@storybook/client-logger": "npm:8.1.6" + "@storybook/core-events": "npm:8.1.6" + "@storybook/csf": "npm:^0.1.7" + "@storybook/global": "npm:^5.0.0" + "@storybook/types": "npm:8.1.6" + "@types/qs": "npm:^6.9.5" + dequal: "npm:^2.0.2" + lodash: "npm:^4.17.21" + memoizerific: "npm:^1.11.3" + qs: "npm:^6.10.0" + tiny-invariant: "npm:^1.3.1" + ts-dedent: "npm:^2.0.0" + util-deprecate: "npm:^1.0.2" + checksum: 10c0/7402944ac2179c0abc4205796ebd20387ae850e1a2495223eb1fa3245d4bfd3743fdeaa1d8c7bbd1a4b84c0456a42aa5ae8f619f0966e882d25b231eb2b9af14 + languageName: node + linkType: hard + "@storybook/preview@npm:8.0.6": version: 8.0.6 resolution: "@storybook/preview@npm:8.0.6" @@ -8688,14 +8712,14 @@ __metadata: languageName: node linkType: hard -"@storybook/router@npm:8.1.2": - version: 8.1.2 - resolution: "@storybook/router@npm:8.1.2" +"@storybook/router@npm:8.1.6": + version: 8.1.6 + resolution: "@storybook/router@npm:8.1.6" dependencies: - "@storybook/client-logger": "npm:8.1.2" + "@storybook/client-logger": "npm:8.1.6" memoizerific: "npm:^1.11.3" qs: "npm:^6.10.0" - checksum: 10c0/dd830d106437ebc2bb209e5ec57a55181e798d56c1922fa7ca1de618d7e26fe2a4826709f78ab05440ac6a830a58eb727569aa68aa4b47a9cf15050eda8445cc + checksum: 10c0/426d14cc7905e7bf0f6e784cb02002205bd2b054c3df13d85d59551ceaf1d1409f071170a0283f3d395b58e8e690c06df95961e3a94fc15a7772f24e9f19829b languageName: node linkType: hard @@ -8754,12 +8778,12 @@ __metadata: languageName: node linkType: hard -"@storybook/theming@npm:8.1.2": - version: 8.1.2 - resolution: "@storybook/theming@npm:8.1.2" +"@storybook/theming@npm:8.1.6": + version: 8.1.6 + resolution: "@storybook/theming@npm:8.1.6" dependencies: "@emotion/use-insertion-effect-with-fallbacks": "npm:^1.0.1" - "@storybook/client-logger": "npm:8.1.2" + "@storybook/client-logger": "npm:8.1.6" "@storybook/global": "npm:^5.0.0" memoizerific: "npm:^1.11.3" peerDependencies: @@ -8770,7 +8794,7 @@ __metadata: optional: true react-dom: optional: true - checksum: 10c0/ff094cb6e61573567348f8f9d56f2ccf39be14ec483ba3ec013d05e5c82114f47bccaefc410db738121b1641580247603fd8249456c2c6eee7d32dc0bdb08b89 + checksum: 10c0/13bf3c940ce2ff27088d9147d22cd45f53de5251e8d41cc170d2f569cd6ba30aa1a4574494a15774c82f501889709accff370b9131a5d973243528160d7a0d50 languageName: node linkType: hard @@ -8785,14 +8809,14 @@ __metadata: languageName: node linkType: hard -"@storybook/types@npm:8.1.2": - version: 8.1.2 - resolution: "@storybook/types@npm:8.1.2" +"@storybook/types@npm:8.1.6": + version: 8.1.6 + resolution: "@storybook/types@npm:8.1.6" dependencies: - "@storybook/channels": "npm:8.1.2" + "@storybook/channels": "npm:8.1.6" "@types/express": "npm:^4.7.0" file-system-cache: "npm:2.3.0" - checksum: 10c0/13e7228596c77bbbe47ace99d2cf55973e71efce161e90af8e7b6332be8a99466a5e3017388db26fcf17064a4de9ef4d26219b14429b054ec304369fcbe78334 + checksum: 10c0/f6ccfe58e921cbe533b33d8bcfab2e0289f7018f9baa5052f0997f46ade3e0c5fc150819e1ced4bf6dc384de0964564c4dbd3383a380e9786906739574cd82b7 languageName: node linkType: hard @@ -9923,6 +9947,13 @@ __metadata: languageName: node linkType: hard +"@types/prettier@npm:2.7.3": + version: 2.7.3 + resolution: "@types/prettier@npm:2.7.3" + checksum: 10c0/0960b5c1115bb25e979009d0b44c42cf3d792accf24085e4bfce15aef5794ea042e04e70c2139a2c3387f781f18c89b5706f000ddb089e9a4a2ccb7536a2c5f0 + languageName: node + linkType: hard + "@types/pretty-hrtime@npm:^1.0.0": version: 1.0.1 resolution: "@types/pretty-hrtime@npm:1.0.1"