From b9d25d79f4a24c0024b8b768587d9cb33e4a1889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Aubert?= Date: Wed, 17 Jun 2026 17:03:31 +0200 Subject: [PATCH] ECHOES-1363 Implement Toolbar component --- i18n/keys.json | 8 + src/components/index.ts | 1 + src/components/toolbar/Toolbar.tsx | 241 ++++++++++++++++++ .../toolbar/__tests__/Toolbar-test.tsx | 197 ++++++++++++++ src/components/toolbar/index.ts | 23 ++ stories/toolbar/Toolbar-stories.tsx | 168 ++++++++++++ 6 files changed, 638 insertions(+) create mode 100644 src/components/toolbar/Toolbar.tsx create mode 100644 src/components/toolbar/__tests__/Toolbar-test.tsx create mode 100644 src/components/toolbar/index.ts create mode 100644 stories/toolbar/Toolbar-stories.tsx diff --git a/i18n/keys.json b/i18n/keys.json index f5b3ae38..2414a7d9 100644 --- a/i18n/keys.json +++ b/i18n/keys.json @@ -262,5 +262,13 @@ "toggletip.help": { "defaultMessage": "More information", "description": "aria-label text and tooltip for the ToggleTip" + }, + "toolbar.clear-all": { + "defaultMessage": "Clear filters", + "description": "Label for the button that clears all active filters in the toolbar" + }, + "toolbar.filter-tags-count": { + "defaultMessage": "Applied filters ({count})", + "description": "Label shown before active filter tags, indicating how many are applied" } } diff --git a/src/components/index.ts b/src/components/index.ts index 738cfa33..7d75ea78 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -55,5 +55,6 @@ export * from './text-area'; export * from './text-input'; export * from './toggle-button-group'; export * from './toggle-tip'; +export * from './toolbar'; export * from './tooltip'; export * from './typography'; diff --git a/src/components/toolbar/Toolbar.tsx b/src/components/toolbar/Toolbar.tsx new file mode 100644 index 00000000..f8c03e18 --- /dev/null +++ b/src/components/toolbar/Toolbar.tsx @@ -0,0 +1,241 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import styled from '@emotion/styled'; +import { ReactNode, Ref, useId } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { isDefined } from '~common/helpers/types'; +import { cssVar } from '~utils/design-tokens'; +import { Button, ButtonSize, ButtonVariety } from '../buttons'; +import { Text } from '../typography'; + +export interface ToolbarProps { + /** + * Accessible label for the toolbar group. + * Example: `"Issues filters and sorting"`. + */ + ariaLabel?: string; + + /** + * Optional CSS class name applied to the root element. + */ + className?: string; + + /** + * Content displayed at the trailing end of the top row (e.g. action buttons, item count). + * Automatically pushed to the right. + */ + datasetControls?: ReactNode; + + /** + * Filter controls (e.g., filter dropdowns) displayed after the search input. + */ + filterControls?: ReactNode; + + /** + * Array of active filter tags displayed in the applied-filters row. + * An empty array hides the tags and shows `labelEmptyFilterTags` instead (when configured). + * An array of FilterTag components is expected here. + */ + filterTags?: ReactNode[]; + + /** + * Accessible label for the "Clear filters" button. + * @defaultValue "Clear filters" + */ + labelClearAll?: string; + + /** + * Text displayed in the applied-filters row when `filterControls` is defined but + * `filterTags` is empty or not provided. + * @defaultValue not displayed + */ + labelEmptyFilterTags?: string; + + /** + * Static label displayed before the active filter tags, e.g. `"Applied filters (3)"`. + * Only shown when `filterTags` is non-empty. This is a static string — to include the + * active count, build it from your `filterTags` length before passing it. + * @defaultValue `"Applied filters ({count})"` where `{count}` is `filterTags.length` + */ + labelFilterTags?: string; + + /** + * Called when the user clicks the "Clear filters" button. + * The button is only shown when this is provided and `filterTags` is non-empty. + */ + onClearAll?: () => void; + + /** React ref forwarded to the root element. */ + ref?: Ref; + + /** + * Search input displayed after the selection checkbox in the top row. + */ + searchInput?: ReactNode; + + /** + * Select-all checkbox displayed at the very start of the top row. + */ + selectAllControl?: ReactNode; + + /** + * Sorting control displayed after the filter controls. + */ + sortControls?: ReactNode; +} + +/** + * Toolbar is a horizontal container implementing the filtering pattern. + * It displays filter controls in a top row and active FilterTag components in a second row. + */ +export function Toolbar(props: Readonly) { + const { + ariaLabel, + className, + datasetControls, + filterTags = [], + filterControls, + labelClearAll, + labelEmptyFilterTags, + labelFilterTags, + onClearAll, + ref, + searchInput, + selectAllControl, + sortControls, + ...restProps + } = props; + + const filterTagsLabelId = useId(); + const hasActiveTags = filterTags.length > 0; + const hasEmptyFilterTagsLabel = isDefined(labelEmptyFilterTags) && isDefined(filterControls); + + return ( + + + {selectAllControl} + {isDefined(searchInput) && {searchInput}} + {filterControls} + {sortControls} + {isDefined(datasetControls) && ( + {datasetControls} + )} + + + {hasActiveTags && ( + + + {labelFilterTags ?? ( + + )} + + + {filterTags} + + {isDefined(onClearAll) && ( + + )} + + )} + {!hasActiveTags && hasEmptyFilterTagsLabel && ( + {labelEmptyFilterTags} + )} + + ); +} + +Toolbar.displayName = 'Toolbar'; + +const ToolbarWrapper = styled.div` + display: flex; + flex-direction: column; + gap: ${cssVar('dimension-space-150')}; + margin-bottom: ${cssVar('dimension-space-200')}; + width: 100%; +`; + +ToolbarWrapper.displayName = 'ToolbarWrapper'; + +const ToolbarRow = styled.div` + display: flex; + align-items: center; + flex-wrap: wrap; + gap: ${cssVar('dimension-space-100')}; + width: 100%; +`; + +ToolbarRow.displayName = 'ToolbarRow'; + +const ToolbarSearchWrapper = styled.div` + flex: 0 0 auto; + min-width: 0; + + // Only a full-width search input should stretch to fill the available space. + // SearchInput exposes its width mode through the data-width attribute. + &:has([data-width='full']) { + flex: 1 1 0; + // 40% is a layout heuristic — no design token exists for this proportional cap. + max-width: 40%; + min-width: ${cssVar('sizes-form-field-medium')}; + } +`; + +ToolbarSearchWrapper.displayName = 'ToolbarSearchWrapper'; + +const ToolbarDatasetControlsWrapper = styled.div` + display: flex; + align-items: center; + gap: ${cssVar('dimension-space-100')}; + margin-left: auto; +`; + +ToolbarDatasetControlsWrapper.displayName = 'ToolbarDatasetControlsWrapper'; + +const ToolbarFilterLabel = styled(Text)` + padding-top: ${cssVar('dimension-space-75')}; + padding-bottom: ${cssVar('dimension-space-75')}; +`; + +ToolbarFilterLabel.displayName = 'ToolbarFilterLabel'; diff --git a/src/components/toolbar/__tests__/Toolbar-test.tsx b/src/components/toolbar/__tests__/Toolbar-test.tsx new file mode 100644 index 00000000..586d8498 --- /dev/null +++ b/src/components/toolbar/__tests__/Toolbar-test.tsx @@ -0,0 +1,197 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { screen } from '@testing-library/react'; +import { render } from '~common/helpers/test-utils'; +import { FilterTag } from '../../filters/FilterTag'; +import { Toolbar, ToolbarProps } from '../Toolbar'; + +describe('Toolbar', () => { + it('renders search, filters, sorting, and datasetControls in the top row', async () => { + const { container } = renderToolbar({ + datasetControls: , + filterControls: , + sortControls: , + }); + + expect(screen.getByRole('searchbox')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Severity' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sort' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Export' })).toBeInTheDocument(); + await expect(container).toHaveNoA11yViolations(); + }); + + it('renders the applied-filters row with filter tags when filterTags is provided', async () => { + const onClearAll = jest.fn(); + const { container, user } = renderToolbar({ + filterTags: [ + + Severity + , + ], + onClearAll, + }); + + expect(screen.getByText('Severity')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Clear filters' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Clear filters' })); + expect(onClearAll).toHaveBeenCalledTimes(1); + await expect(container).toHaveNoA11yViolations(); + }); + + it('does not render the "Clear filters" button when onClearAll is not provided', () => { + renderToolbar({ + filterTags: [ + + Severity + , + ], + }); + + expect(screen.queryByRole('button', { name: 'Clear filters' })).not.toBeInTheDocument(); + }); + + it('does not render the "Clear filters" button when filterTags is empty', () => { + renderToolbar({ + filterTags: [], + onClearAll: jest.fn(), + }); + + expect(screen.queryByRole('button', { name: 'Clear filters' })).not.toBeInTheDocument(); + }); + + it('activates "Clear filters" via keyboard', async () => { + const onClearAll = jest.fn(); + const { user } = renderToolbar({ + filterTags: [ + + Severity + , + ], + onClearAll, + }); + + await user.tab(); // search input + await user.tab(); // FilterTag dismiss button + await user.tab(); // "Clear filters" button + await user.keyboard('{Enter}'); + + expect(onClearAll).toHaveBeenCalledTimes(1); + }); + + it('uses custom labelClearAll', () => { + renderToolbar({ + filterTags: [ + + Severity + , + ], + labelClearAll: 'Reset', + onClearAll: jest.fn(), + }); + + expect(screen.getByRole('button', { name: 'Reset' })).toBeInTheDocument(); + }); + + it('shows default labelFilterTags when filterTags is non-empty', async () => { + const { container } = renderToolbar({ + filterTags: [ + + Severity + , + ], + }); + + expect(screen.getByText('Applied filters (1)')).toBeInTheDocument(); + await expect(container).toHaveNoA11yViolations(); + }); + + it('shows a custom static labelFilterTags when filterTags is non-empty', () => { + renderToolbar({ + filterTags: [ + + Severity + , + ], + labelFilterTags: 'Active filters (1)', + }); + + expect(screen.getByText('Active filters (1)')).toBeInTheDocument(); + }); + + it('renders a group role with ariaLabel on the root element', async () => { + const { container } = renderToolbar({ ariaLabel: 'Issue filters' }); + + expect(screen.getByRole('group', { name: 'Issue filters' })).toBeInTheDocument(); + await expect(container).toHaveNoA11yViolations(); + }); + + it('does not render labelFilterTags when filterTags is empty', () => { + renderToolbar({ + filterTags: [], + filterControls: , + labelEmptyFilterTags: 'No filters applied', + labelFilterTags: 'Applied filters', + }); + + expect(screen.queryByText(/Applied filters/)).not.toBeInTheDocument(); + }); + + it('shows labelEmptyFilterTags when filterControls is defined and filterTags is empty', async () => { + const { container } = renderToolbar({ + filterControls: , + filterTags: [], + labelEmptyFilterTags: 'No filters applied', + }); + + expect(screen.getByText('No filters applied')).toBeInTheDocument(); + await expect(container).toHaveNoA11yViolations(); + }); + + it('does not render labelEmptyFilterTags when filterTags is non-empty', () => { + renderToolbar({ + filterControls: , + filterTags: [ + + Severity + , + ], + labelEmptyFilterTags: 'No filters applied', + }); + + expect(screen.queryByText('No filters applied')).not.toBeInTheDocument(); + }); + + it('does not render labelEmptyFilterTags when filterControls is not defined', () => { + renderToolbar({ filterTags: [], labelEmptyFilterTags: 'No filters applied' }); + + expect(screen.queryByText('No filters applied')).not.toBeInTheDocument(); + }); +}); + +function renderToolbar(props: Partial = {}) { + return render( + } + {...props} + />, + ); +} diff --git a/src/components/toolbar/index.ts b/src/components/toolbar/index.ts new file mode 100644 index 00000000..a3fee394 --- /dev/null +++ b/src/components/toolbar/index.ts @@ -0,0 +1,23 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +export { Toolbar } from './Toolbar'; + +export type { ToolbarProps } from './Toolbar'; diff --git a/stories/toolbar/Toolbar-stories.tsx b/stories/toolbar/Toolbar-stories.tsx new file mode 100644 index 00000000..27a31add --- /dev/null +++ b/stories/toolbar/Toolbar-stories.tsx @@ -0,0 +1,168 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { + Button, + Checkbox, + DropdownMenu, + FilterTag, + IconChevronDown, + IconFilter, + SearchInput, + Text, + Toolbar, + ToolbarProps, +} from '../../src'; +import { basicWrapperDecorator } from '../helpers/BasicWrapper'; + +const meta: Meta = { + argTypes: { + datasetControls: { control: 'boolean' }, + filterControls: { control: 'boolean' }, + filterTags: { control: false }, + onClearAll: { control: 'boolean' }, + ref: { control: false }, + searchInput: { control: 'boolean' }, + selectAllControl: { control: 'boolean' }, + sortControls: { control: 'boolean' }, + }, + component: Toolbar, + decorators: [basicWrapperDecorator], + title: 'Echoes/Toolbar', +}; + +export default meta; + +type Story = StoryObj; + +const SEVERITIES = ['High', 'Medium', 'Low']; +const SORT_OPTIONS = ['Last updated', 'Name', 'Severity']; + +function DefaultStory(props: Readonly) { + const { + datasetControls, + filterControls, + onClearAll, + searchInput, + selectAllControl, + sortControls, + ...toolbarProps + } = props; + + const [search, setSearch] = useState(''); + const [selectAll, setSelectAll] = useState('indeterminate'); + const [selectedSeverities, setSelectedSeverities] = useState>(new Set(['High'])); + const [currentSort, setCurrentSort] = useState('Last updated'); + + function toggleSeverity(severity: string) { + setSelectedSeverities((prev) => { + const next = new Set(prev); + if (next.has(severity)) { + next.delete(severity); + } else { + next.add(severity); + } + return next; + }); + } + + return ( + + 4 projects + + + ) : undefined + } + filterControls={ + filterControls ? ( + ( + toggleSeverity(severity)}> + {severity} + + ))}> + + + ) : undefined + } + filterTags={[...selectedSeverities].map((severity) => ( + toggleSeverity(severity)}> + {`Severity: ${severity}`} + + ))} + onClearAll={onClearAll ? () => setSelectedSeverities(new Set()) : undefined} + searchInput={ + searchInput ? ( + + ) : undefined + } + selectAllControl={ + selectAllControl ? ( + setSelectAll(checked)} + /> + ) : undefined + } + sortControls={ + sortControls ? ( + ( + setCurrentSort(option)}> + {option} + + ))}> + + + ) : undefined + } + /> + ); +} + +export const Default: Story = { + args: { + ariaLabel: 'Issue filters and actions', + datasetControls: true, + filterControls: true, + labelClearAll: 'Clear filters', + labelEmptyFilterTags: 'No filters applied', + onClearAll: true as unknown as () => void, + searchInput: true, + selectAllControl: true, + sortControls: true, + }, + render: (args) => , +};