diff --git a/code/ui/manager/src/components/sidebar/Sidebar.tsx b/code/ui/manager/src/components/sidebar/Sidebar.tsx index 6f4c6eee2b8c..2c98f1a5c695 100644 --- a/code/ui/manager/src/components/sidebar/Sidebar.tsx +++ b/code/ui/manager/src/components/sidebar/Sidebar.tsx @@ -152,7 +152,21 @@ export const Sidebar = React.memo(function Sidebar({ isLoading={isLoading} onMenuClick={onMenuClick} /> - + { + const url = new URL(window.location.href); + Object.entries(params).forEach(([key, value]) => { + if (value) { + url.searchParams.set(key, value); + } else { + url.searchParams.delete(key); + } + }); + window.history.pushState({}, '', url); + }} + /> ; + +export default meta; + +type Story = StoryObj; + +export const Closed: Story = { + args: { + api: { + experimental_setFilter: fn(), + } as any, + index: { + story1: { type: 'story', tags: ['A', 'B', 'C', 'dev'] } as any, + }, + updateQueryParams: fn(), + }, +}; + +export const ClosedWithSelection: Story = { + args: { + ...Closed.args, + initialSelectedTags: ['A', 'B'], + }, +}; + +export const Open: Story = { + ...Closed, + play: async ({ canvasElement }) => { + const button = await findByRole(canvasElement, 'button'); + await button.click(); + }, +}; + +export const OpenWithSelection: Story = { + ...ClosedWithSelection, + play: Open.play, +}; diff --git a/code/ui/manager/src/components/sidebar/TagsFilter.tsx b/code/ui/manager/src/components/sidebar/TagsFilter.tsx index e8ec89c0252a..1945459da7a3 100644 --- a/code/ui/manager/src/components/sidebar/TagsFilter.tsx +++ b/code/ui/manager/src/components/sidebar/TagsFilter.tsx @@ -1,60 +1,82 @@ import React, { useState, useEffect } from 'react'; +import { IconButton, WithTooltip } from '@storybook/components'; +import { FilterIcon } from '@storybook/icons'; import type { API } from '@storybook/manager-api'; import type { Tag, API_IndexHash } from '@storybook/types'; -import { IconButton } from '@storybook/components'; -import { FilterIcon } from '@storybook/icons'; +import { TagsFilterPanel } from './TagsFilterPanel'; -interface TagsFilterProps { +const TAGS_FILTER = 'tags-filter'; + +export interface TagsFilterProps { api: API; index: API_IndexHash; + updateQueryParams: (params: Record) => void; + initialSelectedTags?: Tag[]; } -const UI_FILTER = 'ui-filter'; - -export const TagsFilter = ({ api }: TagsFilterProps) => { - const [includeTags, setIncludeTags] = useState([]); - const [excludeTags, setExcludeTags] = useState([]); - const tagsActive = includeTags.length + excludeTags.length > 0; - - const updateTag = (tag: Tag, selected: boolean, include: boolean) => { - const [filter, setFilter, queryParam] = include - ? [includeTags, setIncludeTags, 'includeTags'] - : [excludeTags, setExcludeTags, 'excludeTags']; - - // no change needed for state/url if the tag is already in the correct state - if ((selected && filter.includes(tag)) || (!selected && !filter.includes(tag))) return; - - // update state - const newFilter = selected ? [...filter, tag] : filter.filter((t) => t !== tag); - setFilter(newFilter); - - // update URL - const url = new URL(window.location.href); - if (newFilter.length === 0) { - url.searchParams.delete(queryParam); - } else { - url.searchParams.set(queryParam, newFilter.join(',')); - } - window.history.pushState({}, '', url); - }; - - const toggleTags = () => { - // updateTag('bar', !includeTags.includes('bar'), true); - updateTag('bar', !excludeTags.includes('bar'), false); - }; +export const TagsFilter = ({ + api, + index, + updateQueryParams, + initialSelectedTags = [], +}: TagsFilterProps) => { + const [selectedTags, setSelectedTags] = useState(initialSelectedTags); + const [exclude, setExclude] = useState(false); + const [expanded, setExpanded] = useState(false); + const tagsActive = selectedTags.length > 0; useEffect(() => { - api.experimental_setFilter(UI_FILTER, (item) => { + api.experimental_setFilter(TAGS_FILTER, (item) => { const tags = item.tags ?? []; - if (excludeTags.some((tag) => tags.includes(tag))) return false; - if (!includeTags.every((tag) => tags.includes(tag))) return false; - return true; + return exclude + ? !selectedTags.some((tag) => tags.includes(tag)) + : selectedTags.every((tag) => tags.includes(tag)); }); - }, [api, includeTags, excludeTags]); + + const tagsParam = selectedTags.join(','); + const [includeTags, excludeTags] = exclude ? [null, tagsParam] : [tagsParam, null]; + updateQueryParams({ includeTags, excludeTags }); + }, [api, selectedTags, exclude, updateQueryParams]); + + const allTags = Object.values(index).reduce((acc, entry) => { + if (entry.type === 'story') { + entry.tags.forEach((tag: Tag) => acc.add(tag)); + } + return acc; + }, new Set()); return ( - - - + ( + { + if (selectedTags.includes(tag)) { + setSelectedTags(selectedTags.filter((t) => t !== tag)); + } else { + setSelectedTags([...selectedTags, tag]); + } + }} + toggleExclude={() => setExclude(!exclude)} + /> + )} + > + { + event.preventDefault(); + setExpanded(!expanded); + }} + > + + + ); }; diff --git a/code/ui/manager/src/components/sidebar/TagsFilterPanel.stories.tsx b/code/ui/manager/src/components/sidebar/TagsFilterPanel.stories.tsx new file mode 100644 index 000000000000..b97093b2d0e1 --- /dev/null +++ b/code/ui/manager/src/components/sidebar/TagsFilterPanel.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; + +import { TagsFilterPanel } from './TagsFilterPanel'; + +const meta = { + component: TagsFilterPanel, + args: { + exclude: false, + toggleTag: fn(), + toggleExclude: fn(), + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Empty: Story = { + args: { + allTags: [], + selectedTags: [], + }, +}; + +export const Default: Story = { + args: { + allTags: ['tag1', 'tag2', 'tag3'], + selectedTags: ['tag1', 'tag3'], + }, +}; + +export const Exclude: Story = { + args: { + ...Default.args, + exclude: true, + }, +}; + +export const BuiltInTags: Story = { + args: { + allTags: [...Default.args.allTags, 'dev', 'autodocs'], + selectedTags: ['tag1', 'tag3'], + }, +}; + +export const BuiltInTagsSelected: Story = { + args: { + ...BuiltInTags.args, + selectedTags: ['tag1', 'tag3', 'autodocs'], + }, +}; diff --git a/code/ui/manager/src/components/sidebar/TagsFilterPanel.tsx b/code/ui/manager/src/components/sidebar/TagsFilterPanel.tsx new file mode 100644 index 000000000000..5ab710d928e0 --- /dev/null +++ b/code/ui/manager/src/components/sidebar/TagsFilterPanel.tsx @@ -0,0 +1,135 @@ +import type { ChangeEvent } from 'react'; +import React, { useState } from 'react'; +import { transparentize } from 'polished'; +import { styled } from '@storybook/theming'; +import { CollapseIcon } from './components/CollapseIcon'; + +const BUILT_IN_TAGS = new Set(['dev', 'autodocs', 'test', 'attached-mdx', 'unattached-mdx']); + +const CollapseButton = styled.button(({ theme }) => ({ + all: 'unset', + display: 'flex', + padding: '0px 8px', + borderRadius: 4, + transition: 'color 150ms, box-shadow 150ms', + gap: 6, + alignItems: 'center', + cursor: 'pointer', + height: 28, + + '&:hover, &:focus': { + outline: 'none', + background: transparentize(0.93, theme.color.secondary), + }, +})); + +const Text = styled.span({ + '[aria-readonly=true] &': { + opacity: 0.5, + }, +}); + +const Label = styled.label({ + lineHeight: '20px', + alignItems: 'center', + marginBottom: 8, + + '&:last-child': { + marginBottom: 0, + }, + + input: { + margin: 0, + marginRight: 6, + }, +}); + +interface TagsFilterPanelProps { + allTags: Tag[]; + selectedTags: Tag[]; + exclude: boolean; + toggleTag: (tag: Tag) => void; + toggleExclude: () => void; +} + +interface TagsListProps { + tags: Tag[]; + selectedTags: Tag[]; + toggleTag: (tag: Tag) => void; +} + +const TagsList = ({ tags, selectedTags, toggleTag }: TagsListProps) => { + return tags.map((tag) => { + const checked = selectedTags.includes(tag); + const id = `tag-${tag}`; + return ( + + ); + }); +}; + +const Wrapper = styled.div({ + label: { + display: 'flex', + }, +}); + +export const TagsFilterPanel = ({ + allTags, + selectedTags, + exclude, + toggleTag, + toggleExclude, +}: TagsFilterPanelProps) => { + const userTags = allTags.filter((tag) => !BUILT_IN_TAGS.has(tag)).toSorted(); + const builtInTags = allTags.filter((tag) => BUILT_IN_TAGS.has(tag)).toSorted(); + const [builtinsExpanded, setBuiltinsExpanded] = useState( + selectedTags.some((tag) => BUILT_IN_TAGS.has(tag)) + ); + + return ( +
+ {userTags.length === 0 ? ( + 'No tags defined' + ) : ( + + Tags {exclude ? 'does not contain' : 'contains'} + + + )} + {builtInTags.length > 0 && ( + <> + { + event.preventDefault(); + setBuiltinsExpanded(!builtinsExpanded); + }} + aria-expanded={builtinsExpanded} + > + + Built-in tags + + {builtinsExpanded ? ( + + + + ) : null} + + )} +
+ ); +};