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;
};