diff --git a/code/addons/a11y/src/components/Report/index.tsx b/code/addons/a11y/src/components/Report/index.tsx index d231cc4cf0e2..83bcb1705d32 100644 --- a/code/addons/a11y/src/components/Report/index.tsx +++ b/code/addons/a11y/src/components/Report/index.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react'; import React, { Fragment } from 'react'; -import { Placeholder } from '@storybook/components'; +import { EmptyTabContent } from '@storybook/components'; import type { Result } from 'axe-core'; import { Item } from './Item'; @@ -18,7 +18,7 @@ export const Report: FC = ({ items, empty, type }) => ( {items && items.length ? ( items.map((item) => ) ) : ( - {empty} + )} ); diff --git a/code/addons/interactions/src/components/EmptyState.tsx b/code/addons/interactions/src/components/EmptyState.tsx index d4fa62c144a4..0cb5ecba69e2 100644 --- a/code/addons/interactions/src/components/EmptyState.tsx +++ b/code/addons/interactions/src/components/EmptyState.tsx @@ -1,43 +1,11 @@ import React, { useEffect, useState } from 'react'; -import { Link } from '@storybook/components'; +import { Link, EmptyTabContent } from '@storybook/components'; import { DocumentIcon, VideoIcon } from '@storybook/icons'; -import { Consumer, useStorybookApi } from '@storybook/manager-api'; +import { useStorybookApi } from '@storybook/manager-api'; import { styled } from '@storybook/theming'; import { DOCUMENTATION_LINK, TUTORIAL_VIDEO_LINK } from '../constants'; -const Wrapper = styled.div(({ theme }) => ({ - height: '100%', - display: 'flex', - padding: 0, - alignItems: 'center', - justifyContent: 'center', - flexDirection: 'column', - gap: 15, - background: theme.background.content, -})); - -const Content = styled.div({ - display: 'flex', - flexDirection: 'column', - gap: 4, - maxWidth: 415, -}); - -const Title = styled.div(({ theme }) => ({ - fontWeight: theme.typography.weight.bold, - fontSize: theme.typography.size.s2 - 1, - textAlign: 'center', - color: theme.textColor, -})); - -const Description = styled.div(({ theme }) => ({ - fontWeight: theme.typography.weight.regular, - fontSize: theme.typography.size.s2 - 1, - textAlign: 'center', - color: theme.textMutedColor, -})); - const Links = styled.div(({ theme }) => ({ display: 'flex', fontSize: theme.typography.size.s2 - 1, @@ -73,27 +41,25 @@ export const Empty = () => { if (isLoading) return null; return ( - - - Interaction testing - + Interaction tests allow you to verify the functional aspects of UIs. Write a play function for your story and you'll see it run here. - - - - - Watch 8m video - - - - {({ state }) => ( - - Read docs - - )} - - - + + } + footer={ + + + Watch 8m video + + + + Read docs + + + } + /> ); }; diff --git a/code/ui/.storybook/main.ts b/code/ui/.storybook/main.ts index 3defc0046331..d1af3ccb4f68 100644 --- a/code/ui/.storybook/main.ts +++ b/code/ui/.storybook/main.ts @@ -53,7 +53,8 @@ const config: StorybookConfig = { '@storybook/addon-interactions', '@storybook/addon-storysource', '@storybook/addon-designs', - '@chromaui/addon-visual-tests', + '@storybook/addon-a11y', + '@chromatic-com/storybook', ], build: { test: { diff --git a/code/ui/blocks/src/components/ArgsTable/Empty.tsx b/code/ui/blocks/src/components/ArgsTable/Empty.tsx index c4269a605f95..2f2c1bb40445 100644 --- a/code/ui/blocks/src/components/ArgsTable/Empty.tsx +++ b/code/ui/blocks/src/components/ArgsTable/Empty.tsx @@ -1,8 +1,8 @@ import type { FC } from 'react'; import React, { useEffect, useState } from 'react'; import { styled } from '@storybook/theming'; -import { Link } from '@storybook/components'; -import { DocumentIcon, SupportIcon, VideoIcon } from '@storybook/icons'; +import { Link, EmptyTabContent } from '@storybook/components'; +import { DocumentIcon, VideoIcon } from '@storybook/icons'; interface EmptyProps { inAddonPanel?: boolean; @@ -22,27 +22,6 @@ const Wrapper = styled.div<{ inAddonPanel?: boolean }>(({ inAddonPanel, theme }) boxShadow: 'rgba(0, 0, 0, 0.10) 0 1px 3px 0', })); -const Content = styled.div({ - display: 'flex', - flexDirection: 'column', - gap: 4, - maxWidth: 415, -}); - -const Title = styled.div(({ theme }) => ({ - fontWeight: theme.typography.weight.bold, - fontSize: theme.typography.size.s2 - 1, - textAlign: 'center', - color: theme.textColor, -})); - -const Description = styled.div(({ theme }) => ({ - fontWeight: theme.typography.weight.regular, - fontSize: theme.typography.size.s2 - 1, - textAlign: 'center', - color: theme.textMutedColor, -})); - const Links = styled.div(({ theme }) => ({ display: 'flex', fontSize: theme.typography.size.s2 - 1, @@ -73,39 +52,47 @@ export const Empty: FC = ({ inAddonPanel }) => { return ( - - - {inAddonPanel + <EmptyTabContent + title={ + inAddonPanel ? 'Interactive story playground' - : "Args table with interactive controls couldn't be auto-generated"} - - - Controls give you an easy to use interface to test your components. Set your story args - and you'll see controls appearing here automatically. - - - - {inAddonPanel && ( + : "Args table with interactive controls couldn't be auto-generated" + } + description={ <> - - Watch 5m video - - - - Read docs - + Controls give you an easy to use interface to test your components. Set your story args + and you'll see controls appearing here automatically. - )} - {!inAddonPanel && ( - - Learn how to set that up - - )} - + } + footer={ + + {inAddonPanel && ( + <> + + Watch 5m video + + + + Read docs + + + )} + {!inAddonPanel && ( + + Learn how to set that up + + )} + + } + /> ); }; diff --git a/code/ui/components/src/components/tabs/EmptyTabContent.stories.tsx b/code/ui/components/src/components/tabs/EmptyTabContent.stories.tsx new file mode 100644 index 000000000000..3ef2da755f34 --- /dev/null +++ b/code/ui/components/src/components/tabs/EmptyTabContent.stories.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { EmptyTabContent } from './EmptyTabContent'; +import { DocumentIcon } from '@storybook/icons'; +import { Link } from '@storybook/components'; +import type { Meta, StoryObj } from '@storybook/react'; + +export default { + component: EmptyTabContent, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +type Story = StoryObj; + +export const OnlyTitle: Story = { + args: { + title: 'Nothing found', + }, +}; + +export const TitleAndDescription: Story = { + args: { + title: 'Nothing found', + description: 'Sorry, there is nothing to display here.', + }, +}; + +export const TitleAndFooter: Story = { + args: { + title: 'Nothing found', + footer: ( + + See the docs + + ), + }, +}; + +export const TitleDescriptionAndFooter: Story = { + args: { + title: 'Nothing found', + description: 'Sorry, there is nothing to display here.', + footer: ( + + See the docs + + ), + }, +}; diff --git a/code/ui/components/src/components/tabs/EmptyTabContent.tsx b/code/ui/components/src/components/tabs/EmptyTabContent.tsx new file mode 100644 index 000000000000..eec65f6183d7 --- /dev/null +++ b/code/ui/components/src/components/tabs/EmptyTabContent.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { styled } from '@storybook/theming'; + +const Wrapper = styled.div(({ theme }) => ({ + height: '100%', + display: 'flex', + padding: 30, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'column', + gap: 15, + background: theme.background.content, +})); + +const Content = styled.div({ + display: 'flex', + flexDirection: 'column', + gap: 4, + maxWidth: 415, +}); + +const Title = styled.div(({ theme }) => ({ + fontWeight: theme.typography.weight.bold, + fontSize: theme.typography.size.s2 - 1, + textAlign: 'center', + color: theme.textColor, +})); + +const Description = styled.div(({ theme }) => ({ + fontWeight: theme.typography.weight.regular, + fontSize: theme.typography.size.s2 - 1, + textAlign: 'center', + color: theme.textMutedColor, +})); + +interface Props { + title: React.ReactNode; + description?: React.ReactNode; + footer?: React.ReactNode; +} + +export const EmptyTabContent = ({ title, description, footer }: Props) => { + return ( + + + {title} + {description && {description}} + + {footer} + + ); +}; diff --git a/code/ui/components/src/components/tabs/tabs.stories.tsx b/code/ui/components/src/components/tabs/tabs.stories.tsx index 46a332a87f1f..a3c40fd8a9d9 100644 --- a/code/ui/components/src/components/tabs/tabs.stories.tsx +++ b/code/ui/components/src/components/tabs/tabs.stories.tsx @@ -1,9 +1,9 @@ import { expect } from '@storybook/test'; -import React, { Fragment } from 'react'; +import React from 'react'; import { action } from '@storybook/addon-actions'; import type { Meta, StoryObj } from '@storybook/react'; import { within, fireEvent, waitFor, screen, userEvent, findByText } from '@storybook/test'; -import { CPUIcon, MemoryIcon } from '@storybook/icons'; +import { BottomBarIcon, CloseIcon } from '@storybook/icons'; import { Tabs, TabsState, TabWrapper } from './tabs'; import type { ChildrenList } from './tabs.helpers'; import { IconButton } from '../IconButton/IconButton'; @@ -260,7 +260,27 @@ export const StatelessBordered = { ), } satisfies Story; +const AddonTools = () => ( +
+ + + + + + +
+); + export const StatelessWithTools = { + args: { + tools: , + }, render: (args) => ( - - - - - - - - } {...args} > {content} ), -} satisfies Story; +} satisfies StoryObj; export const StatelessAbsolute = { parameters: { @@ -303,7 +313,7 @@ export const StatelessAbsolute = { {content} ), -} satisfies Story; +} satisfies StoryObj; export const StatelessAbsoluteBordered = { parameters: { @@ -323,9 +333,13 @@ export const StatelessAbsoluteBordered = { {content} ), -} satisfies Story; +} satisfies StoryObj; -export const StatelessEmpty = { +export const StatelessEmptyWithTools = { + args: { + ...StatelessWithTools.args, + showToolsWhenEmpty: true, + }, parameters: { layout: 'fullscreen', }, @@ -340,4 +354,25 @@ export const StatelessEmpty = { {...args} /> ), -} satisfies Story; +} satisfies StoryObj; + +export const StatelessWithCustomEmpty = { + args: { + ...StatelessEmptyWithTools.args, + emptyState:
I am custom!
, + }, + parameters: { + layout: 'fullscreen', + }, + render: (args) => ( + + ), +} satisfies StoryObj; diff --git a/code/ui/components/src/components/tabs/tabs.tsx b/code/ui/components/src/components/tabs/tabs.tsx index 0e3484eab4ba..5b0cbb2b5612 100644 --- a/code/ui/components/src/components/tabs/tabs.tsx +++ b/code/ui/components/src/components/tabs/tabs.tsx @@ -1,14 +1,14 @@ import type { FC, PropsWithChildren, ReactElement, ReactNode, SyntheticEvent } from 'react'; -import React, { useMemo, Component, Fragment, memo } from 'react'; +import React, { useMemo, Component, memo } from 'react'; import { styled } from '@storybook/theming'; import { sanitize } from '@storybook/csf'; import type { Addon_RenderOptions } from '@storybook/types'; -import { Placeholder } from '../placeholder/placeholder'; import { TabButton } from '../bar/button'; import { FlexBar } from '../bar/bar'; import { childrenToList, VisuallyHidden } from './tabs.helpers'; import { useList } from './tabs.hooks'; +import { EmptyTabContent } from './EmptyTabContent'; const ignoreSsrWarning = '/* emotion-disable-server-rendering-unsafe-selector-warning-please-do-not-use-this-the-warning-exists-for-a-reason */'; @@ -119,6 +119,8 @@ export interface TabsProps { }>[]; id?: string; tools?: ReactNode; + showToolsWhenEmpty?: boolean; + emptyState?: ReactNode; selected?: string; actions?: { onSelect: (id: string) => void; @@ -140,6 +142,8 @@ export const Tabs: FC = memo( backgroundColor, id: htmlId, menuName, + emptyState, + showToolsWhenEmpty, }) => { const idList = childrenToList(children) .map((i) => i.id) @@ -157,7 +161,13 @@ export const Tabs: FC = memo( const { visibleList, tabBarRef, tabRefs, AddonTab } = useList(list); - return list.length ? ( + const EmptyContent = emptyState ?? ; + + if (!showToolsWhenEmpty && list.length === 0) { + return EmptyContent; + } + + return ( @@ -190,15 +200,13 @@ export const Tabs: FC = memo( {tools} - {list.map(({ id, active, render }) => { - return React.createElement(render, { key: id, active }, null); - })} + {list.length + ? list.map(({ id, active, render }) => { + return React.createElement(render, { key: id, active }, null); + }) + : EmptyContent} - ) : ( - - Nothing found - ); } ); diff --git a/code/ui/components/src/index.ts b/code/ui/components/src/index.ts index f43d08b5d8f5..ffe4a08d699f 100644 --- a/code/ui/components/src/index.ts +++ b/code/ui/components/src/index.ts @@ -66,6 +66,7 @@ export { default as ListItem } from './components/tooltip/ListItem'; // Toolbar and subcomponents export { Tabs, TabsState, TabBar, TabWrapper } from './components/tabs/tabs'; +export { EmptyTabContent } from './components/tabs/EmptyTabContent'; export { IconButtonSkeleton, TabButton } from './components/bar/button'; export { Separator, interleaveSeparators } from './components/bar/separator'; export { Bar, FlexBar } from './components/bar/bar'; diff --git a/code/ui/manager/src/components/panel/Panel.tsx b/code/ui/manager/src/components/panel/Panel.tsx index e37339445c82..654ea6653849 100644 --- a/code/ui/manager/src/components/panel/Panel.tsx +++ b/code/ui/manager/src/components/panel/Panel.tsx @@ -1,10 +1,10 @@ import React, { Component } from 'react'; -import { Tabs, IconButton } from '@storybook/components'; +import { Tabs, IconButton, Link, EmptyTabContent } from '@storybook/components'; import type { State } from '@storybook/manager-api'; import { shortcutToHumanString } from '@storybook/manager-api'; import type { Addon_BaseType } from '@storybook/types'; import { styled } from '@storybook/theming'; -import { BottomBarIcon, CloseIcon, SidebarAltIcon } from '@storybook/icons'; +import { BottomBarIcon, CloseIcon, DocumentIcon, SidebarAltIcon } from '@storybook/icons'; import { useLayout } from '../layout/LayoutProvider'; export interface SafeTabProps { @@ -60,6 +60,23 @@ export const AddonPanel = React.memo<{ {...(selectedPanel ? { selected: selectedPanel } : {})} menuName="Addons" actions={actions} + showToolsWhenEmpty + emptyState={ + + Integrate your tools with Storybook to connect workflows and unlock advanced + features. + + } + footer={ + + Explore integrations catalog + + } + /> + } tools={ {isDesktop ? ( diff --git a/code/ui/manager/src/globals/exports.ts b/code/ui/manager/src/globals/exports.ts index 079340369fcb..73881b9b5919 100644 --- a/code/ui/manager/src/globals/exports.ts +++ b/code/ui/manager/src/globals/exports.ts @@ -67,6 +67,7 @@ export default { 'DL', 'Div', 'DocumentWrapper', + 'EmptyTabContent', 'ErrorFormatter', 'FlexBar', 'Form',