diff --git a/scripts/deploy/build_docs b/scripts/deploy/build_docs index 07c367b8db4..7fa97740793 100755 --- a/scripts/deploy/build_docs +++ b/scripts/deploy/build_docs @@ -4,8 +4,10 @@ set -e # Docusaurus must know the base URL to work properly DOCS_BASE_URL="/new-docs/" +DOCS_STORYBOOK_BASE_URL="https://eui.elastic.co/storybook" if [ -n "${GIT_PULL_REQUEST_ID}" ] && [ "${GIT_PULL_REQUEST_ID}" != "false" ]; then DOCS_BASE_URL="/pr_${GIT_PULL_REQUEST_ID}/new-docs/" + DOCS_STORYBOOK_BASE_URL="https://eui.elastic.co/pr_${GIT_PULL_REQUEST_ID}/storybook" fi echo "Docusaurus base URL set to: ${DOCS_BASE_URL}" @@ -17,6 +19,7 @@ docker run \ --rm -i \ --env HOME=/tmp \ --env DOCS_BASE_URL="$DOCS_BASE_URL" \ + --env DOCS_STORYBOOK_BASE_URL="$DOCS_STORYBOOK_BASE_URL" \ --"user=$(id -u)":"$(id -g)" \ --volume "$PWD":/app \ --workdir /app \ diff --git a/src/components/combo_box/combo_box.stories.tsx b/src/components/combo_box/combo_box.stories.tsx index 0af9433391a..136a0fff96e 100644 --- a/src/components/combo_box/combo_box.stories.tsx +++ b/src/components/combo_box/combo_box.stories.tsx @@ -6,11 +6,33 @@ * Side Public License, v 1. */ -import React, { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import { action } from '@storybook/addon-actions'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { EuiComboBox, EuiComboBoxProps } from './combo_box'; +import { + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, +} from '../modal'; +import { EuiButton } from '../button'; +import { EuiIcon } from '../icon'; +import { EuiHealth } from '../health'; +import { EuiHighlight } from '../highlight'; +import { EuiFormRow } from '../form'; +import { + euiPaletteColorBlindBehindText, + euiPaletteColorBlind, + useGeneratedHtmlId, +} from '../../services'; +import { EuiComboBoxOptionOption } from './types'; +import { EuiComboBoxOptionsListProps } from './combo_box_options_list'; +import { EuiText } from '../text'; +import { EuiSpacer } from '../spacer'; + +const visColorBlind = euiPaletteColorBlind(); +const visColorsBehindText = euiPaletteColorBlindBehindText(); const options = [ { label: 'Item 1' }, @@ -21,9 +43,14 @@ const options = [ ]; const meta: Meta> = { - title: 'EuiComboBox', + title: 'Components/EuiComboBox', // @ts-ignore typescript shenanigans component: EuiComboBox, + args: { + options: options, + selectedOptions: [options[0]], + singleSelection: false, + }, argTypes: { singleSelection: { control: 'radio', @@ -31,59 +58,319 @@ const meta: Meta> = { }, append: { control: 'text' }, prepend: { control: 'text' }, - // Storybook is skipping the Pick<> props from EuiComboBoxList for some annoying reason - onCreateOption: { control: 'boolean' }, // Set to a true/false for ease of testing - customOptionText: { control: 'text' }, - renderOption: { control: 'function' }, }, - args: { - // Pass options in by default for ease of testing - options: options, - selectedOptions: [options[0]], - // Component defaults - delimiter: ',', - sortMatchesBy: 'none', - singleSelection: false, - noSuggestions: false, - async: false, - isCaseSensitive: false, - isClearable: true, - isDisabled: false, - isInvalid: false, - isLoading: false, - autoFocus: false, - compressed: false, - fullWidth: false, - onCreateOption: undefined, // Override Storybook's default callback + render: function Component({ singleSelection, ...args }) { + const [selectedOptions, setSelectedOptions] = useState( + args.selectedOptions + ); + const onChange = useCallback((newOptions: any[]) => { + setSelectedOptions(newOptions); + }, []); + return ( + + ); }, }; export default meta; type Story = StoryObj>; -export const Playground: Story = { - render: function Render({ singleSelection, onCreateOption, ...args }) { +export const Default: Story = {}; + +export const Disabled: Story = { + args: { + isDisabled: true, + }, +}; + +export const CaseSensitive: Story = { + args: { + isCaseSensitive: true, + }, +}; + +const virtualizedOptions = []; +let virtualizedGroupOptions = []; + +for (let i = 1; i < 5000; i++) { + virtualizedGroupOptions.push({ label: `Option ${i}` }); + if (i % 25 === 0) { + virtualizedOptions.push({ + label: `Options ${i - (virtualizedGroupOptions.length - 1)} to ${i}`, + options: virtualizedGroupOptions, + }); + virtualizedGroupOptions = []; + } +} + +export const VirtualizedOptions: Story = { + args: { + options: virtualizedOptions, + }, +}; + +export const InsideModal: Story = { + render: function Component({ singleSelection, ...args }) { + const [isModalVisible, setIsModalVisible] = useState(false); const [selectedOptions, setSelectedOptions] = useState( args.selectedOptions ); - const onChange: EuiComboBoxProps<{}>['onChange'] = (options, ...args) => { - setSelectedOptions(options); - action('onChange')(options, ...args); - }; - const _onCreateOption: EuiComboBoxProps<{}>['onCreateOption'] = ( - searchValue, - ...args - ) => { - const createdOption = { label: searchValue }; - setSelectedOptions((prevState) => - !prevState || singleSelection - ? [createdOption] - : [...prevState, createdOption] - ); - action('onCreateOption')(searchValue, ...args); - }; + const onChange = useCallback((newOptions: any[]) => { + setSelectedOptions(newOptions); + }, []); + + return ( + <> + {isModalVisible && ( + setIsModalVisible(false)} + style={{ width: '800px' }} + > + + Combo box in a modal + + + + + + )} + setIsModalVisible(true)}> + Show modal + + + ); + }, +}; + +const optionsWithColors = [ + { + label: 'Titan', + 'data-test-subj': 'titanOption', + color: visColorsBehindText[0], + }, + { + label: 'Enceladus', + color: visColorsBehindText[1], + }, + { + label: 'Mimas', + color: visColorsBehindText[2], + }, + { + label: 'Dione', + color: visColorsBehindText[3], + }, + { + label: 'Iapetus', + color: visColorsBehindText[4], + }, + { + label: 'Phoebe', + color: visColorsBehindText[5], + }, + { + label: 'Rhea', + color: visColorsBehindText[6], + }, + { + label: + "Pandora is one of Saturn's moons, named for a Titaness of Greek mythology", + color: visColorsBehindText[7], + }, + { + label: 'Tethys', + color: visColorsBehindText[8], + }, + { + label: 'Hyperion', + color: visColorsBehindText[9], + }, +]; + +export const PillColors: Story = { + args: { + options: optionsWithColors, + selectedOptions: [optionsWithColors[2], optionsWithColors[5]], + }, +}; + +const prependAppendOptions = [ + { + label: 'Titan', + 'data-test-subj': 'titanOption', + prepend: , + }, + { + label: 'Enceladus', + prepend: , + }, + { + label: 'Mimas', + prepend: , + }, + { + label: + "Pandora is one of Saturn's moons, named for a Titaness of Greek mythology", + prepend: , + append: '(10)', + }, + { + label: 'Iapetus', + prepend: , + append: '(2)', + }, + { + label: 'Phoebe', + prepend: , + append: '(5)', + }, +]; +export const PrependAppend: Story = { + args: { + options: prependAppendOptions, + selectedOptions: [prependAppendOptions[0], prependAppendOptions[5]], + }, +}; + +const customDropdownContentOptions = [ + { + value: { + size: 5, + }, + label: 'Titan', + 'data-test-subj': 'titanOption', + color: visColorsBehindText[0], + }, + { + value: { + size: 2, + }, + label: 'Enceladus', + color: visColorsBehindText[1], + }, + { + value: { + size: 15, + }, + label: 'Mimas', + color: visColorsBehindText[2], + }, + { + value: { + size: 1, + }, + label: 'Dione', + color: visColorsBehindText[3], + }, + { + value: { + size: 8, + }, + label: 'Iapetus', + color: visColorsBehindText[4], + }, + { + value: { + size: 2, + }, + label: 'Phoebe', + color: visColorsBehindText[5], + }, + { + value: { + size: 33, + }, + label: 'Rhea', + color: visColorsBehindText[6], + }, + { + value: { + size: 18, + }, + label: + "Pandora is one of Saturn's moons, named for a Titaness of Greek mythology", + color: visColorsBehindText[7], + }, + { + value: { + size: 9, + }, + label: 'Tethys', + color: visColorsBehindText[8], + }, + { + value: { + size: 4, + }, + label: 'Hyperion', + color: visColorsBehindText[9], + }, +]; + +export const CustomDropdownContent: Story = { + args: { + options: customDropdownContentOptions, + selectedOptions: [ + customDropdownContentOptions[0], + customDropdownContentOptions[5], + ], + }, + render: function Component({ singleSelection, ...args }) { + const [selectedOptions, setSelectedOptions] = useState( + args.selectedOptions + ); + const onChange = useCallback((newOptions: any[]) => { + setSelectedOptions(newOptions); + }, []); + + const renderOption = useCallback< + NonNullable['renderOption']> + >( + ( + option: EuiComboBoxOptionOption<{ size?: number }>, + searchValue, + contentClassName + ) => { + const { color, label, value } = option; + const dotColor = color + ? visColorBlind[visColorsBehindText.indexOf(color)] + : 'default'; + return ( + + + {label} +   + ({value?.size}) + + + ); + }, + [] + ); + return ( ); }, }; + +const groupsColorsGroup = { + label: 'Colors', + options: [ + { + label: 'Red', + }, + { + label: 'Blue', + }, + { + label: 'Yellow', + }, + { + label: 'Green', + }, + ], +}; + +const groupsSoundsGroup = { + label: 'Sounds', + options: [ + { + label: 'Pop', + }, + { + label: 'Hiss', + }, + { + label: 'Screech', + }, + { + label: 'Ding', + }, + ], +}; + +const groupsOptions = [groupsColorsGroup, groupsSoundsGroup]; + +export const Groups: Story = { + args: { + options: groupsOptions, + }, +}; + +export const SingleSelection: Story = { + args: { + // @ts-ignore The EuiComboBoxSingleSelectionShape part of the type isn't resolved here + singleSelection: 'asPlainText', + }, +}; + +export const SingleSelectionWithPrepend: Story = { + args: { + // @ts-ignore singleSelection value is redefined for the story and doesn't match original types + singleSelection: 'asPlainText', + prepend: 'Prepend', + }, +}; + +export const SingleSelectionWithCustomOptions: Story = { + args: { + // @ts-ignore singleSelection value is redefined for the story and doesn't match original types + singleSelection: 'asPlainText', + }, + render: function Component({ singleSelection, ...args }) { + const [selectedOptions, setSelectedOptions] = useState( + args.selectedOptions + ); + const onChange = useCallback((newOptions: any[]) => { + setSelectedOptions(newOptions); + }, []); + const onCreateOption = useCallback((searchValue: string) => { + const normalized = searchValue.trim().toLowerCase(); + if (!normalized) { + return; + } + + setSelectedOptions([{ label: searchValue }]); + }, []); + return ( + + + + ); + }, +}; + +export const SingleSelectionWithCustomOptionsDisallowed: Story = { + args: { + // @ts-ignore singleSelection value is redefined for the story and doesn't match original types + singleSelection: 'asPlainText', + }, + render: function Component({ singleSelection, ...args }) { + const [selectedOptions, setSelectedOptions] = useState( + args.selectedOptions + ); + const [error, setError] = useState(null); + const [inputRef, setInputRef] = useState(null); + + const onChange = useCallback((newOptions: any[]) => { + setSelectedOptions(newOptions); + setError(null); + }, []); + + const onBlur = useCallback(() => { + if (!inputRef) { + return; + } + + const { value } = inputRef; + setError(value.length === 0 ? null : `"${value}" is not a valid option`); + }, [inputRef]); + + return ( + + + + ); + }, +}; + +export const SingleSelectionWithCustomOptionsAndValidation: Story = { + args: { + noSuggestions: true, + }, + render: function Component({ singleSelection, ...args }) { + const [selectedOptions, setSelectedOptions] = useState( + args.selectedOptions + ); + const [isInvalid, setIsInvalid] = useState(false); + + const isValid = (value: string) => /^[a-zA-Z]+$/.test(value); + + const onChange = useCallback((newOptions: any[]) => { + setSelectedOptions(newOptions); + setIsInvalid(false); + }, []); + + const onCreateOption = useCallback((searchValue: string) => { + if (!isValid(searchValue)) { + // Return false to explicitly reject the user's input. + return false; + } + + const newOption = { + label: searchValue, + }; + + setSelectedOptions((selectedOptions) => [ + ...(selectedOptions || []), + newOption, + ]); + }, []); + + const onSearchChange = useCallback((searchValue: string) => { + if (!searchValue) { + setIsInvalid(false); + return; + } + + setIsInvalid(!isValid(searchValue)); + }, []); + + return ( + + + + ); + }, +}; + +export const Async: Story = { + args: { + async: true, + placeholder: 'Search asynchronously', + }, + render: function Component({ singleSelection, ...args }) { + const [allOptions, setAllOptions] = + useState>>(optionsWithColors); + const [selectedOptions, setSelectedOptions] = useState< + EuiComboBoxOptionOption[] + >([]); + const [isLoading, setIsLoading] = useState(false); + const [options, setOptions] = useState>>( + [] + ); + const searchTimeoutRef = useRef(); + + const onChange = useCallback((newOptions: any[]) => { + setSelectedOptions(newOptions); + }, []); + + const onCreateOption = useCallback< + NonNullable['onCreateOption']> + >((searchValue: string, options = []) => { + const normalizedSearchValue = searchValue.toLowerCase().trim(); + if (!normalizedSearchValue) { + return; + } + + const newOption: EuiComboBoxOptionOption = { + label: searchValue, + }; + + // Create the option if it doesn't exist. + if ( + options.findIndex( + (option) => + option.label.trim().toLowerCase() === normalizedSearchValue + ) === -1 + ) { + setAllOptions((allOptions) => [...allOptions, newOption]); + setOptions((options) => [...options, newOption]); + } + + setSelectedOptions((selectedOptions) => [ + ...(selectedOptions || []), + newOption, + ]); + }, []); + + const onSearchChange = useCallback( + (searchValue: string) => { + setIsLoading(true); + setOptions([]); + + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + searchTimeoutRef.current = window.setTimeout(() => { + setIsLoading(false); + setOptions( + allOptions.filter((option) => + option.label.toLowerCase().includes(searchValue.toLowerCase()) + ) + ); + }, 1200); + }, + [allOptions] + ); + + useEffect(() => { + onSearchChange(''); + }, [onSearchChange]); + + return ( + + ); + }, +}; + +export const Delimiter: Story = { + args: { + delimiter: ',', + }, + render: function Component({ singleSelection, ...args }) { + const [selectedOptions, setSelectedOptions] = useState< + EuiComboBoxOptionOption[] + >([optionsWithColors[2], optionsWithColors[4]]); + const [options, setOptions] = + useState>>(optionsWithColors); + + const onChange = useCallback((newOptions: any[]) => { + setSelectedOptions(newOptions); + }, []); + + const onCreateOption = useCallback< + NonNullable['onCreateOption']> + >((searchValue: string, options = []) => { + const normalizedSearchValue = searchValue.toLowerCase().trim(); + if (!normalizedSearchValue) { + return; + } + + const newOption: EuiComboBoxOptionOption = { + label: searchValue, + }; + + // Create the option if it doesn't exist. + if ( + options.findIndex( + (option) => + option.label.trim().toLowerCase() === normalizedSearchValue + ) === -1 + ) { + setOptions((options) => [...options, newOption]); + } + + setSelectedOptions((selectedOptions) => [ + ...(selectedOptions || []), + newOption, + ]); + }, []); + + return ( + + ); + }, +}; + +export const SortingMatches: Story = { + args: { + sortMatchesBy: 'startsWith', + }, +}; + +export const DuplicateLabels: Story = { + args: { + options: [ + { + label: 'Titan', + key: 'titan1', + }, + { + label: 'Titan', + key: 'titan2', + }, + { + label: 'Enceladus is disabled', + disabled: true, + }, + { + label: 'Titan', + key: 'titan3', + }, + { + label: 'Dione', + }, + ], + }, +}; + +export const AccessibleLabel: Story = { + render: function Component({ singleSelection, ...args }) { + const id = useGeneratedHtmlId({ prefix: 'generated-heading' }); + const [selectedOptions, setSelectedOptions] = useState( + args.selectedOptions + ); + const onChange = useCallback((newOptions: any[]) => { + setSelectedOptions(newOptions); + }, []); + return ( + <> + +

Heading as a label

+
+ + + + ); + }, +}; diff --git a/src/components/combo_box/combo_box.tsx b/src/components/combo_box/combo_box.tsx index 560d2a619f0..baa86526197 100644 --- a/src/components/combo_box/combo_box.tsx +++ b/src/components/combo_box/combo_box.tsx @@ -206,7 +206,7 @@ export class EuiComboBox extends Component< _EuiComboBoxProps, EuiComboBoxState > { - static defaultProps = { + static defaultProps: Partial<_EuiComboBoxProps> = { async: false, compressed: false, fullWidth: false, diff --git a/website/docs/02_components/forms/combo_box/overview.mdx b/website/docs/02_components/forms/combo_box/overview.mdx index 86af671f610..0c2a1523a40 100644 --- a/website/docs/02_components/forms/combo_box/overview.mdx +++ b/website/docs/02_components/forms/combo_box/overview.mdx @@ -20,35 +20,49 @@ or passing a text node ID to the `aria-labelledby` prop. ::: + + ## Disabled Set the prop `isDisabled` to make the combo box disabled. + + ## Case-sensitive matching Set the prop `isCaseSensitive` to make the combo box option matching case sensitive. + + ## Virtualized **EuiComboBoxList** uses [react-window](https://github.com/bvaughn/react-window) to only render visible options to be super fast no matter how many options there are. + + ## Containers This example demonstrates how the combo box works within containers. Because this component uses portals, it’s important that it works within other portal-using components. + + ## Pill colors Useful for visualization or tagging systems. You can also pass a color in your option list. The color can be a hex value (like `#000`) or any other named color value accepted by the [**EuiBadge**](#/display/badge) component. + + ## Option rendering There are two object properties you can add to enhance the content of your options, `option.prepend` and `option.append`. These will add nodes before and after the option label respectively, to both the dropdown option and selected pill. They will not be included in the searchable content as this only matches against the label property. + + ### Custom dropdown content While it is best to stick to the `option.label`, `option.append`, and `option.prepend` props, you can pass @@ -60,27 +74,37 @@ You can use the `value` prop of the `option` object to store metadata about the **Note:** virtualization (above) requires that each option have the same height. Ensure that you render the options so that wrapping text is truncated instead of causing the height of the option to change. + + ## Truncation By default, **EuiComboBox** truncates long option text at the end of the string. You can use `truncationProps` and almost any prop that [**EuiTextTruncate**](#/utilities/text-truncation) accepts to configure this behavior. This can be configured at the **EuiComboBox** level, as well as by each individual option. + + ## Groups You can group options together. The groups _won’t_ match against the search value. + + ## Single selection To only allow the user to select a single option, provide the `singleSelection` prop. You may want to render the selected option as plain text instead of pill form. To do this, pass `singleSelection={{ asPlainText: true }}` + + ## Single selection with prepended label `append` and `prepend` props only work if `singleSelection` prop is not set to `false` to avoid multi-lines that makes combobox height greater than that of `append` and `prepend`. + + ## Single selection with custom options You can allow the user to select a single option and also allow the creation of custom options. @@ -90,38 +114,54 @@ To do that, use the `singleSelection` in conjunction with the `onCreateOption` p that this option is available. You can also customize the custom option text by passing a text to `customOptionText` prop. + + ## Disallowing custom options Leave out the `onCreateOption` prop to disallow the creation of custom options. + + ## Custom options only, with validation Alternatively, provide the `noSuggestions` prop to hide the suggestions list and _only_ allow the creation of custom options. + + ## Async Use the `onSearchChange` code to handle searches asynchronously. Use the`isLoading` prop to let the user know that something async is happening. + + ## With delimiter Pass a unique character to the `delimiter` prop to aid in option creation. This is best used when knowing that content may be pasted from elsewhere such as a comma separated list. + + ## Sorting matches By default, the matched options will keep their original sort order. If you would like to prioritize those options that **start with** the searched string, pass `sortMatchesBy="startsWith"`to display those options at the top of the list. + + ## Duplicate labels In general, it is not recommended to use duplicate labels on the options because the user has no way to distinguish between them. If you need duplicate labels, you will need to add a unique `key` for each option. + + ## Accessible label with aria-labelledby Sometimes it’s preferable to label a combobox with a heading or paragraph. You can easily create a unique ID for a text element using the [HTML ID generator](/#/utilities/html-id-generator), then pass your unique ID to the `aria-labelledby` prop. + + diff --git a/website/docs/02_components/navigation/combo_box/testing.mdx b/website/docs/02_components/forms/combo_box/testing.mdx similarity index 100% rename from website/docs/02_components/navigation/combo_box/testing.mdx rename to website/docs/02_components/forms/combo_box/testing.mdx diff --git a/website/docs/02_components/navigation/combo_box/testing_quick_reference.svg b/website/docs/02_components/forms/combo_box/testing_quick_reference.svg similarity index 100% rename from website/docs/02_components/navigation/combo_box/testing_quick_reference.svg rename to website/docs/02_components/forms/combo_box/testing_quick_reference.svg diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index b263f81017a..907110a8ed3 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -4,6 +4,15 @@ import type * as Preset from '@docusaurus/preset-classic'; const baseUrl = process.env.DOCS_BASE_URL || '/'; +let storybookBaseUrl = process.env.DOCS_STORYBOOK_BASE_URL; +if (!storybookBaseUrl) { + if (process.env.NODE_ENV !== 'production') { + storybookBaseUrl = 'http://localhost:6006'; + } else { + storybookBaseUrl = 'https://eui.elastic.co/storybook'; + } +} + const config: Config = { title: 'Elastic UI Framework', tagline: 'The framework powering the Elastic Stack', @@ -27,6 +36,10 @@ const config: Config = { locales: ['en'], }, + customFields: { + storybookBaseUrl: storybookBaseUrl, + }, + presets: [ [ 'classic', diff --git a/website/src/components/story_embed/index.ts b/website/src/components/story_embed/index.ts new file mode 100644 index 00000000000..95fe6cebac2 --- /dev/null +++ b/website/src/components/story_embed/index.ts @@ -0,0 +1 @@ +export { StoryEmbed } from './story_embed'; diff --git a/website/src/components/story_embed/story_embed.module.css b/website/src/components/story_embed/story_embed.module.css new file mode 100644 index 00000000000..74cd344b191 --- /dev/null +++ b/website/src/components/story_embed/story_embed.module.css @@ -0,0 +1,25 @@ +/* TODO: Replace with proper EUI-compatible styles and use Emotion instead if possible */ +.story-embed__iframe { + display: block; + border: 1px solid var(--ifm-color-emphasis-300); + border-bottom-width: 0; + border-radius: 5px 5px 0 0; + transition: 0.35s ease-in-out height; + + @media (prefers-reduced-motion) { + transition: none; + } +} + +.story-embed__expand-button { + border: 1px solid var(--ifm-color-emphasis-300); + color: var(--ifm-color-content); + font-weight: 600; + font-size: 12px; + background: var(--ifm-background-color); + display: block; + width: 100%; + border-radius: 0 0 5px 5px; + cursor: pointer; + padding: 4px 10px; +} diff --git a/website/src/components/story_embed/story_embed.tsx b/website/src/components/story_embed/story_embed.tsx new file mode 100644 index 00000000000..14c47a3df2b --- /dev/null +++ b/website/src/components/story_embed/story_embed.tsx @@ -0,0 +1,77 @@ +import { HTMLAttributes, useMemo, useState } from 'react'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { stringifyArgsObject } from './stringify_args_object'; +import styles from './story_embed.module.css'; + +export interface StoryEmbedProps> + extends HTMLAttributes { + storyId: string; + view: 'story' | 'docs'; + args: TArgs; + height?: number; + expandedHeight?: number; +} + +export const DEFAULT_HEIGHT = 250; +export const DEFAULT_EXPANDED_HEIGHT = DEFAULT_HEIGHT * 2; + +export const StoryEmbed = >({ + storyId, + args, + view = 'story', + height = DEFAULT_HEIGHT, + expandedHeight = DEFAULT_EXPANDED_HEIGHT, + ...rest +}: StoryEmbedProps) => { + const [isExpanded, setIsExpanded] = useState(false); + + const { siteConfig } = useDocusaurusContext(); + const { storybookBaseUrl } = siteConfig.customFields; + + const fullUrl = useMemo(() => { + const params = new URLSearchParams({ + full: '1', + shortcuts: 'false', + singleStory: 'true', + }); + + let path: string; + if (view === 'docs') { + path = 'index.html'; + params.set('path', `/docs/${storyId}`); + } else { + path = 'iframe.html'; + params.set('id', storyId); + } + + const argsString = stringifyArgsObject(args); + if (argsString) { + params.set('args', argsString); + } + + return `${storybookBaseUrl}/${path}?${params.toString()}`; + }, [storyId, args, view]); + + return ( +
+ {/* TODO: Display custom skeleton when loading the iframe to hide storybook loading state */} +