diff --git a/code/core/src/manager-errors.ts b/code/core/src/manager-errors.ts index b33843655ddd..73f843fde185 100644 --- a/code/core/src/manager-errors.ts +++ b/code/core/src/manager-errors.ts @@ -1,3 +1,4 @@ +import { title } from './core-server/presets/common-preset.ts'; import type { Status, StatusTypeId } from './shared/status-store/index.ts'; import { StorybookError } from './storybook-error.ts'; @@ -31,6 +32,22 @@ export class ProviderDoesNotExtendBaseProviderError extends StorybookError { } } +export class FilterError extends StorybookError { + constructor( + public data: { + title: string; + type: string; + } + ) { + super({ + name: 'FilterError', + category: Category.MANAGER_UI, + code: 1, + message: `Unknown filter type '${data.type}' with value '${data.title}'`, + }); + } +} + export class UncaughtManagerError extends StorybookError { constructor( public data: { diff --git a/code/core/src/manager/components/sidebar/FilterPanel.stories.tsx b/code/core/src/manager/components/sidebar/FilterPanel.stories.tsx index 1a2aeca0ff8c..9fd31de5bb94 100644 --- a/code/core/src/manager/components/sidebar/FilterPanel.stories.tsx +++ b/code/core/src/manager/components/sidebar/FilterPanel.stories.tsx @@ -6,6 +6,7 @@ import type { StatusValue, } from 'storybook/internal/types'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent } from 'storybook/test'; import type { API } from '../../../manager-api/index.ts'; import { IconSymbolsDecorator, MockAPIDecorator } from './Filter.story-helpers.tsx'; @@ -299,3 +300,48 @@ export const OnlyAffectedStatus: Story = { }), }, }; + +const projectedCounterEntries = { + 'foo-1': { tags: ['foo', 'ter'], type: 'story' } as StoryIndexEntry, + 'foo-2': { tags: ['foo', 'ter'], type: 'story' } as StoryIndexEntry, + 'foo-3': { tags: ['foo', 'ter'], type: 'story' } as StoryIndexEntry, + 'foo-4': { tags: ['foo', 'ter'], type: 'story' } as StoryIndexEntry, + 'foo-5': { tags: ['foo', 'ter'], type: 'story' } as StoryIndexEntry, + 'bar-1': { tags: ['bar'], type: 'story' } as StoryIndexEntry, + 'bar-2': { tags: ['bar'], type: 'story' } as StoryIndexEntry, + 'bar-3': { tags: ['bar'], type: 'story' } as StoryIndexEntry, + 'bar-4': { tags: ['bar'], type: 'story' } as StoryIndexEntry, + 'bar-5': { tags: ['bar'], type: 'story' } as StoryIndexEntry, + 'ter-1': { tags: ['ter'], type: 'story' } as StoryIndexEntry, + 'ter-2': { tags: ['ter'], type: 'story' } as StoryIndexEntry, + 'ter-3': { tags: ['ter'], type: 'story' } as StoryIndexEntry, + 'ter-4': { tags: ['ter'], type: 'story' } as StoryIndexEntry, + 'ter-5': { tags: ['ter'], type: 'story' } as StoryIndexEntry, + 'ter-6': { tags: ['ter'], type: 'story' } as StoryIndexEntry, + 'ter-7': { tags: ['ter'], type: 'story' } as StoryIndexEntry, + 'ter-8': { tags: ['ter'], type: 'story' } as StoryIndexEntry, + 'ter-9': { tags: ['ter'], type: 'story' } as StoryIndexEntry, + 'ter-10': { tags: ['ter'], type: 'story' } as StoryIndexEntry, +}; + +export const ProjectedTagCounts: Story = { + args: { + indexJson: { + v: 6, + entries: projectedCounterEntries, + } as StoryIndex, + includedFilters: ['foo', 'bar'], + }, + play: async ({ canvas }) => { + const terCheckbox = await canvas.findByRole('checkbox', { + name: /include tag filter ter\. 10 items would be added\./i, + }); + + await userEvent.hover(terCheckbox); + + await expect( + canvas.getByLabelText('20 of 20 items visible if you include tag filter ter') + ).toBeVisible(); + await expect(canvas.getByLabelText('10 items would be added')).toBeVisible(); + }, +}; diff --git a/code/core/src/manager/components/sidebar/FilterPanel.tsx b/code/core/src/manager/components/sidebar/FilterPanel.tsx index 83963a05eee8..1adb2c1f9bdc 100644 --- a/code/core/src/manager/components/sidebar/FilterPanel.tsx +++ b/code/core/src/manager/components/sidebar/FilterPanel.tsx @@ -1,4 +1,4 @@ -import React, { Fragment, useCallback, useMemo } from 'react'; +import React, { Fragment, useCallback, useMemo, useState } from 'react'; import { ActionList } from 'storybook/internal/components'; import type { StatusValue, StatusesByStoryIdAndTypeId, StoryIndex } from 'storybook/internal/types'; @@ -6,17 +6,24 @@ import type { StatusValue, StatusesByStoryIdAndTypeId, StoryIndex } from 'storyb import { BatchAcceptIcon, DocumentIcon, ShareAltIcon, SweepIcon, UndoIcon } from '@storybook/icons'; import type { API } from 'storybook/manager-api'; -import { styled, useTheme } from 'storybook/theming'; +import { styled, useTheme, type Theme } from 'storybook/theming'; import { getStatus } from '../../utils/status.tsx'; import { createFilterLink, StatusIcon } from './FilterPanelLink.tsx'; -import { type FilterItem, areFiltersEqual } from './FilterPanel.utils.ts'; +import { + type FilterItem, + type FilterPreviewAction, + areFiltersEqual, + computeFilterPanelCounts, + getFilterPreviewDescription, +} from './FilterPanel.utils.ts'; import { type StatusFilterEntry, type TagFilterEntry, useStatusFilterEntries, useTagFilterEntries, } from './useFilterData.tsx'; +import { transparentize } from 'polished'; const Wrapper = styled.div({ minWidth: 240, @@ -27,6 +34,57 @@ const Wrapper = styled.div({ scrollbarWidth: 'thin', }); +const StickyActionList = styled(ActionList)(({ theme }) => ({ + position: 'sticky', + top: 0, + zIndex: 1, + background: theme.base === 'light' ? theme.background.content : theme.background.app, + borderBottom: `1px solid ${theme.appBorderColor}`, + '&& + *': { + borderTop: `none`, + }, +})); + +const SummaryRow = styled.div<{ $delta: number }>(({ theme, $delta }) => ({ + display: 'flex', + alignItems: 'center', + gap: 4, + marginInlineStart: 'auto', + fontSize: theme.typography.size.s1, + whiteSpace: 'nowrap', + borderRadius: 999, + padding: '2px 8px', + fontVariantNumeric: 'tabular-nums', + // background: getSummaryCountBackgroundColor($delta, theme), + color: getSummaryCountColor($delta, theme), +})); + +const getSummaryCountBackgroundColor = (delta: number, theme: Theme) => { + if (delta > 0) { + return theme.background.positive; + } + + if (delta < 0) { + return theme.background.negative; + } + + return 'inherit'; +}; + +const getSummaryCountColor = (delta: number, theme: Theme) => { + if (delta > 0) { + return theme.color.positiveText; + } + + if (delta < 0) { + return theme.color.negativeText; + } + return theme.textMutedColor; +}; + +const getItemPreviewId = (item: Pick) => + `filter-${item.type}-${item.id}`; + export interface FilterPanelProps { api: API; indexJson: StoryIndex; @@ -51,10 +109,42 @@ export const FilterPanel = ({ excludedStatusFilters, }: FilterPanelProps) => { const theme = useTheme(); + const [previewState, setPreviewState] = useState<{ + action: FilterPreviewAction; + itemId: string; + } | null>(null); const { builtInEntries, tagEntries } = useTagFilterEntries(indexJson); const statusEntries = useStatusFilterEntries(allStatuses); + const filterCounts = useMemo( + () => + computeFilterPanelCounts({ + allStatuses, + includedFilters, + excludedFilters, + includedStatusFilters, + excludedStatusFilters, + indexJson, + statusValues: statusEntries.map((entry) => entry.statusValue), + tagIds: [ + ...builtInEntries.map((entry) => entry.id), + ...tagEntries.map((entry) => entry.id), + ], + }), + [ + allStatuses, + builtInEntries, + excludedFilters, + excludedStatusFilters, + includedFilters, + includedStatusFilters, + indexJson, + statusEntries, + tagEntries, + ] + ); + const toTagFilterItem = useCallback( (entry: TagFilterEntry): FilterItem | null => { if (entry.count === 0 && entry.type === 'built-in') return null; @@ -66,6 +156,9 @@ export const FilterPanel = ({ type: entry.type, title: entry.title, count: entry.count, + visibleCount: filterCounts.tags[entry.id]?.visibleCount ?? 0, + toggle: filterCounts.tags[entry.id]?.toggle ?? { delta: 0, visibleCount: 0 }, + invert: filterCounts.tags[entry.id]?.invert ?? { delta: 0, visibleCount: 0 }, icon: entry.icon, isIncluded, isExcluded, @@ -79,7 +172,7 @@ export const FilterPanel = ({ onInvert: () => api.addTagFilters([entry.id], !isExcluded), }; }, - [api, includedFilters, excludedFilters] + [api, excludedFilters, filterCounts.tags, includedFilters] ); const toStatusFilterItem = useCallback( @@ -93,6 +186,9 @@ export const FilterPanel = ({ type: 'status', title: entry.shortName.charAt(0).toUpperCase() + entry.shortName.slice(1), count: entry.count, + visibleCount: filterCounts.statuses[entry.statusValue]?.visibleCount ?? 0, + toggle: filterCounts.statuses[entry.statusValue]?.toggle ?? { delta: 0, visibleCount: 0 }, + invert: filterCounts.statuses[entry.statusValue]?.invert ?? { delta: 0, visibleCount: 0 }, icon: statusIconEl ? {statusIconEl} : null, isIncluded, isExcluded, @@ -106,7 +202,7 @@ export const FilterPanel = ({ onInvert: () => api.addStatusFilters([entry.statusValue], !isExcluded), }; }, - [api, includedStatusFilters, excludedStatusFilters, theme] + [api, excludedStatusFilters, filterCounts.statuses, includedStatusFilters, theme] ); const builtInItems = useMemo( @@ -157,12 +253,49 @@ export const FilterPanel = ({ includedStatusFilters.length === 0 && excludedStatusFilters.length === 0; - const hasItems = builtInItems.length > 0 || tagItems.length > 0; + const hasItems = builtInItems.length > 0 || tagItems.length > 0 || statusItems.length > 0; + const allItems = useMemo( + () => [...builtInItems, ...statusItems, ...tagItems], + [builtInItems, statusItems, tagItems] + ); + const previewAction = previewState?.action ?? null; + const previewedItem = previewState + ? allItems.find((item) => getItemPreviewId(item) === previewState.itemId) + : null; + const previewedProjection = previewedItem && previewAction ? previewedItem[previewAction] : null; + const summaryCount = previewedProjection?.visibleCount ?? filterCounts.currentVisibleCount; + const summaryCountString = `${summaryCount}/${filterCounts.totalCount}`; + const summaryAriaLabel = + previewedItem && previewedProjection && previewAction + ? `${summaryCount} of ${filterCounts.totalCount} items visible if you ${getFilterPreviewDescription( + previewedItem, + previewAction + )}` + : `${summaryCount} of ${filterCounts.totalCount} items currently visible`; + const summaryDelta = previewedProjection?.delta ?? 0; + + const renderItem = useCallback( + (item: FilterItem) => { + const itemPreviewId = getItemPreviewId(item); + const link = createFilterLink(item, { + activePreviewAction: previewState?.itemId === itemPreviewId ? previewState.action : null, + onPreviewEnd: () => { + setPreviewState((current) => (current?.itemId === itemPreviewId ? null : current)); + }, + onPreviewStart: (action) => { + setPreviewState({ action, itemId: itemPreviewId }); + }, + }); + + return {link.content}; + }, + [previewState] + ); return ( {hasItems && ( - + {isNothingSelectedYet ? ( )} + + {summaryCountString} + - - )} - {builtInItems.length > 0 && ( - - {builtInItems.map((item) => { - const link = createFilterLink(item); - return {link.content}; - })} - - )} - {statusItems.length > 0 && ( - - {statusItems.map((item) => { - const link = createFilterLink(item); - return {link.content}; - })} - - )} - {tagItems.length > 0 && ( - - {tagItems.map((item) => { - const link = createFilterLink(item); - return {link.content}; - })} - + )} + {builtInItems.length > 0 && {builtInItems.map(renderItem)}} + {statusItems.length > 0 && {statusItems.map(renderItem)}} + {tagItems.length > 0 && {tagItems.map(renderItem)}} {tagItems.length === 0 && ( diff --git a/code/core/src/manager/components/sidebar/FilterPanel.utils.test.ts b/code/core/src/manager/components/sidebar/FilterPanel.utils.test.ts new file mode 100644 index 000000000000..377cc347eee1 --- /dev/null +++ b/code/core/src/manager/components/sidebar/FilterPanel.utils.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; + +import type { + StoryIndex, + StoryIndexEntry, + StatusesByStoryIdAndTypeId, +} from 'storybook/internal/types'; + +import { computeFilterPanelCounts } from './FilterPanel.utils.ts'; + +describe('FilterPanel.utils', () => { + it('computes current counts and tag projections', () => { + const indexJson = { + v: 6, + entries: { + 'foo-1': { tags: ['foo', 'ter'], type: 'story' } as StoryIndexEntry, + 'foo-2': { tags: ['foo', 'ter'], type: 'story' } as StoryIndexEntry, + 'foo-3': { tags: ['foo', 'ter'], type: 'story' } as StoryIndexEntry, + 'foo-4': { tags: ['foo', 'ter'], type: 'story' } as StoryIndexEntry, + 'foo-5': { tags: ['foo', 'ter'], type: 'story' } as StoryIndexEntry, + 'bar-1': { tags: ['bar'], type: 'story' } as StoryIndexEntry, + 'bar-2': { tags: ['bar'], type: 'story' } as StoryIndexEntry, + 'bar-3': { tags: ['bar'], type: 'story' } as StoryIndexEntry, + 'bar-4': { tags: ['bar'], type: 'story' } as StoryIndexEntry, + 'bar-5': { tags: ['bar'], type: 'story' } as StoryIndexEntry, + 'ter-1': { tags: ['ter'], type: 'story' } as StoryIndexEntry, + 'ter-2': { tags: ['ter'], type: 'story' } as StoryIndexEntry, + 'ter-3': { tags: ['ter'], type: 'story' } as StoryIndexEntry, + 'ter-4': { tags: ['ter'], type: 'story' } as StoryIndexEntry, + 'ter-5': { tags: ['ter'], type: 'story' } as StoryIndexEntry, + 'ter-6': { tags: ['ter'], type: 'story' } as StoryIndexEntry, + 'ter-7': { tags: ['ter'], type: 'story' } as StoryIndexEntry, + 'ter-8': { tags: ['ter'], type: 'story' } as StoryIndexEntry, + 'ter-9': { tags: ['ter'], type: 'story' } as StoryIndexEntry, + 'ter-10': { tags: ['ter'], type: 'story' } as StoryIndexEntry, + }, + } as StoryIndex; + + const counts = computeFilterPanelCounts({ + allStatuses: {}, + excludedFilters: [], + excludedStatusFilters: [], + includedFilters: ['foo', 'bar'], + includedStatusFilters: [], + indexJson, + statusValues: [], + tagIds: ['foo', 'bar', 'ter'], + }); + + expect(counts.currentVisibleCount).toBe(10); + expect(counts.totalCount).toBe(20); + expect(counts.tags.foo.visibleCount).toBe(5); + expect(counts.tags.bar.visibleCount).toBe(5); + expect(counts.tags.ter.visibleCount).toBe(5); + expect(counts.tags.ter.toggle).toEqual({ delta: 10, visibleCount: 20 }); + expect(counts.tags.ter.invert).toEqual({ delta: -5, visibleCount: 5 }); + }); + + it('computes current counts and status projections', () => { + const indexJson = { + v: 6, + entries: { + 'story-1': { tags: ['foo'], type: 'story' } as StoryIndexEntry, + 'story-2': { tags: ['bar'], type: 'story' } as StoryIndexEntry, + 'story-3': { tags: ['baz'], type: 'story' } as StoryIndexEntry, + }, + } as StoryIndex; + const allStatuses = { + 'story-1': { + change: { + description: '', + storyId: 'story-1', + title: 'New', + typeId: 'change', + value: 'status-value:new', + }, + }, + 'story-2': { + change: { + description: '', + storyId: 'story-2', + title: 'Modified', + typeId: 'change', + value: 'status-value:modified', + }, + }, + } as StatusesByStoryIdAndTypeId; + + const counts = computeFilterPanelCounts({ + allStatuses, + excludedFilters: [], + excludedStatusFilters: [], + includedFilters: [], + includedStatusFilters: ['status-value:new'], + indexJson, + statusValues: ['status-value:new', 'status-value:modified'], + tagIds: [], + }); + + expect(counts.currentVisibleCount).toBe(1); + expect(counts.statuses['status-value:new']).toEqual({ + invert: { delta: 1, visibleCount: 2 }, + toggle: { delta: 2, visibleCount: 3 }, + visibleCount: 1, + }); + expect(counts.statuses['status-value:modified']).toEqual({ + invert: { delta: 0, visibleCount: 1 }, + toggle: { delta: 1, visibleCount: 2 }, + visibleCount: 0, + }); + }); +}); diff --git a/code/core/src/manager/components/sidebar/FilterPanel.utils.ts b/code/core/src/manager/components/sidebar/FilterPanel.utils.ts index b3233a22c4b9..2c07da25110d 100644 --- a/code/core/src/manager/components/sidebar/FilterPanel.utils.ts +++ b/code/core/src/manager/components/sidebar/FilterPanel.utils.ts @@ -1,8 +1,17 @@ import type { ReactElement } from 'react'; -import type { FilterFunction, StatusValue, Tag } from 'storybook/internal/types'; +import type { + API_PreparedIndexEntry, + FilterFunction, + StatusValue, + StatusesByStoryIdAndTypeId, + StoryIndex, + Tag, +} from 'storybook/internal/types'; import { BUILT_IN_FILTERS, USER_TAG_FILTER } from '../../../shared/constants/tags.ts'; +import { StorybookError } from '../../../storybook-error.ts'; +import { FilterError } from '../../../manager-errors.ts'; export { statusValueShortName, toStatusValue } from '../../../shared/status-store/index.ts'; @@ -11,6 +20,9 @@ export type FilterItem = { type: string; title: string; count: number; + visibleCount: number; + toggle: FilterProjection; + invert: FilterProjection; icon: ReactElement | null; isIncluded: boolean; isExcluded: boolean; @@ -18,6 +30,37 @@ export type FilterItem = { onInvert: () => void; }; +export type FilterPreviewAction = 'toggle' | 'invert'; + +export type FilterProjection = { + visibleCount: number; + delta: number; +}; + +type FilterState = { + includedFilters: string[]; + excludedFilters: string[]; + includedStatusFilters: StatusValue[]; + excludedStatusFilters: StatusValue[]; +}; + +type FilterableEntry = API_PreparedIndexEntry; + +export type FilterPanelCounts = { + currentVisibleCount: number; + totalCount: number; + tags: Record< + string, + { visibleCount: number; toggle: FilterProjection; invert: FilterProjection } + >; + statuses: Partial< + Record< + StatusValue, + { visibleCount: number; toggle: FilterProjection; invert: FilterProjection } + > + >; +}; + /** Tags that are hidden in the filter UI. There's a more general built-in list defined in `shared/constants/tags`. */ export const BUILT_IN_TAGS = new Set([ 'dev', @@ -44,6 +87,15 @@ export const STATUS_DISPLAY_ORDER: StatusValue[] = [ export const areFiltersEqual = (left: string[], right: string[]) => left.length === right.length && new Set([...left, ...right]).size === left.length; +export const formatFilterDelta = (delta: number) => { + if (delta > 0) { + return `+${delta}`; + } + + // Already has - sign when negative + return `${delta}`; +}; + export const getFilterFunction = (tag: Tag): FilterFunction | null => { if (Object.hasOwn(BUILT_IN_FILTERS, tag)) { return BUILT_IN_FILTERS[tag as keyof typeof BUILT_IN_FILTERS]; @@ -51,3 +103,294 @@ export const getFilterFunction = (tag: Tag): FilterFunction | null => { return USER_TAG_FILTER(tag); } }; + +export const getFilterPreviewDescription = ( + item: Pick, + action: FilterPreviewAction +) => { + const verb = getFilterActionVerb(item, action); + + if (item.type === 'status') { + switch (verb) { + case 'include': + return `Show ${item.title} items`; + case 'exclude': + return `Exclude ${item.title} items`; + case 'remove': + return `Remove '${item.title}' status filter`; + } + } + + if (item.type === 'tag') { + switch (verb) { + case 'include': + return `Show items tagged '${item.title}'`; + case 'exclude': + return `Exclude items tagged '${item.title}'`; + case 'remove': + return `Remove '${item.title}' tag filter`; + } + } + + if (item.type === 'built-in') { + const suffix = { + Documentation: 'docs pages', + Play: 'stories with play functions', + Testing: 'tests', + }[item.title]; + + switch (verb) { + case 'include': + return `Show ${suffix}`; + case 'exclude': + return `Exclude ${suffix}`; + case 'remove': + return `Remove '${item.title}' filter`; + } + } + + throw new FilterError(item); +}; + +const getFilterActionVerb = ( + { isIncluded, isExcluded }: Pick, + action: FilterPreviewAction +) => { + if (action === 'toggle') { + return isIncluded || isExcluded ? 'remove' : 'include'; + } + + return isExcluded ? 'include' : 'exclude'; +}; + +const getLeafEntries = (indexJson: StoryIndex): FilterableEntry[] => + Object.entries(indexJson.entries).reduce((acc, [id, entry]) => { + if (entry.type === 'story' || entry.type === 'docs') { + acc.push({ id, ...entry } as FilterableEntry); + } + + return acc; + }, []); + +const matchTag = (entry: FilterableEntry, tag: string) => + getFilterFunction(tag as Tag)?.(entry) ?? false; + +const matchStatus = ( + entry: FilterableEntry, + allStatuses: StatusesByStoryIdAndTypeId, + statusValue: StatusValue +) => Object.values(allStatuses[entry.id] ?? {}).some((status) => status.value === statusValue); + +const compileTagFilters = (filters: string[]): FilterFunction[][] => + Object.values( + filters.reduce( + (acc, tag) => { + const filterFn = getFilterFunction(tag as Tag); + if (!filterFn) { + return acc; + } + + if (Object.hasOwn(BUILT_IN_FILTERS, tag)) { + acc['built-in'].push(filterFn); + } else { + acc.user.push(filterFn); + } + + return acc; + }, + { 'built-in': [], user: [] } as { 'built-in': FilterFunction[]; user: FilterFunction[] } + ) + ).filter((group) => group.length > 0); + +const matchesFilters = ( + entry: FilterableEntry, + compiledTags: { included: FilterFunction[][]; excluded: FilterFunction[][] }, + allStatuses: StatusesByStoryIdAndTypeId, + { includedStatusFilters, excludedStatusFilters }: FilterState +) => { + const matchesIncludedTags = + compiledTags.included.length === 0 || + compiledTags.included.every((group) => group.some((filterFn) => filterFn(entry, false))); + + const matchesExcludedTags = + compiledTags.excluded.length === 0 || + compiledTags.excluded.every((group) => group.every((filterFn) => filterFn(entry, true))); + + const statuses = Object.values(allStatuses[entry.id] ?? {}).map((status) => status.value); + const matchesIncludedStatuses = + includedStatusFilters.length === 0 || + includedStatusFilters.some((value) => statuses.includes(value)); + const matchesExcludedStatuses = + excludedStatusFilters.length === 0 || + excludedStatusFilters.every((value) => !statuses.includes(value)); + + return ( + matchesIncludedTags && matchesExcludedTags && matchesIncludedStatuses && matchesExcludedStatuses + ); +}; + +const serializeFilterState = ({ + excludedFilters, + excludedStatusFilters, + includedFilters, + includedStatusFilters, +}: FilterState) => + [ + [...includedFilters].sort().join(';'), + [...excludedFilters].sort().join(';'), + [...includedStatusFilters].sort().join(';'), + [...excludedStatusFilters].sort().join(';'), + ].join('|'); + +const updateFilterState = ( + currentState: FilterState, + kind: 'tag' | 'status', + value: string, + action: FilterPreviewAction +): FilterState => { + const included = + kind === 'tag' + ? new Set(currentState.includedFilters) + : new Set(currentState.includedStatusFilters); + const excluded = + kind === 'tag' + ? new Set(currentState.excludedFilters) + : new Set(currentState.excludedStatusFilters); + + const isIncluded = included.has(value); + const isExcluded = excluded.has(value); + const includeValue = () => { + included.add(value); + excluded.delete(value); + }; + const excludeValue = () => { + included.delete(value); + excluded.add(value); + }; + const clearValue = () => { + included.delete(value); + excluded.delete(value); + }; + + if (action === 'toggle') { + if (isIncluded || isExcluded) { + clearValue(); + } else { + includeValue(); + } + } else if (isExcluded) { + includeValue(); + } else { + excludeValue(); + } + + return { + includedFilters: kind === 'tag' ? Array.from(included) : [...currentState.includedFilters], + excludedFilters: kind === 'tag' ? Array.from(excluded) : [...currentState.excludedFilters], + includedStatusFilters: + kind === 'status' + ? (Array.from(included) as StatusValue[]) + : [...currentState.includedStatusFilters], + excludedStatusFilters: + kind === 'status' + ? (Array.from(excluded) as StatusValue[]) + : [...currentState.excludedStatusFilters], + }; +}; + +export const computeFilterPanelCounts = ({ + allStatuses, + includedFilters, + excludedFilters, + includedStatusFilters, + excludedStatusFilters, + indexJson, + statusValues, + tagIds, +}: { + allStatuses: StatusesByStoryIdAndTypeId; + includedFilters: string[]; + excludedFilters: string[]; + includedStatusFilters: StatusValue[]; + excludedStatusFilters: StatusValue[]; + indexJson: StoryIndex; + statusValues: StatusValue[]; + tagIds: string[]; +}): FilterPanelCounts => { + const leafEntries = getLeafEntries(indexJson); + const currentState: FilterState = { + includedFilters, + excludedFilters, + includedStatusFilters, + excludedStatusFilters, + }; + + // Reuse projected visible counts across repeated hover/focus computations for the same filter state. + const visibleCountCache = new Map(); + + const getVisibleCount = (state: FilterState) => { + const cacheKey = serializeFilterState(state); + const cached = visibleCountCache.get(cacheKey); + + if (cached !== undefined) { + return cached; + } + + const compiledTags = { + included: compileTagFilters(state.includedFilters), + excluded: compileTagFilters(state.excludedFilters), + }; + + const count = leafEntries.filter((entry) => + matchesFilters(entry, compiledTags, allStatuses, state) + ).length; + + visibleCountCache.set(cacheKey, count); + + return count; + }; + + const currentCompiledTags = { + included: compileTagFilters(includedFilters), + excluded: compileTagFilters(excludedFilters), + }; + const currentVisibleEntries = leafEntries.filter((entry) => + matchesFilters(entry, currentCompiledTags, allStatuses, currentState) + ); + const currentVisibleCount = currentVisibleEntries.length; + + const buildCounts = ( + kind: 'tag' | 'status', + value: string, + predicate: (entry: FilterableEntry) => boolean + ) => { + const toggleState = updateFilterState(currentState, kind, value, 'toggle'); + const invertState = updateFilterState(currentState, kind, value, 'invert'); + + return { + visibleCount: currentVisibleEntries.filter(predicate).length, + toggle: { + visibleCount: getVisibleCount(toggleState), + delta: getVisibleCount(toggleState) - currentVisibleCount, + }, + invert: { + visibleCount: getVisibleCount(invertState), + delta: getVisibleCount(invertState) - currentVisibleCount, + }, + }; + }; + + return { + currentVisibleCount, + totalCount: leafEntries.length, + tags: Object.fromEntries( + tagIds.map((tagId) => [tagId, buildCounts('tag', tagId, (entry) => matchTag(entry, tagId))]) + ), + statuses: Object.fromEntries( + statusValues.map((statusValue) => [ + statusValue, + buildCounts('status', statusValue, (entry) => matchStatus(entry, allStatuses, statusValue)), + ]) + ), + }; +}; diff --git a/code/core/src/manager/components/sidebar/FilterPanelLink.tsx b/code/core/src/manager/components/sidebar/FilterPanelLink.tsx index 2430091c7465..dd1cdde992f6 100644 --- a/code/core/src/manager/components/sidebar/FilterPanelLink.tsx +++ b/code/core/src/manager/components/sidebar/FilterPanelLink.tsx @@ -7,12 +7,35 @@ import { DeleteIcon } from '@storybook/icons'; import { styled } from 'storybook/theming'; import type { Link } from '../../../components/components/tooltip/TooltipLinkList.tsx'; -import type { FilterItem } from './FilterPanel.utils.ts'; +import { + type FilterItem, + type FilterPreviewAction, + formatFilterDelta, + getFilterPreviewDescription, +} from './FilterPanel.utils.ts'; const MutedText = styled.span(({ theme }) => ({ color: theme.textMutedColor, })); +const Count = styled.span(({ theme }) => ({ + minWidth: 36, + textAlign: 'end', + fontVariantNumeric: 'tabular-nums', + color: theme.textMutedColor, +})); + +const getDeltaColor = ( + delta: number, + theme: { color: { negative: string; positive: string; secondary: string } } +) => (delta > 0 ? theme.color.positive : delta < 0 ? theme.color.negative : 'inherit'); + +const Delta = styled(Count)<{ $delta: number }>(({ theme, $delta }) => ({ + color: getDeltaColor($delta, theme), +})); + +const getItemText = (count: number) => `item${count === 1 ? '' : 's'}`; + export const StatusIcon = styled.span<{ $iconColor?: string | null }>(({ $iconColor }) => ({ display: 'contents', color: $iconColor ?? undefined, @@ -21,34 +44,83 @@ export const StatusIcon = styled.span<{ $iconColor?: string | null }>(({ $iconCo }, })); -export const createFilterLink = ({ - id, - type, - title, - count, - icon, - isIncluded, - isExcluded, - onCheckboxChange, - onInvert, -}: FilterItem): Link => { +const getActionCopy = (item: FilterItem, action: FilterPreviewAction) => { + const projection = item[action]; + const description = getFilterPreviewDescription(item, action); + const absoluteDelta = Math.abs(projection.delta); + const deltaText = `${absoluteDelta} ${getItemText(absoluteDelta)} would be ${ + projection.delta >= 0 ? 'added' : 'removed' + }`; + + return { + ariaLabel: `${description}. ${deltaText}.`, + tooltip: `${description} (${projection.delta ? `${formatFilterDelta(projection.delta)} items` : 'no change'})`, + }; +}; + +export const createFilterLink = ( + item: FilterItem, + { + activePreviewAction, + onPreviewEnd, + onPreviewStart, + }: { + activePreviewAction: FilterPreviewAction | null; + onPreviewEnd: () => void; + onPreviewStart: (action: FilterPreviewAction) => void; + } +): Link => { + const { + id, + type, + title, + visibleCount, + toggle, + invert, + icon, + isIncluded, + isExcluded, + onCheckboxChange, + onInvert, + } = item; const isChecked = isIncluded || isExcluded; - const toggleLabel = `${type} filter: ${isExcluded ? `exclude ${title}` : title}`; - const toggleTooltip = `${isChecked ? 'Remove' : 'Add'} ${type} filter: ${title}`; - const invertButtonLabel = `${isExcluded ? 'Include' : 'Exclude'} ${type}: ${title}`; + const toggleAction = getActionCopy(item, 'toggle'); + const invertAction = getActionCopy(item, 'invert'); + const activeProjection = activePreviewAction + ? activePreviewAction === 'toggle' + ? toggle + : invert + : null; + const valueText = activeProjection + ? formatFilterDelta(activeProjection.delta) + : `${visibleCount}`; + const valueAriaLabel = activeProjection + ? `${Math.abs(activeProjection.delta)} ${getItemText( + Math.abs(activeProjection.delta) + )} would be ${activeProjection.delta >= 0 ? 'added' : 'removed'}` + : `${visibleCount} ${getItemText(visibleCount)} currently shown`; return { id: `filter-${type}-${id}`, content: ( - + onPreviewStart('toggle')} + onMouseEnter={() => onPreviewStart('toggle')} + onMouseLeave={onPreviewEnd} + > {isExcluded ? : isIncluded ? null : icon} @@ -57,12 +129,27 @@ export const createFilterLink = ({ {isExcluded && (excluded)} - {isExcluded ? {count} : {count}} + {activeProjection ? ( + activeProjection.delta ? ( + + {valueText} + + ) : null + ) : ( + + {valueText} + + )} onPreviewStart('invert')} + onMouseEnter={() => onPreviewStart('invert')} + onMouseLeave={onPreviewEnd} > {isExcluded ? 'Include' : 'Exclude'}