From a085bdeea50bbaec8969fd2863c6dccc61ee27a6 Mon Sep 17 00:00:00 2001 From: Michal Murawski Date: Wed, 5 Aug 2026 12:40:06 +0200 Subject: [PATCH] feat(design-system): adjust DsTagFilter to new design [AR-77549] --- .changeset/neat-mails-joke.md | 5 + .../ds-table-column-filters.browser.test.tsx | 1 - .../__tests__/ds-tag-filter.browser.test.tsx | 140 ++++++++ .../ds-tag-filter/ds-tag-filter.module.scss | 42 +-- .../ds-tag-filter/ds-tag-filter.stories.tsx | 298 +----------------- .../ds-tag-filter/ds-tag-filter.tsx | 91 ++---- .../ds-tag-filter/ds-tag-filter.types.ts | 3 +- .../hooks/use-tag-overflow-calculation.ts | 32 +- 8 files changed, 208 insertions(+), 404 deletions(-) create mode 100644 .changeset/neat-mails-joke.md create mode 100644 packages/design-system/src/components/ds-tag-filter/__tests__/ds-tag-filter.browser.test.tsx diff --git a/.changeset/neat-mails-joke.md b/.changeset/neat-mails-joke.md new file mode 100644 index 000000000..d14af2770 --- /dev/null +++ b/.changeset/neat-mails-joke.md @@ -0,0 +1,5 @@ +--- +'@drivenets/design-system': patch +--- + +Change `DsTagFilter` to match new design. diff --git a/packages/design-system/src/components/ds-table/__tests__/ds-table-column-filters.browser.test.tsx b/packages/design-system/src/components/ds-table/__tests__/ds-table-column-filters.browser.test.tsx index 9e85f8501..cf175069a 100644 --- a/packages/design-system/src/components/ds-table/__tests__/ds-table-column-filters.browser.test.tsx +++ b/packages/design-system/src/components/ds-table/__tests__/ds-table-column-filters.browser.test.tsx @@ -81,7 +81,6 @@ describe('DsTable - column filters', () => { expect(getDataRows()).toHaveLength(4); - await expect.element(page.getByText(/Filtered by/i)).toBeVisible(); await expect.element(page.getByRole('button', { name: 'Type: PP-LGX' })).toBeVisible(); await expect.element(page.getByRole('button', { name: 'Type: ME10' })).toBeVisible(); }); diff --git a/packages/design-system/src/components/ds-tag-filter/__tests__/ds-tag-filter.browser.test.tsx b/packages/design-system/src/components/ds-tag-filter/__tests__/ds-tag-filter.browser.test.tsx new file mode 100644 index 000000000..86dd63a68 --- /dev/null +++ b/packages/design-system/src/components/ds-tag-filter/__tests__/ds-tag-filter.browser.test.tsx @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from 'vitest'; +import { page } from 'vitest/browser'; +import DsTagFilter from '../ds-tag-filter'; +import type { TagFilterItem } from '../ds-tag-filter.types'; + +const fewFilters: TagFilterItem[] = [ + { id: '1', label: 'Status: Active' }, + { id: '2', label: 'Version: 1.0.0' }, + { id: '3', label: 'Author: John Doe' }, +]; + +const manyFilters: TagFilterItem[] = [ + { id: '1', label: 'Status: Active' }, + { id: '2', label: 'Running: From 100 to 10,000' }, + { id: '3', label: 'Completed from 20,000 to 100,000' }, + { id: '4', label: 'Executor: Category 1, Layer 1 transporter' }, + { id: '5', label: 'Executor: Category 2, Layer 11 transporter' }, + { id: '6', label: 'Executor: Category 2, Layer 12 transporter' }, + { id: '7', label: 'Executor: Category 2, Layer 13 transporter' }, + { id: '8', label: 'Version: 000.0001-3' }, + { id: '9', label: 'Version: 000.0001-4' }, + { id: '10', label: 'Version: 000.0001-5' }, + { id: '11', label: 'Version: 000.0001-6' }, + { id: '12', label: 'Last editor: Kevin Levin' }, + { id: '13', label: 'Last editor: Emery Dance' }, +]; + +// Constrains the row so `manyFilters` overflows the first line deterministically. +const Narrow = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +describe('DsTagFilter', () => { + it('renders nothing when there are no items', async () => { + await page.render(); + + expect(document.querySelector('[aria-live="polite"]')).toBeNull(); + expect(page.getByRole('button').query()).toBeNull(); + }); + + it('never renders the deprecated locale.label', async () => { + await page.render( + , + ); + + await expect.element(page.getByRole('button', { name: 'Status: Active' })).toBeInTheDocument(); + expect(page.getByText('Filtered by:').query()).toBeNull(); + }); + + it('wraps the tags in an aria-live="polite" region', async () => { + await page.render(); + + const tag = page.getByRole('button', { name: 'Status: Active' }); + await expect.element(tag).toBeInTheDocument(); + expect(tag.element().closest('[aria-live="polite"]')).not.toBeNull(); + }); + + it('calls onItemSelect with the clicked item', async () => { + const onItemSelect = vi.fn(); + await page.render(); + + await page.getByRole('button', { name: 'Status: Active' }).click(); + + expect(onItemSelect).toHaveBeenCalledWith(expect.objectContaining({ id: '1' })); + }); + + it('reflects a pre-selected item via aria-pressed', async () => { + await page.render( + , + ); + + await expect + .element(page.getByRole('button', { name: 'Status: Active' })) + .toHaveAttribute('aria-pressed', 'true'); + }); + + it('calls onItemDelete with the removed item', async () => { + const onItemDelete = vi.fn(); + await page.render(); + + const tag = page.getByRole('button', { name: 'Status: Active' }); + await tag.hover(); + + const deleteButton = tag.getByRole('button', { name: 'Delete tag' }); + await expect.element(deleteButton).toBeVisible(); + await deleteButton.click(); + + expect(onItemDelete).toHaveBeenCalledWith(expect.objectContaining({ id: '1' })); + }); + + it('does not render delete buttons when onItemDelete is omitted', async () => { + await page.render(); + + await expect.element(page.getByRole('button', { name: 'Status: Active' })).toBeInTheDocument(); + expect(page.getByRole('button', { name: 'Delete tag' }).query()).toBeNull(); + }); + + it('renders "Clear all filters" only when onClearAll is provided', async () => { + const { rerender } = await page.render(); + expect(page.getByRole('button', { name: /Clear all filters/ }).query()).toBeNull(); + + const onClearAll = vi.fn(); + await rerender(); + + await page.getByRole('button', { name: /Clear all filters/ }).click(); + expect(onClearAll).toHaveBeenCalledOnce(); + }); + + it('expands to reveal every tag and keeps a stable hidden count', async () => { + const onExpand = vi.fn(); + await page.render( + + + , + ); + + const showMore = page.getByRole('button', { name: /Show more \(\d+\)/ }); + await expect.element(showMore).toBeInTheDocument(); + await expect.element(showMore).toHaveAttribute('aria-expanded', 'false'); + + const collapsedText = showMore.element().textContent; + const hidden = /Show more \((\d+)\)/.exec(collapsedText)?.[1]; + expect(hidden).toBeDefined(); + expect(collapsedText.trim().endsWith('keyboard_arrow_down')).toBe(true); + + await showMore.click(); + expect(onExpand).toHaveBeenCalledWith(true); + + const showLess = page.getByRole('button', { name: new RegExp(`Show less \\(${hidden ?? ''}\\)`) }); + await expect.element(showLess).toBeInTheDocument(); + await expect.element(showLess).toHaveAttribute('aria-expanded', 'true'); + expect(showLess.element().textContent.trim().endsWith('keyboard_arrow_up')).toBe(true); + + // All tags — including the last one hidden while collapsed — are now visible. + await expect.element(page.getByRole('button', { name: 'Last editor: Emery Dance' })).toBeInTheDocument(); + + await showLess.click(); + expect(onExpand).toHaveBeenCalledWith(false); + }); +}); diff --git a/packages/design-system/src/components/ds-tag-filter/ds-tag-filter.module.scss b/packages/design-system/src/components/ds-tag-filter/ds-tag-filter.module.scss index 51c06ea44..348bb83f7 100644 --- a/packages/design-system/src/components/ds-tag-filter/ds-tag-filter.module.scss +++ b/packages/design-system/src/components/ds-tag-filter/ds-tag-filter.module.scss @@ -1,61 +1,33 @@ .container { display: flex; - flex-direction: column; + align-items: flex-start; gap: var(--xs); padding: var(--xs) var(--standard); background: var(--background-secondary-hover); border: 1px solid var(--outline-weak); border-radius: var(--3xs); overflow: hidden; - - &.expanded { - max-height: none; - } -} - -.header { - display: flex; - align-items: center; - width: 100%; - gap: var(--xs); - flex-shrink: 0; -} - -.headerActions { - display: flex; - align-items: center; - gap: var(--xs); - flex-shrink: 0; - margin-left: auto; } .tagsArea { display: flex; align-items: center; - width: 100%; + flex: 1; + min-width: 0; gap: var(--xs); flex-wrap: wrap; } -.label { - color: var(--font-secondary); - white-space: nowrap; - flex-shrink: 0; -} - -.itemCheckbox { +.actions { display: flex; align-items: center; -} - -.ghostButtonContent { - // TODO: remove once migrated 1.2 button to the ghost with transparent bg - background: transparent !important; + gap: var(--xs); + flex-shrink: 0; } .actionButton { - padding: 0; flex-shrink: 0; + white-space: nowrap; } .measurementContainer { diff --git a/packages/design-system/src/components/ds-tag-filter/ds-tag-filter.stories.tsx b/packages/design-system/src/components/ds-tag-filter/ds-tag-filter.stories.tsx index 462a84dca..d3919dd1f 100644 --- a/packages/design-system/src/components/ds-tag-filter/ds-tag-filter.stories.tsx +++ b/packages/design-system/src/components/ds-tag-filter/ds-tag-filter.stories.tsx @@ -1,6 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; -import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; import DsTagFilter from './ds-tag-filter'; import type { TagFilterItem } from './ds-tag-filter.types'; import styles from './ds-tag-filter.stories.module.scss'; @@ -13,7 +12,7 @@ const meta: Meta = { docs: { description: { component: - 'A component for displaying active filters as tags with overflow handling. Non-tag elements (label, expand/collapse, clear) sit in a header row. Tags wrap in a dedicated area below.', + 'Displays active filters as tags with overflow handling. Tags fill the left of a single row; the actions block (expand/collapse toggle, clear all) is pinned to the top-right and stays aligned with the first tag row when tags wrap.', }, }, }, @@ -121,57 +120,6 @@ export const Default: Story = { ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Wait for layout calculation to complete and tags to be rendered - await waitFor(async () => { - await expect(canvas.getByRole('button', { name: 'Status: Active' })).toBeInTheDocument(); - }); - - await expect(canvas.getByText('Filtered by:')).toBeInTheDocument(); - await expect(canvas.getByRole('button', { name: /Clear all filters/ })).toBeInTheDocument(); - - const firstTag = canvas.getByRole('button', { name: 'Status: Active' }); - await userEvent.click(firstTag); - - await waitFor(async () => { - await expect(firstTag).toHaveAttribute('aria-pressed', 'true'); - }); - - // Verify selection is reflected in the info text - await expect(canvas.getByText(/Selected filters:.*"Status: Active"/)).toBeInTheDocument(); - - // Click again to deselect - await userEvent.click(firstTag); - - await waitFor(async () => { - await expect(firstTag).not.toHaveAttribute('aria-pressed'); - }); - - firstTag.focus(); - - // Wait for delete button to become visible after focus - await waitFor(async () => { - await expect(canvas.getByRole('button', { name: 'Delete tag' })).toBeVisible(); - }); - - const deleteButton = canvas.getByRole('button', { name: 'Delete tag' }); - await userEvent.click(deleteButton); - - await waitFor(async () => { - await expect(canvas.queryByRole('button', { name: 'Status: Active' })).not.toBeInTheDocument(); - await expect(canvas.getByText('Total filters: 12')).toBeInTheDocument(); - }); - - // Test clear all functionality - const clearAllButton = canvas.getByRole('button', { name: /Clear all filters/ }); - await userEvent.click(clearAllButton); - - await waitFor(async () => { - await expect(canvas.getByText('Total filters: 0')).toBeInTheDocument(); - }); - }, }; /** @@ -207,45 +155,6 @@ export const FewFilters: Story = { /> ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Wait for layout calculation to complete and tags to be rendered - await waitFor(async () => { - await expect(canvas.getByRole('button', { name: 'Status: Active' })).toBeInTheDocument(); - }); - - // Verify all filters are visible - await expect(canvas.getByRole('button', { name: 'Version: 1.0.0' })).toBeInTheDocument(); - await expect(canvas.getByRole('button', { name: 'Author: John Doe' })).toBeInTheDocument(); - - // Test selection interaction - const statusTag = canvas.getByRole('button', { name: 'Status: Active' }); - await userEvent.click(statusTag); - - await waitFor(async () => { - await expect(statusTag).toHaveAttribute('aria-pressed', 'true'); - }); - - // Test deletion interaction - focus the tag to reveal delete button - statusTag.focus(); - - // Wait for delete button to become visible after focus - await waitFor(async () => { - await expect(canvas.getByRole('button', { name: 'Delete tag' })).toBeVisible(); - }); - - const deleteButton = canvas.getByRole('button', { name: 'Delete tag' }); - await userEvent.click(deleteButton); - - await waitFor(async () => { - await expect(canvas.queryByRole('button', { name: 'Status: Active' })).not.toBeInTheDocument(); - }); - - // Verify remaining filters - await expect(canvas.getByRole('button', { name: 'Version: 1.0.0' })).toBeInTheDocument(); - await expect(canvas.getByRole('button', { name: 'Author: John Doe' })).toBeInTheDocument(); - }, }; /** @@ -263,33 +172,6 @@ export const WithoutClearAll: Story = { // so the component hides the "Clear all" button. return ; }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Wait for layout calculation to complete and tags to be rendered - await waitFor(async () => { - await expect(canvas.getByRole('button', { name: 'Status: Active' })).toBeInTheDocument(); - }); - - // Verify "Clear all filters" button is NOT present - await expect(canvas.queryByRole('button', { name: /Clear all filters/ })).not.toBeInTheDocument(); - - // Verify deletion still works - focus the tag to reveal delete button - const firstTag = canvas.getByRole('button', { name: 'Status: Active' }); - firstTag.focus(); - - // Wait for delete button to become visible after focus - await waitFor(async () => { - await expect(canvas.getByRole('button', { name: 'Delete tag' })).toBeVisible(); - }); - - const deleteButton = canvas.getByRole('button', { name: 'Delete tag' }); - await userEvent.click(deleteButton); - - await waitFor(async () => { - await expect(canvas.queryByRole('button', { name: 'Status: Active' })).not.toBeInTheDocument(); - }); - }, }; /** @@ -308,33 +190,16 @@ export const ReadOnly: Story = { onClearAll={undefined} onItemDelete={undefined} onItemSelect={undefined} - locale={{ label: 'Applied filters:' }} /> ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Wait for layout calculation to complete and tags to be rendered - await waitFor(async () => { - await expect(canvas.getByText('Status: Active')).toBeInTheDocument(); - }); - - // Verify custom label is shown - await expect(canvas.getByText('Applied filters:')).toBeInTheDocument(); - - // Verify delete buttons are NOT visible (read-only) - await expect(canvas.queryByRole('button', { name: 'Delete tag' })).not.toBeInTheDocument(); - - // Verify "Clear all filters" button is NOT present - await expect(canvas.queryByRole('button', { name: /Clear all filters/ })).not.toBeInTheDocument(); - }, }; /** - * Story showing TagFilter without a label. + * Story documenting that `locale.label` is deprecated and never rendered. + * We removed the header label, so any label passed here has no visible effect. */ -export const WithoutLabel: Story = { +export const DeprecatedLabelIgnored: Story = { render: function Render(args) { const [filters, setFilters] = useState(sampleFilters.slice(0, 5)); @@ -350,46 +215,17 @@ export const WithoutLabel: Story = { ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Wait for layout calculation to complete and tags to be rendered - await waitFor(async () => { - await expect(canvas.getByRole('button', { name: 'Status: Active' })).toBeInTheDocument(); - }); - - // Verify "Filtered by:" label is NOT present - await expect(canvas.queryByText('Filtered by:')).not.toBeInTheDocument(); - - // Verify "Clear all filters" button is still present and works - await expect(canvas.getByRole('button', { name: /Clear all filters/ })).toBeInTheDocument(); - - // Verify deletion still works - focus the tag to reveal delete button - const firstTag = canvas.getByRole('button', { name: 'Status: Active' }); - firstTag.focus(); - - // Wait for delete button to become visible after focus - await waitFor(async () => { - await expect(canvas.getByRole('button', { name: 'Delete tag' })).toBeVisible(); - }); - - const deleteButton = canvas.getByRole('button', { name: 'Delete tag' }); - await userEvent.click(deleteButton); - - await waitFor(async () => { - await expect(canvas.queryByRole('button', { name: 'Status: Active' })).not.toBeInTheDocument(); - }); - }, }; /** - * Story demonstrating full locale customization with both label and clearButton. + * Story demonstrating locale customization of the action buttons (clear, show more, + * show less). `locale.label` is intentionally omitted — it is deprecated and unused. */ export const CustomLocale: Story = { render: function Render(args) { @@ -408,8 +244,6 @@ export const CustomLocale: Story = { {...args} items={filters} locale={{ - // cspell:disable-next-line - label: 'Aktywne filtry:', // cspell:disable-next-line clearButton: 'Zresetuj', // cspell:disable-next-line @@ -422,76 +256,15 @@ export const CustomLocale: Story = { /> ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Wait for layout calculation to complete and tags to be rendered - await waitFor(async () => { - await expect(canvas.getByRole('button', { name: 'Status: Active' })).toBeInTheDocument(); - }); - - // Verify custom label is rendered - // cspell:disable-next-line - await expect(canvas.getByText('Aktywne filtry:')).toBeInTheDocument(); - - // cspell:disable-next-line - await expect(canvas.getByRole('button', { name: /Zresetuj/ })).toBeInTheDocument(); - - // Verify custom showMore locale - // cspell:disable-next-line - await expect(canvas.getByRole('button', { name: /Pokaż więcej/ })).toBeInTheDocument(); - - await expect(canvas.queryByText('Filtered by:')).not.toBeInTheDocument(); - await expect(canvas.queryByRole('button', { name: /Clear all filters/ })).not.toBeInTheDocument(); - await expect(canvas.queryByRole('button', { name: /Show more/ })).not.toBeInTheDocument(); - }, }; /** - * Story testing the expand/collapse functionality and onExpand callback. + * Story demonstrating the expand/collapse toggle. The hidden count stays stable + * between `Show more (N)` and `Show less (N)`. */ export const ExpandCollapse: Story = { args: { items: sampleFilters, - onExpand: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - // Wait for layout calculation to complete and "Show more" button to appear - await waitFor(async () => { - await expect(canvas.getByRole('button', { name: /Show more \(\d+\)/ })).toBeInTheDocument(); - }); - - const expandButton = canvas.getByRole('button', { name: /Show more \(\d+\)/ }); - - // Click an expand button - await userEvent.click(expandButton); - - // Verify onExpand was called with true (expanded) - await waitFor(async () => { - await expect(args.onExpand).toHaveBeenCalledWith(true); - }); - - // Verify the button now shows "Show less" - await waitFor(async () => { - await expect(canvas.getByRole('button', { name: /Show less/ })).toBeInTheDocument(); - }); - - const collapseButton = canvas.getByRole('button', { name: /Show less/ }); - - // Click collapse button - await userEvent.click(collapseButton); - - // Verify onExpand was called with false (collapsed) - await waitFor(async () => { - await expect(args.onExpand).toHaveBeenCalledWith(false); - }); - - // Verify an expand button is back - await waitFor(async () => { - await expect(canvas.getByRole('button', { name: /Show more \(\d+\)/ })).toBeInTheDocument(); - }); }, }; @@ -519,16 +292,6 @@ export const SmallSize: Story = { ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await waitFor(async () => { - await expect(canvas.getByRole('button', { name: 'Status: Active' })).toBeInTheDocument(); - }); - - await expect(canvas.getByText('Filtered by:')).toBeInTheDocument(); - await expect(canvas.getByRole('button', { name: /Clear all filters/ })).toBeInTheDocument(); - }, }; /** @@ -566,44 +329,6 @@ export const WithPreSelectedItems: Story = { /> ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Wait for layout calculation to complete and tags to be rendered - await waitFor(async () => { - await expect(canvas.getByRole('button', { name: 'Status: Active' })).toBeInTheDocument(); - }); - - // Verify pre-selected tags have aria-pressed="true" - const activeTag = canvas.getByRole('button', { name: 'Status: Active' }); - const completedTag = canvas.getByRole('button', { name: 'Completed from 20,000 to 100,000' }); - const versionTag = canvas.getByRole('button', { name: 'Version: 1.0.0' }); - - await expect(activeTag).toHaveAttribute('aria-pressed', 'true'); - await expect(completedTag).toHaveAttribute('aria-pressed', 'true'); - await expect(versionTag).toHaveAttribute('aria-pressed', 'true'); - - // Verify non-selected tags do not have aria-pressed - const runningTag = canvas.getByRole('button', { name: 'Running: From 100 to 10,000' }); - const executorTag = canvas.getByRole('button', { name: 'Executor: Category 1' }); - - await expect(runningTag).not.toHaveAttribute('aria-pressed'); - await expect(executorTag).not.toHaveAttribute('aria-pressed'); - - // Test toggling selection on a pre-selected tag - await userEvent.click(activeTag); - - await waitFor(async () => { - await expect(activeTag).not.toHaveAttribute('aria-pressed'); - }); - - // Test toggling selection on a non-selected tag - await userEvent.click(runningTag); - - await waitFor(async () => { - await expect(runningTag).toHaveAttribute('aria-pressed', 'true'); - }); - }, }; /** @@ -613,9 +338,4 @@ export const EmptyState: Story = { args: { items: [], }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await expect(canvas.queryByText('Filtered by:')).not.toBeInTheDocument(); - }, }; diff --git a/packages/design-system/src/components/ds-tag-filter/ds-tag-filter.tsx b/packages/design-system/src/components/ds-tag-filter/ds-tag-filter.tsx index 69acb12d4..93ebc28a5 100644 --- a/packages/design-system/src/components/ds-tag-filter/ds-tag-filter.tsx +++ b/packages/design-system/src/components/ds-tag-filter/ds-tag-filter.tsx @@ -4,17 +4,12 @@ import classNames from 'classnames'; import styles from './ds-tag-filter.module.scss'; import type { DsTagFilterProps, TagFilterItem } from './ds-tag-filter.types'; import { useTagOverflowCalculation } from './hooks/use-tag-overflow-calculation'; -import { DsTypography } from '../ds-typography'; import { DsTag, type DsTagProps } from '../ds-tag'; -import { DsButton } from '../ds-button'; +import { DsButtonV3 } from '../ds-button-v3'; import { DsIcon } from '../ds-icon'; /** * Design system TagFilter component - * - * A component for displaying active filters as tags with overflow handling. - * Non-tag elements (label, expand/collapse, clear) sit in a header row. - * Tags occupy the full width below, wrapping as needed. */ const DsTagFilter = ({ items, @@ -27,22 +22,17 @@ const DsTagFilter = ({ onExpand, }: DsTagFilterProps) => { const [expanded, setExpanded] = useState(false); - const containerRef = useRef(null); + const tagsAreaRef = useRef(null); const measurementRef = useRef(null); const { visibleTagCount, hasOverflow } = useTagOverflowCalculation({ - containerRef, + tagsAreaRef, measurementRef, totalItems: items.length, expanded, }); - const { - label = 'Filtered by:', - clearButton = 'Clear all filters', - showMore = 'Show more', - showLess = 'Show less', - } = locale; + const { clearButton = 'Clear all filters', showMore = 'Show more', showLess = 'Show less' } = locale; const visibleTags = expanded ? items : items.slice(0, visibleTagCount); const hiddenCount = items.length - visibleTagCount; @@ -79,54 +69,41 @@ const DsTagFilter = ({ ); + const toggleLabel = expanded ? showLess : showMore; + return ( <> -
-
- {label && ( - - {label} - - )} +
+
+ {visibleTags.map((item) => renderTag(item))} +
-
- {hasOverflow && ( - - - {expanded ? showLess : `${showMore} (${String(hiddenCount)})`} - - )} +
+ {hasOverflow && ( + + {`${toggleLabel} (${String(hiddenCount)})`} + + + )} - {onClearAll && ( - - - {clearButton} - - )} -
+ {onClearAll && ( + + {clearButton} + + )}
- -
{visibleTags.map((item) => renderTag(item))}
{createPortal(measurementContainer, document.body)} diff --git a/packages/design-system/src/components/ds-tag-filter/ds-tag-filter.types.ts b/packages/design-system/src/components/ds-tag-filter/ds-tag-filter.types.ts index 436389486..b0f555a03 100644 --- a/packages/design-system/src/components/ds-tag-filter/ds-tag-filter.types.ts +++ b/packages/design-system/src/components/ds-tag-filter/ds-tag-filter.types.ts @@ -40,7 +40,8 @@ export interface DsTagFilterProps { */ locale?: { /** - * Heading label shown above the tag list (e.g., "Filters"). + * @deprecated No longer rendered. Retained for backwards compatibility; + * Will be removed in the future. */ label?: string; /** diff --git a/packages/design-system/src/components/ds-tag-filter/hooks/use-tag-overflow-calculation.ts b/packages/design-system/src/components/ds-tag-filter/hooks/use-tag-overflow-calculation.ts index 0d66bc818..f6cb7aff5 100644 --- a/packages/design-system/src/components/ds-tag-filter/hooks/use-tag-overflow-calculation.ts +++ b/packages/design-system/src/components/ds-tag-filter/hooks/use-tag-overflow-calculation.ts @@ -2,7 +2,7 @@ import { type RefObject, useCallback, useLayoutEffect, useState } from 'react'; import { fitTagsInRow, getContainerAvailableWidth, getElementMeasurements } from '../utils'; interface UseTagOverflowCalculationOptions { - containerRef: RefObject; + tagsAreaRef: RefObject; measurementRef: RefObject; totalItems: number; expanded: boolean; @@ -14,13 +14,10 @@ interface UseTagOverflowCalculationResult { } /** - * Custom hook to calculate how many tags fit in the available container width. - * - * Tags occupy the full container width. Non-tag elements (label, clear button, - * expand control) live in a separate header row and don't affect the calculation. + * Custom hook to calculate how many tags fit on the first row of the tags-area. */ export const useTagOverflowCalculation = ({ - containerRef, + tagsAreaRef, measurementRef, totalItems, expanded, @@ -31,11 +28,11 @@ export const useTagOverflowCalculation = ({ }); const calculateLayout = useCallback(() => { - if (!containerRef.current || !measurementRef.current) { + if (!tagsAreaRef.current || !measurementRef.current) { return; } - const container = containerRef.current; + const tagsArea = tagsAreaRef.current; const measurementContainer = measurementRef.current; const { tagWidths, gap } = getElementMeasurements(measurementContainer); @@ -45,13 +42,13 @@ export const useTagOverflowCalculation = ({ return; } - const containerWidth = getContainerAvailableWidth(container); + const availableWidth = getContainerAvailableWidth(tagsArea); - const { count } = fitTagsInRow(tagWidths, containerWidth, gap); + const { count } = fitTagsInRow(tagWidths, availableWidth, gap); const hasOverflow = count < tagWidths.length; setState({ visibleTagCount: count, hasOverflow }); - }, [containerRef, measurementRef]); + }, [tagsAreaRef, measurementRef]); useLayoutEffect(() => { const rafId = requestAnimationFrame(() => { @@ -64,22 +61,15 @@ export const useTagOverflowCalculation = ({ }); }); - if (containerRef.current) { - resizeObserver.observe(containerRef.current); + if (tagsAreaRef.current) { + resizeObserver.observe(tagsAreaRef.current); } return () => { cancelAnimationFrame(rafId); resizeObserver.disconnect(); }; - }, [containerRef, measurementRef, totalItems, expanded, calculateLayout]); - - if (expanded) { - return { - visibleTagCount: totalItems, - hasOverflow: true, - }; - } + }, [tagsAreaRef, measurementRef, totalItems, expanded, calculateLayout]); return state; };