diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts
index 0f4d2f42..237079e6 100644
--- a/.storybook/components/Roadmap/data.ts
+++ b/.storybook/components/Roadmap/data.ts
@@ -332,4 +332,10 @@ export const rows: Rows = [
stage: '🔵 experimental',
planned: 'Q2 2026',
},
+ {
+ component: 'TagList',
+ status: '✅ Done',
+ stage: '🔵 experimental',
+ planned: 'Q2 2026',
+ },
];
diff --git a/packages/components/src/components/Form/Form.stories.tsx b/packages/components/src/components/Form/Form.stories.tsx
index 5c230109..43f92765 100644
--- a/packages/components/src/components/Form/Form.stories.tsx
+++ b/packages/components/src/components/Form/Form.stories.tsx
@@ -1,4 +1,4 @@
-import { type FormEvent, useState } from 'react';
+import { type FormEvent, useRef, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
@@ -10,12 +10,14 @@ import { CheckboxGroup } from '../CheckboxGroup';
import { DatePicker } from '../DatePicker';
import { FlexBox } from '../FlexBox';
import { FormField } from '../FormField';
+import { useListData } from '../index';
import { Input } from '../Input';
import { InputNumber } from '../InputNumber';
import { spacing } from '../layout';
import { Radio, RadioGroup } from '../RadioGroup';
import { SearchInput } from '../SearchInput';
import { Select } from '../Select';
+import { TagInput } from '../TagInput';
import { Textarea } from '../Textarea';
import { TimePicker } from '../TimePicker';
import { Typography } from '../Typography';
@@ -426,6 +428,25 @@ export const FormFields: Story = {
},
name: 'All form fields',
render: function Render() {
+ const tags = useListData<{ id: string; name: string }>({
+ initialItems: [{ id: 'react', name: 'React' }],
+ });
+
+ const tagCounter = useRef(1);
+
+ const addTags = (values: string[]) => {
+ tags.append(
+ ...values.map((name) => {
+ tagCounter.current += 1;
+
+ return {
+ id: `tag-${tagCounter.current}-${name}`,
+ name,
+ };
+ })
+ );
+ };
+
return (
`, `TagAutocomplete` inherits `isDisabled`,
+`isReadOnly`, `labelPlacement`, and `labelAlign` from the form context.
+
+
+
+## Accessibility
+
+- The text input uses `role="combobox"` with `aria-autocomplete="list"`.
+- When the popover is open, the input points to the listbox via
+ `aria-controls` and tracks the focused option with `aria-activedescendant`.
+- DOM focus stays on the input while arrow keys move virtual focus through
+ suggestions.
+- Selected tags keep the same grid semantics as `TagInput`.
+
+## Keyboard
+
+Inside the text input:
+
+| Key | Behavior |
+| -------------------- | -------------------------------------------------------------------------------- |
+| ArrowDown | Open suggestions and move virtual focus to the first option. |
+| ArrowUp | Open suggestions and move virtual focus to the last option. |
+| Enter | Select the focused suggestion, or commit the current input value as a new tag. |
+| Escape | Close the suggestion popover. |
+| Backspace | If the input is empty and there are tags, focus the last tag. |
+| _split character_ | Commit and reset the input — same effect as Enter for free-form text. |
+| Tab | Move focus to the cleaner button (if visible) or out of the field. |
diff --git a/packages/components/src/components/TagAutocomplete/TagAutocomplete.module.css b/packages/components/src/components/TagAutocomplete/TagAutocomplete.module.css
new file mode 100644
index 00000000..da18ac66
--- /dev/null
+++ b/packages/components/src/components/TagAutocomplete/TagAutocomplete.module.css
@@ -0,0 +1,18 @@
+.popover {
+ border-radius: var(--kbq-size-s);
+}
+
+.container {
+ display: flex;
+ flex-direction: column;
+ min-block-size: 0;
+}
+
+.list {
+ overflow-y: auto;
+ max-block-size: inherit;
+}
+
+.list:empty {
+ display: none;
+}
diff --git a/packages/components/src/components/TagAutocomplete/TagAutocomplete.stories.tsx b/packages/components/src/components/TagAutocomplete/TagAutocomplete.stories.tsx
new file mode 100644
index 00000000..e96f2d0e
--- /dev/null
+++ b/packages/components/src/components/TagAutocomplete/TagAutocomplete.stories.tsx
@@ -0,0 +1,341 @@
+import { useRef } from 'react';
+
+import { IconCircleInfo16, IconGridSquares16 } from '@koobiq/react-icons';
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { FlexBox } from '../FlexBox';
+import { Form } from '../Form';
+import { Button, useBreakpoints, useListData } from '../index';
+import { tagInputPropVariant } from '../TagInput';
+
+import { TagAutocomplete } from './index';
+import type { TagAutocompleteProps } from './types';
+
+type TagItem = { id: string; name: string };
+
+const defaultTags: TagItem[] = [
+ { id: 'react', name: 'React' },
+ { id: 'typescript', name: 'TypeScript' },
+];
+
+const technologySuggestions: TagItem[] = [
+ { id: 'react', name: 'React' },
+ { id: 'typescript', name: 'TypeScript' },
+ { id: 'storybook', name: 'Storybook' },
+ { id: 'vite', name: 'Vite' },
+ { id: 'vitest', name: 'Vitest' },
+ { id: 'playwright', name: 'Playwright' },
+];
+
+const categorySuggestions: TagItem[] = [
+ { id: 'news', name: 'News' },
+ { id: 'sports', name: 'Sports' },
+ { id: 'security', name: 'Security' },
+ { id: 'research', name: 'Research' },
+];
+
+const containsFilter = (textValue: string, inputValue: string) =>
+ textValue.toLocaleLowerCase().includes(inputValue.toLocaleLowerCase());
+
+const renderTag = (item: TagItem) => (
+
+ {item.name}
+
+);
+
+const renderListItem = (item: TagItem) => (
+
+ {item.name}
+
+);
+
+type ExampleTagAutocompleteProps = Omit<
+ TagAutocompleteProps,
+ 'items' | 'children' | 'onAdd' | 'onRemove' | 'listItems' | 'renderListItem'
+> & {
+ initialItems?: TagItem[];
+ suggestions?: TagItem[];
+ allowDuplicateFreeform?: boolean;
+};
+
+function ExampleTagAutocomplete(props: ExampleTagAutocompleteProps) {
+ const {
+ initialItems = defaultTags,
+ suggestions = technologySuggestions,
+ allowDuplicateFreeform = true,
+ defaultFilter = containsFilter,
+ style,
+ ...tagAutocompleteProps
+ } = props;
+
+ const { m } = useBreakpoints();
+
+ const counter = useRef(0);
+
+ const tags = useListData({
+ initialItems,
+ getKey: (item) => item.id,
+ });
+
+ const createTag = (name: string): TagItem => {
+ counter.current += 1;
+
+ return { id: `tag-${counter.current}-${name}`, name };
+ };
+
+ return (
+
+ label="Tags"
+ fullWidth
+ style={{ inlineSize: m ? 360 : 240, ...style }}
+ placeholder="Type or choose a tag"
+ items={tags.items}
+ onAdd={(values, context) => {
+ if (context.source === 'suggestion') {
+ tags.append(context.suggestion);
+
+ return;
+ }
+
+ let nextValues = values;
+
+ if (!allowDuplicateFreeform) {
+ const existing = new Set(
+ tags.items.map((item) => item.name.toLocaleLowerCase())
+ );
+
+ nextValues = values.filter(
+ (value) => !existing.has(value.toLocaleLowerCase())
+ );
+ }
+
+ if (nextValues.length === 0) return;
+
+ tags.append(...nextValues.map(createTag));
+ }}
+ onRemove={(keys) => tags.remove(...keys)}
+ listItems={suggestions}
+ renderListItem={renderListItem}
+ defaultFilter={defaultFilter}
+ {...tagAutocompleteProps}
+ >
+ {renderTag}
+
+ );
+}
+
+const meta = {
+ title: 'Components/TagAutocomplete',
+ component: TagAutocomplete,
+ subcomponents: {
+ 'TagAutocomplete.ListItem': TagAutocomplete.ListItem,
+ 'TagAutocomplete.Tag': TagAutocomplete.Tag,
+ },
+ parameters: { layout: 'centered' },
+ tags: ['status:new', 'date:2026-06-01'],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj>;
+
+export const Base: Story = {
+ render: function Render(args) {
+ return ;
+ },
+};
+
+export const Variant: Story = {
+ render: function Render() {
+ const { m } = useBreakpoints();
+
+ return (
+
+ {tagInputPropVariant.map((variant) => (
+
+ ))}
+
+ );
+ },
+};
+
+export const FormField: Story = {
+ render: function Render() {
+ const { m } = useBreakpoints();
+
+ const categories = useListData({
+ initialItems: [
+ { id: 'news', name: 'News' },
+ { id: 'sports', name: 'Sports' },
+ ],
+ getKey: (item) => item.id,
+ });
+
+ const tags = useListData({ initialItems: [] });
+ const categoryCounter = useRef(0);
+ const tagCounter = useRef(0);
+
+ const createCategory = (name: string): TagItem => {
+ categoryCounter.current += 1;
+
+ return { id: `category-${categoryCounter.current}-${name}`, name };
+ };
+
+ const createTag = (name: string): TagItem => {
+ tagCounter.current += 1;
+
+ return { id: `tag-${tagCounter.current}-${name}`, name };
+ };
+
+ const isEmpty = tags.items.length === 0;
+
+ return (
+
+
+ label="Categories"
+ items={categories.items}
+ placeholder="Add a category"
+ caption="Choose a suggestion or press Enter"
+ onRemove={(keys) => categories.remove(...keys)}
+ onAdd={(values, context) => {
+ if (context.source === 'suggestion') {
+ categories.append(context.suggestion);
+ } else {
+ categories.append(...values.map(createCategory));
+ }
+ }}
+ listItems={categorySuggestions}
+ renderListItem={renderListItem}
+ defaultFilter={containsFilter}
+ fullWidth
+ isRequired
+ >
+ {renderTag}
+
+
+ label="Tags"
+ items={tags.items}
+ isInvalid={isEmpty}
+ placeholder="Add at least one tag"
+ caption={isEmpty ? undefined : 'Looks good'}
+ onRemove={(keys) => tags.remove(...keys)}
+ onAdd={(values, context) => {
+ if (context.source === 'suggestion') {
+ tags.append(context.suggestion);
+ } else {
+ tags.append(...values.map(createTag));
+ }
+ }}
+ listItems={technologySuggestions}
+ renderListItem={renderListItem}
+ defaultFilter={containsFilter}
+ errorMessage={isEmpty ? 'At least one tag is required' : undefined}
+ fullWidth
+ >
+ {renderTag}
+
+
+ );
+ },
+};
+
+export const SplitPattern: Story = {
+ render: function Render() {
+ return (
+
+ );
+ },
+};
+
+export const PreventDuplicates: Story = {
+ render: function Render() {
+ return (
+
+ );
+ },
+};
+
+export const Disabled: Story = {
+ render: function Render() {
+ return ;
+ },
+};
+
+export const ReadOnly: Story = {
+ render: function Render() {
+ return ;
+ },
+};
+
+export const Clearable: Story = {
+ render: function Render() {
+ return (
+
+ );
+ },
+};
+
+export const Addons: Story = {
+ render: function Render() {
+ return (
+ }
+ endAddon={ }
+ />
+ );
+ },
+};
+
+export const MultiPick: Story = {
+ render: function Render() {
+ return (
+
+ );
+ },
+};
+
+export const InsideForm: Story = {
+ render: function Render() {
+ const { m } = useBreakpoints();
+
+ return (
+
+
+
+ Submit
+
+
+ );
+ },
+};
diff --git a/packages/components/src/components/TagAutocomplete/TagAutocomplete.test.tsx b/packages/components/src/components/TagAutocomplete/TagAutocomplete.test.tsx
new file mode 100644
index 00000000..a55f23ad
--- /dev/null
+++ b/packages/components/src/components/TagAutocomplete/TagAutocomplete.test.tsx
@@ -0,0 +1,650 @@
+import { useRef } from 'react';
+import type { ReactNode } from 'react';
+
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+import { useListData } from '../index';
+import type { TagInputAddContext } from '../TagInput';
+
+import { TagAutocomplete } from './index';
+
+type TagItem = { id: string; name: string };
+
+type HarnessProps = {
+ initialTags?: TagItem[];
+ suggestions?: TagItem[];
+ startAddon?: ReactNode;
+ endAddon?: ReactNode;
+ isReadOnly?: boolean;
+ allowsEmptyCollection?: boolean;
+ disableCloseOnSelect?: boolean;
+ defaultFilter?: (textValue: string, inputValue: string) => boolean;
+ onAdd?: (values: string[], ctx: TagInputAddContext) => void;
+};
+
+function Harness(props: HarnessProps) {
+ const {
+ initialTags = [],
+ suggestions = [
+ { id: 'react', name: 'React' },
+ { id: 'typescript', name: 'TypeScript' },
+ { id: 'storybook', name: 'Storybook' },
+ ],
+ startAddon,
+ endAddon,
+ isReadOnly,
+ allowsEmptyCollection,
+ disableCloseOnSelect,
+ defaultFilter,
+ onAdd: userOnAdd,
+ } = props;
+
+ const tags = useListData({
+ initialItems: initialTags,
+ getKey: (item) => item.id,
+ });
+
+ const counter = useRef(0);
+
+ const newTag = (name: string): TagItem => {
+ counter.current += 1;
+
+ return { id: `t-${counter.current}-${name}`, name };
+ };
+
+ return (
+
+ aria-label="Tags"
+ startAddon={startAddon}
+ endAddon={endAddon}
+ slotProps={{ input: { 'data-testid': 'input' } }}
+ items={tags.items}
+ onAdd={(values, ctx) => {
+ if (ctx.source === 'suggestion') {
+ tags.append(ctx.suggestion);
+ } else {
+ tags.append(...values.map(newTag));
+ }
+
+ userOnAdd?.(values, ctx);
+ }}
+ onRemove={(keys) => tags.remove(...keys)}
+ listItems={suggestions}
+ isReadOnly={isReadOnly}
+ allowsEmptyCollection={allowsEmptyCollection}
+ disableCloseOnSelect={disableCloseOnSelect}
+ renderListItem={(item) => (
+
+ {item.name}
+
+ )}
+ defaultFilter={defaultFilter}
+ >
+ {(item) => (
+
+ {item.name}
+
+ )}
+
+ );
+}
+
+const getInput = () => screen.getByTestId('input');
+const queryListbox = () => screen.queryByRole('listbox');
+
+describe('TagAutocomplete', () => {
+ it('does not render the popover until the input is focused', () => {
+ render( );
+ expect(queryListbox()).toBeNull();
+ });
+
+ it('exposes combobox semantics while the popover is closed', () => {
+ render( );
+
+ const input = getInput();
+
+ expect(input).toHaveAttribute('role', 'combobox');
+ expect(input).toHaveAttribute('aria-expanded', 'false');
+ expect(input).toHaveAttribute('aria-autocomplete', 'list');
+ expect(input).not.toHaveAttribute('aria-controls');
+ expect(input).toHaveAttribute('autocomplete', 'off');
+ expect(input).toHaveAttribute('autocorrect', 'off');
+ expect(input).toHaveAttribute('spellcheck', 'false');
+ });
+
+ it('opens the popover on input focus', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(getInput());
+
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+ });
+
+ it('does not open the popover when read-only input is focused', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(getInput());
+
+ expect(getInput()).toHaveFocus();
+ expect(queryListbox()).not.toBeInTheDocument();
+ expect(getInput()).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('does not open the popover from keyboard when read-only', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(getInput());
+ await user.keyboard('{ArrowDown}');
+
+ expect(queryListbox()).not.toBeInTheDocument();
+ expect(getInput()).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('does not render the popover when no suggestions are available', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(getInput());
+
+ expect(queryListbox()).not.toBeInTheDocument();
+ expect(getInput()).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('allows rendering an empty popover when allowsEmptyCollection is true', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(getInput());
+
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+ expect(screen.queryAllByRole('option')).toHaveLength(0);
+ expect(getInput()).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('renders the provided suggestion items', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(getInput());
+
+ expect(
+ await screen.findByRole('option', { name: 'React' })
+ ).toBeInTheDocument();
+
+ expect(
+ screen.getByRole('option', { name: 'TypeScript' })
+ ).toBeInTheDocument();
+
+ expect(
+ screen.getByRole('option', { name: 'Storybook' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders startAddon and endAddon', () => {
+ render( );
+
+ expect(screen.getByTestId('field-addon-start')).toHaveTextContent(
+ 'start-addon'
+ );
+
+ expect(screen.getByTestId('field-addon-end')).toHaveTextContent(
+ 'end-addon'
+ );
+ });
+
+ it('excludes selected tags from the suggestions by key or textValue', async () => {
+ const user = userEvent.setup();
+
+ render( );
+
+ await user.click(getInput());
+
+ expect(
+ await screen.findByRole('option', { name: 'TypeScript' })
+ ).toBeInTheDocument();
+
+ expect(screen.queryByRole('option', { name: 'React' })).toBeNull();
+ });
+
+ it('filters suggestions with defaultFilter', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ textValue.toLocaleLowerCase().includes(inputValue.toLocaleLowerCase())
+ }
+ />
+ );
+
+ await user.click(getInput());
+ await user.type(getInput(), 'type');
+
+ expect(
+ await screen.findByRole('option', { name: 'TypeScript' })
+ ).toBeInTheDocument();
+
+ await waitFor(() =>
+ expect(screen.queryByRole('option', { name: 'React' })).toBeNull()
+ );
+ });
+
+ it('hides the popover when filtering leaves no suggestions', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ textValue.toLocaleLowerCase().includes(inputValue.toLocaleLowerCase())
+ }
+ />
+ );
+
+ const input = getInput();
+
+ await user.click(input);
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+
+ await user.type(input, 'missing');
+
+ await waitFor(() => expect(queryListbox()).not.toBeInTheDocument());
+ expect(input).toHaveAttribute('aria-expanded', 'false');
+
+ await user.clear(input);
+
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+ });
+
+ it('closes the popover on Escape', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(getInput());
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+
+ await user.keyboard('{Escape}');
+
+ await waitFor(() => expect(queryListbox()).not.toBeInTheDocument());
+ });
+
+ it('closes the popover when focus moves outside', async () => {
+ const user = userEvent.setup();
+
+ render(
+ <>
+
+ outside
+ >
+ );
+
+ await user.click(getInput());
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+
+ await user.click(screen.getByTestId('outside'));
+
+ await waitFor(() => expect(queryListbox()).not.toBeInTheDocument());
+ });
+
+ it('closes the popover when keyboard focus leaves the input', async () => {
+ const user = userEvent.setup();
+
+ render(
+ <>
+
+ outside
+ >
+ );
+
+ await user.click(getInput());
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+
+ await user.tab();
+
+ expect(screen.getByTestId('outside')).toHaveFocus();
+ await waitFor(() => expect(queryListbox()).not.toBeInTheDocument());
+ });
+
+ it('closes the popover on click on a non-focusable element outside', async () => {
+ const user = userEvent.setup();
+
+ render(
+ <>
+
+ bystander
+ >
+ );
+
+ await user.click(getInput());
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+
+ await user.click(screen.getByTestId('bystander'));
+
+ await waitFor(() => expect(queryListbox()).not.toBeInTheDocument());
+ });
+
+ it('does not close the popover when clicking back on the input', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(getInput());
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+
+ await user.click(getInput());
+
+ expect(queryListbox()).toBeInTheDocument();
+ });
+
+ it('fires onAdd with source="suggestion" when a suggestion is clicked and closes the popover', async () => {
+ const user = userEvent.setup();
+ const onAdd = vi.fn();
+ render( );
+
+ await user.click(getInput());
+ const option = await screen.findByRole('option', { name: 'React' });
+ await user.click(option);
+
+ expect(onAdd).toHaveBeenCalledWith(
+ ['React'],
+ expect.objectContaining({
+ source: 'suggestion',
+ suggestion: { id: 'react', name: 'React' },
+ })
+ );
+
+ await waitFor(() => expect(queryListbox()).not.toBeInTheDocument());
+ });
+
+ it('closes the popover on outside click AFTER an option was selected with disableCloseOnSelect', async () => {
+ const user = userEvent.setup();
+
+ render(
+ <>
+
+ bystander
+ >
+ );
+
+ await user.click(getInput());
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+
+ const option = await screen.findByRole('option', { name: 'React' });
+ await user.click(option);
+
+ expect(queryListbox()).toBeInTheDocument();
+
+ await user.click(screen.getByTestId('bystander'));
+
+ await waitFor(() => expect(queryListbox()).not.toBeInTheDocument());
+ });
+
+ describe('keyboard navigation', () => {
+ it('ArrowDown from a closed-collection state focuses the first option', async () => {
+ const user = userEvent.setup();
+ const onAdd = vi.fn();
+ render( );
+
+ await user.click(getInput());
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+
+ await user.keyboard('{Escape}');
+ await waitFor(() => expect(queryListbox()).not.toBeInTheDocument());
+
+ await user.keyboard('{ArrowDown}');
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+ await user.keyboard('{Enter}');
+
+ expect(getInput()).toHaveFocus();
+
+ expect(onAdd).toHaveBeenCalledWith(
+ ['React'],
+ expect.objectContaining({
+ source: 'suggestion',
+ suggestion: { id: 'react', name: 'React' },
+ })
+ );
+ });
+
+ it('updates active descendant while DOM focus stays on the input', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(getInput());
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+
+ await user.keyboard('{ArrowDown}');
+
+ const input = getInput();
+ const option = screen.getByRole('option', { name: 'React' });
+
+ expect(input).toHaveFocus();
+
+ expect(option).toHaveAttribute(
+ 'id',
+ input.getAttribute('aria-activedescendant')
+ );
+ });
+
+ it('walks ArrowDown across options and stops at the last', async () => {
+ const user = userEvent.setup();
+ const onAdd = vi.fn();
+ render( );
+
+ await user.click(getInput());
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+
+ await user.keyboard(
+ '{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{Enter}'
+ );
+
+ expect(onAdd).toHaveBeenCalledWith(
+ ['Storybook'],
+ expect.objectContaining({
+ source: 'suggestion',
+ suggestion: { id: 'storybook', name: 'Storybook' },
+ })
+ );
+ });
+
+ it('ArrowUp from nothing focuses the last option', async () => {
+ const user = userEvent.setup();
+ const onAdd = vi.fn();
+ render( );
+
+ await user.click(getInput());
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+
+ await user.keyboard('{ArrowUp}{Enter}');
+
+ expect(onAdd).toHaveBeenCalledWith(
+ ['Storybook'],
+ expect.objectContaining({
+ source: 'suggestion',
+ suggestion: { id: 'storybook', name: 'Storybook' },
+ })
+ );
+ });
+
+ it('ArrowUp from a closed-collection state focuses the last option', async () => {
+ const user = userEvent.setup();
+ const onAdd = vi.fn();
+ render( );
+
+ await user.click(getInput());
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+
+ await user.keyboard('{Escape}');
+ await waitFor(() => expect(queryListbox()).not.toBeInTheDocument());
+
+ await user.keyboard('{ArrowUp}');
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+ await user.keyboard('{Enter}');
+
+ expect(onAdd).toHaveBeenCalledWith(
+ ['Storybook'],
+ expect.objectContaining({
+ source: 'suggestion',
+ suggestion: { id: 'storybook', name: 'Storybook' },
+ })
+ );
+ });
+
+ it('Enter on a focused option closes the popover', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(getInput());
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+
+ await user.keyboard('{ArrowDown}{Enter}');
+
+ await waitFor(() => expect(queryListbox()).not.toBeInTheDocument());
+ });
+
+ it('clears option focus after keyboard selection while keeping list navigation available', async () => {
+ const user = userEvent.setup();
+ const onAdd = vi.fn();
+ render( );
+
+ await user.click(getInput());
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+
+ await user.keyboard('{ArrowDown}{Enter}');
+
+ expect(getInput()).toHaveFocus();
+ expect(getInput()).not.toHaveAttribute('aria-activedescendant');
+
+ expect(onAdd).toHaveBeenLastCalledWith(
+ ['React'],
+ expect.objectContaining({
+ source: 'suggestion',
+ suggestion: { id: 'react', name: 'React' },
+ })
+ );
+
+ await user.keyboard('custom');
+ await user.keyboard('{Enter}');
+
+ expect(onAdd).toHaveBeenLastCalledWith(['custom'], { source: 'enter' });
+
+ await user.keyboard('{ArrowDown}{Enter}');
+
+ expect(onAdd).toHaveBeenLastCalledWith(
+ ['TypeScript'],
+ expect.objectContaining({
+ source: 'suggestion',
+ suggestion: { id: 'typescript', name: 'TypeScript' },
+ })
+ );
+ });
+
+ it('prevents boundary tag arrow keys from scrolling and closing the popover', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(getInput());
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+
+ await user.keyboard('{ArrowLeft}');
+
+ const tag = screen
+ .getByText('React')
+ .closest('[role="row"]');
+
+ expect(tag).not.toBeNull();
+ await waitFor(() => expect(tag).toHaveFocus());
+
+ expect(fireEvent.keyDown(tag as HTMLElement, { key: 'ArrowRight' })).toBe(
+ false
+ );
+
+ expect(queryListbox()).toBeInTheDocument();
+
+ expect(fireEvent.keyDown(tag as HTMLElement, { key: 'ArrowDown' })).toBe(
+ false
+ );
+
+ expect(queryListbox()).toBeInTheDocument();
+ });
+
+ it('Enter without arrow-nav falls through to the existing tag-commit logic', async () => {
+ const user = userEvent.setup();
+ const onAdd = vi.fn();
+ render( );
+
+ await user.click(getInput());
+ await user.type(getInput(), 'foo');
+ await user.keyboard('{Enter}');
+
+ expect(onAdd).toHaveBeenLastCalledWith(['foo'], { source: 'enter' });
+ });
+
+ it('ignores Enter while IME composition is active', async () => {
+ const user = userEvent.setup();
+ const onAdd = vi.fn();
+ render( );
+
+ await user.click(getInput());
+ await user.type(getInput(), 'foo');
+
+ fireEvent.keyDown(getInput(), {
+ key: 'Enter',
+ code: 'Enter',
+ isComposing: true,
+ });
+
+ expect(onAdd).not.toHaveBeenCalled();
+ });
+ });
+
+ it('routes a clicked suggestion through onAdd and does not commit the typed text', async () => {
+ const user = userEvent.setup();
+ const onAdd = vi.fn();
+ render( );
+
+ await user.click(getInput());
+ await user.type(getInput(), 'foo');
+
+ const option = await screen.findByRole('option', { name: 'React' });
+ await user.click(option);
+
+ expect(onAdd).toHaveBeenCalledTimes(1);
+
+ expect(onAdd).toHaveBeenCalledWith(
+ ['React'],
+ expect.objectContaining({
+ source: 'suggestion',
+ suggestion: { id: 'react', name: 'React' },
+ })
+ );
+ });
+
+ describe('disableCloseOnSelect', () => {
+ it('keeps the popover open after a suggestion click', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(getInput());
+ const option = await screen.findByRole('option', { name: 'React' });
+ await user.click(option);
+
+ expect(queryListbox()).toBeInTheDocument();
+ });
+
+ it('keeps the popover open after Enter on a focused option', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(getInput());
+ await waitFor(() => expect(queryListbox()).toBeInTheDocument());
+
+ await user.keyboard('{ArrowDown}{Enter}');
+
+ expect(queryListbox()).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/components/src/components/TagAutocomplete/TagAutocomplete.tsx b/packages/components/src/components/TagAutocomplete/TagAutocomplete.tsx
new file mode 100644
index 00000000..7523285a
--- /dev/null
+++ b/packages/components/src/components/TagAutocomplete/TagAutocomplete.tsx
@@ -0,0 +1,133 @@
+'use client';
+
+import { forwardRef, useRef } from 'react';
+import type { Ref } from 'react';
+
+import { mergeProps, useElementSize } from '@koobiq/react-core';
+import {
+ useTagAutocomplete,
+ useTagAutocompleteState,
+} from '@koobiq/react-primitives';
+
+import { ListInner } from '../List';
+import { PopoverInner } from '../Popover/PopoverInner';
+import { TagInputInner } from '../TagInput';
+import { Tag } from '../TagList/Tag';
+
+import s from './TagAutocomplete.module.css';
+import { TagAutocompleteListItem } from './TagAutocompleteItem';
+import type { TagAutocompleteComponent, TagAutocompleteProps } from './types';
+
+const MIN_POPOVER_INLINE_SIZE = 200;
+
+function TagAutocompleteRender(
+ props: TagAutocompleteProps,
+ ref?: Ref
+) {
+ const {
+ caption,
+ isLabelHidden,
+ fullWidth,
+ variant,
+ labelPlacement,
+ labelAlign,
+ className,
+ style,
+ 'data-testid': dataTestId,
+ slotProps,
+ ...tagAutocompleteProps
+ } = props;
+
+ const tagInputUIProps = {
+ caption,
+ isLabelHidden,
+ fullWidth,
+ variant,
+ labelPlacement,
+ labelAlign,
+ className,
+ style,
+ 'data-testid': dataTestId,
+ };
+
+ const { ref: anchorRef, width: anchorWidth } =
+ useElementSize();
+
+ const popoverRef = useRef(null);
+ const listBoxRef = useRef(null);
+
+ const autocompleteState = useTagAutocompleteState(tagAutocompleteProps);
+
+ const {
+ tagFieldProps,
+ listProps: listPropsAria,
+ popoverProps: popoverPropsAria,
+ } = useTagAutocomplete(
+ {
+ ...tagAutocompleteProps,
+ anchorRef,
+ popoverRef,
+ listBoxRef,
+ },
+ autocompleteState
+ );
+
+ const popoverProps = mergeProps(
+ {
+ offset: 4,
+ hideArrow: true,
+ maxBlockSize: 256,
+ className: s.popover,
+ placement: 'bottom start' as const,
+ size: Math.max(anchorWidth, MIN_POPOVER_INLINE_SIZE),
+ slotProps: {
+ backdrop: { hidden: true },
+ container: { className: s.container },
+ },
+ },
+ popoverPropsAria
+ );
+
+ const listProps = mergeProps(
+ {
+ isPadded: true,
+ className: s.list,
+ noItemsText: props.allowsEmptyCollection ? undefined : null,
+ },
+ listPropsAria
+ );
+
+ return (
+ <>
+
+ {...tagFieldProps}
+ {...tagInputUIProps}
+ inputRef={ref}
+ slotProps={{
+ ...slotProps,
+ group: mergeProps(slotProps?.group, {
+ ref: anchorRef,
+ }),
+ }}
+ />
+
+ {...listProps} />
+
+ >
+ );
+}
+
+const TagAutocompleteComponent = forwardRef(
+ TagAutocompleteRender
+) as TagAutocompleteComponent;
+
+type CompoundedComponent = typeof TagAutocompleteComponent & {
+ ListItem: typeof TagAutocompleteListItem;
+ Tag: typeof Tag;
+};
+
+export const TagAutocomplete = TagAutocompleteComponent as CompoundedComponent;
+
+TagAutocomplete.ListItem = TagAutocompleteListItem;
+
+TagAutocomplete.Tag = Tag;
diff --git a/packages/components/src/components/TagAutocomplete/TagAutocompleteItem.tsx b/packages/components/src/components/TagAutocomplete/TagAutocompleteItem.tsx
new file mode 100644
index 00000000..f13274d4
--- /dev/null
+++ b/packages/components/src/components/TagAutocomplete/TagAutocompleteItem.tsx
@@ -0,0 +1,14 @@
+'use client';
+
+import { Item } from '../Collections';
+import type { ItemProps } from '../Collections';
+
+export type TagAutocompleteListItemProps =
+ ItemProps;
+
+/**
+ * Item rendered inside `TagAutocomplete`'s suggestion list. Re-export of the
+ * shared collection `Item` so it plugs into `useListState({items, children})`
+ * naturally inside the autocomplete state hook.
+ */
+export const TagAutocompleteListItem = Item;
diff --git a/packages/components/src/components/TagAutocomplete/index.ts b/packages/components/src/components/TagAutocomplete/index.ts
new file mode 100644
index 00000000..eac52fae
--- /dev/null
+++ b/packages/components/src/components/TagAutocomplete/index.ts
@@ -0,0 +1,2 @@
+export * from './TagAutocomplete';
+export * from './types';
diff --git a/packages/components/src/components/TagAutocomplete/types.ts b/packages/components/src/components/TagAutocomplete/types.ts
new file mode 100644
index 00000000..23475f17
--- /dev/null
+++ b/packages/components/src/components/TagAutocomplete/types.ts
@@ -0,0 +1,20 @@
+import type { Ref, ReactElement } from 'react';
+
+import type { AriaTagAutocompleteProps } from '@koobiq/react-primitives';
+
+import type { TagInputProps, TagInputRef } from '../TagInput';
+
+type TagAutocompleteBaseProps = Omit<
+ AriaTagAutocompleteProps,
+ 'description' | 'validate' | 'validationState'
+>;
+
+export type TagAutocompleteProps =
+ TagAutocompleteBaseProps &
+ Omit, keyof AriaTagAutocompleteProps | 'ref'>;
+
+export type TagAutocompleteComponent = (
+ props: TagAutocompleteProps & { ref?: Ref }
+) => ReactElement | null;
+
+export type TagAutocompleteRef = TagInputRef;
diff --git a/packages/components/src/components/TagInput/TagInput.mdx b/packages/components/src/components/TagInput/TagInput.mdx
new file mode 100644
index 00000000..0b90ee3d
--- /dev/null
+++ b/packages/components/src/components/TagInput/TagInput.mdx
@@ -0,0 +1,212 @@
+import {
+ Props,
+ Story,
+ Meta,
+ Status,
+} from '../../../../../.storybook/components';
+
+import * as Stories from './TagInput.stories';
+
+
+
+# TagInput
+
+
+
+An input component that allows users to enter, display, and manage multiple
+tags or keywords dynamically. It supports adding and removing tags.
+
+## Import
+
+```tsx
+import { TagInput } from '@koobiq/react-components';
+```
+
+## Usage
+
+
+
+## Props
+
+
+
+## Addons
+
+You can add extra content using the `startAddon` and `endAddon` props.
+
+
+
+## Items
+
+The tag collection is **owned by the consumer**. `TagInput` only renders
+items and emits intent events — assign each item a stable, unique `id` and
+manage the list externally. `useListData` from `@koobiq/react-components`
+is the most convenient option:
+
+```tsx
+const list = useListData({
+ initialItems: [{ id: 'react', name: 'React' }],
+ getKey: (item) => item.id,
+});
+
+
+ list.append(...values.map((name) => ({ id: crypto.randomUUID(), name })))
+ }
+ onRemove={(keys) => list.remove(...keys)}
+>
+ {(item) => {item.name} }
+ ;
+```
+
+Because the consumer assigns ids, duplicate display values never collide
+— each tag has its own identity in the collection.
+
+## Adding tags
+
+`onAdd(values, context)` fires when the user commits new values from the
+text input. `context.source` tells how it happened — `'enter'`,
+`'separator'`, `'paste'`, or `'blur'`. Tags are committed when the user:
+
+- presses Enter ;
+- types a character matching `splitPattern` (default `/,/`);
+- pastes a delimited string into the input (multiple values arrive in one
+ call);
+- moves focus out of the field while the input has a non-empty value
+ (commit-on-blur — enabled by default, opt out with `disableCommitOnBlur`).
+
+To also split on `;` or whitespace, pass a custom `splitPattern`.
+
+
+
+## Preventing duplicates
+
+`TagInput` passes every committed value to `onAdd` as-is, even if it
+already exists in the collection. To keep the list unique, filter the
+values inside the handler:
+
+```tsx
+onAdd={(values) => {
+ const existing = new Set(list.items.map((item) => item.name.toLowerCase()));
+ const fresh = values.filter((value) => !existing.has(value.toLowerCase()));
+ if (fresh.length === 0) return;
+ list.append(...fresh.map(createTag));
+}}
+```
+
+The input is cleared after every commit, so a rejected duplicate just
+disappears. The same pattern works for case-insensitive matches, trimmed
+values, or any other policy.
+
+
+
+## Validation
+
+`inputValue` and `onInputChange` make the text input controlled, so you can
+check every change. To reject a character, don't save the new value. The
+input then keeps what it had. Paste runs through the same handler, so it is
+filtered too. Show the error however you like. The example below flashes a
+red `Tooltip` by the control. See the story source for the full code.
+
+
+
+## Removing tags
+
+`onRemove(keys)` fires with the set of removed tag keys. Tags can be
+removed via the × button on each tag, by Backspace /
+
+Delete on a focused tag, or all at once with the cleaner button
+(`isClearable`).
+
+## Selection
+
+`selectedKeys`, `defaultSelectedKeys` and `onSelectionChange` expose the
+tag-list selection state. Useful for syncing with external state (e.g.
+showing the count of selected tags). Press Space on a focused
+tag or Ctrl /Cmd +A to select.
+
+
+
+## Variant
+
+
+
+## Form field
+
+`TagInput` is a full form-field on par with `Input`. It accepts a visible
+or hidden `label`, a helper `caption` below the control, and an
+`errorMessage` paired with `isInvalid`. Mark the field as required with
+`isRequired`, stretch it to the container with `fullWidth`, or change the
+label position via `labelPlacement` and `labelAlign`.
+
+Validation is driven from the outside: both `isInvalid` and `errorMessage`
+can be controlled by your own state. In the example below the second
+field stays invalid until at least one tag has been added — once a tag
+is committed, the error gives way to a helper caption.
+
+
+
+## Disabled
+
+When `isDisabled` is set, the input is disabled and every tag renders in a
+disabled state. The remove button on each tag stays visible but cannot be
+clicked, and the cleaner is hidden.
+
+
+
+## Read-only
+
+When `isReadOnly` is set, the input is focusable for navigation but no
+modifications are allowed — typing is blocked and the remove buttons on
+tags are not rendered.
+
+
+
+## Cleaner
+
+Enable the cleaner button with `isClearable`. Pressing it removes all tags,
+clears the text input, calls `onClear`, and returns focus to the input.
+
+
+
+## Inside a Form
+
+When wrapped in a ``, `TagInput` inherits `isDisabled`, `isReadOnly`,
+`labelPlacement` and `labelAlign` from the form context.
+
+
+
+## Autofill
+
+See the example when a tag input has its value autofilled by the browser.
+
+
+
+## Accessibility
+
+- The root field renders a `` linked to the inner ` `. Pass
+ either `label` or `aria-label` so the field has an accessible name.
+- The tag list inside the field uses `role="grid"` when there are tags,
+ `role="group"` otherwise. Each tag is a `role="row"` with one
+ `role="gridcell"`.
+- When `onRemove` is wired up, the remove button on each tag (and the
+ global cleaner) is announced with its localized `aria-label`.
+- Keyboard hints for tag removal (Backspace / Delete )
+ are exposed via `aria-describedby` on tags — only for keyboard /
+ assistive modalities, not for pointer users.
+
+## Keyboard
+
+Inside the text input:
+
+| Key | Behavior |
+| ----------------------------------------------- | --------------------------------------------------------------------------------- |
+| Enter | Commit the current input value as a new tag (if non-empty). |
+| _split character_ | Commit and reset the input — same effect as Enter . |
+| Backspace / ArrowLeft | If the input is empty and there are tags, focus the last tag. |
+| Ctrl / Cmd + A | If the input is empty and there are tags, select all tags and focus the last one. |
+| Shift + Tab | If the input is empty and there are tags, move focus to the first tag. |
+| Tab | Move focus to the cleaner button (if visible) or out of the field. |
+
+Inside a focused tag — see [TagList keyboard](?path=/docs/components-taglist--docs#keyboard) for the full table (Arrow navigation, Space selection, Delete / Backspace removal, Ctrl + A , etc.).
diff --git a/packages/components/src/components/TagInput/TagInput.module.css b/packages/components/src/components/TagInput/TagInput.module.css
new file mode 100644
index 00000000..fc6b90da
--- /dev/null
+++ b/packages/components/src/components/TagInput/TagInput.module.css
@@ -0,0 +1,70 @@
+.body {
+ inline-size: 100%;
+}
+
+.body > :first-child + * {
+ margin: 0;
+ margin-block-start: var(--kbq-size-xs);
+}
+
+.body > :first-child + * + * {
+ margin: 0;
+ margin-block-start: var(--kbq-size-xxs);
+}
+
+.group {
+ --form-field-control-padding-inline-start: var(--kbq-size-xxs);
+ --form-field-control-padding-block-start: var(--kbq-size-xxs);
+ --form-field-control-padding-block-end: var(--kbq-size-xxs);
+}
+
+.group:has(> [data-placement='start']) {
+ --form-field-control-padding-inline-start: var(--kbq-size-m);
+}
+
+.tagListContainer {
+ flex: 1;
+ display: flex;
+ flex-wrap: wrap;
+ min-inline-size: 0;
+ align-items: center;
+ gap: var(--kbq-size-xxs);
+ padding-block: var(--form-field-control-padding-block-start)
+ var(--form-field-control-padding-block-end);
+}
+
+.tagList {
+ display: contents;
+}
+
+.tagList:empty + .input {
+ padding-inline-start: var(--kbq-size-s);
+}
+
+.group:has(> [data-placement='start']) .tagList:empty + .input {
+ padding-inline-start: 0;
+}
+
+.input {
+ flex: 1;
+ padding-block: 0;
+ min-inline-size: 4rem;
+ block-size: var(--kbq-size-xxl);
+ padding-inline: var(--kbq-size-xxs);
+ box-sizing: border-box;
+}
+
+.input[data-field-sizing-fallback] {
+ flex: 0 0 auto;
+ inline-size: min(var(--kbq-autosize-inline-size), 100%);
+ max-inline-size: 100%;
+}
+
+@supports (field-sizing: content) {
+ .input {
+ flex: 0 0 auto;
+ inline-size: auto;
+ max-inline-size: 100%;
+ field-sizing: content;
+ }
+}
diff --git a/packages/components/src/components/TagInput/TagInput.stories.tsx b/packages/components/src/components/TagInput/TagInput.stories.tsx
new file mode 100644
index 00000000..30a586ce
--- /dev/null
+++ b/packages/components/src/components/TagInput/TagInput.stories.tsx
@@ -0,0 +1,561 @@
+import { useRef, useState } from 'react';
+
+import type { Key, Selection } from '@koobiq/react-core';
+import { useTimeout } from '@koobiq/react-core';
+import { IconCircleInfo16, IconGridSquares16 } from '@koobiq/react-icons';
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { FlexBox } from '../FlexBox';
+import { Form } from '../Form';
+import { Button, useBreakpoints, useListData } from '../index';
+import { Tooltip } from '../Tooltip';
+import { Typography } from '../Typography';
+
+import { TagInput } from './TagInput';
+import type { TagInputProps } from './types';
+import { tagInputPropVariant } from './types';
+
+type TagItem = { id: string; name: string };
+
+const meta = {
+ title: 'Components/TagInput',
+ component: TagInput,
+ subcomponents: { 'TagInput.Tag': TagInput.Tag },
+ parameters: { layout: 'centered' },
+ tags: ['status:new', 'date:2026-05-25'],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj>;
+
+export const Base: Story = {
+ render: function Render(args) {
+ const { m } = useBreakpoints();
+
+ const tagCounter = useRef(0);
+
+ const list = useListData({
+ initialItems: [
+ { id: 'react', name: 'React' },
+ { id: 'typescript', name: 'TypeScript' },
+ ],
+ });
+
+ const createTag = (name: string): TagItem => {
+ tagCounter.current += 1;
+
+ return { id: `tag-${tagCounter.current}-${name}`, name };
+ };
+
+ return (
+
+ label="Tags"
+ fullWidth
+ style={{ inlineSize: m ? 360 : 240 }}
+ placeholder="Type and press Enter"
+ items={list.items}
+ onAdd={(values) => list.append(...values.map(createTag))}
+ onRemove={(keys) => list.remove(...keys)}
+ {...args}
+ >
+ {(item) => {item.name} }
+
+ );
+ },
+};
+
+export const Variant: Story = {
+ render: function Render() {
+ const { m } = useBreakpoints();
+
+ return (
+
+ {tagInputPropVariant.map((variant) => (
+
+ One
+ Two
+
+ ))}
+
+ );
+ },
+};
+
+export const FormField: Story = {
+ render: function Render() {
+ const { m } = useBreakpoints();
+
+ const categories = useListData({
+ initialItems: [
+ { id: 'news', name: 'News' },
+ { id: 'sports', name: 'Sports' },
+ ],
+ });
+
+ const tags = useListData({ initialItems: [] });
+ const categoryCounter = useRef(0);
+ const tagCounter = useRef(0);
+
+ const createCategory = (name: string): TagItem => {
+ categoryCounter.current += 1;
+
+ return { id: `category-${categoryCounter.current}-${name}`, name };
+ };
+
+ const createTag = (name: string): TagItem => {
+ tagCounter.current += 1;
+
+ return { id: `tag-${tagCounter.current}-${name}`, name };
+ };
+
+ const isEmpty = tags.items.length === 0;
+
+ return (
+
+
+ label="Categories"
+ items={categories.items}
+ placeholder="Add a category"
+ caption="Press Enter or comma to add"
+ onRemove={(keys) => categories.remove(...keys)}
+ onAdd={(values) => categories.append(...values.map(createCategory))}
+ fullWidth
+ isRequired
+ >
+ {(item) => {item.name} }
+
+
+ label="Tags"
+ items={tags.items}
+ isInvalid={isEmpty}
+ placeholder="Add at least one tag"
+ caption={isEmpty ? undefined : 'Looks good'}
+ onRemove={(keys) => tags.remove(...keys)}
+ onAdd={(values) => tags.append(...values.map(createTag))}
+ errorMessage={isEmpty ? 'At least one tag is required' : undefined}
+ fullWidth
+ >
+ {(item) => {item.name} }
+
+
+ );
+ },
+};
+
+export const Disabled: Story = {
+ render: function Render() {
+ const { m } = useBreakpoints();
+
+ const list = useListData({
+ initialItems: [
+ { id: 'react', name: 'React' },
+ { id: 'typescript', name: 'TypeScript' },
+ ],
+ });
+
+ const tagCounter = useRef(0);
+
+ const createTag = (name: string): TagItem => {
+ tagCounter.current += 1;
+
+ return { id: `tag-${tagCounter.current}-${name}`, name };
+ };
+
+ return (
+
+ label="Tags"
+ items={list.items}
+ placeholder="Disabled"
+ style={{ inlineSize: m ? 360 : 240 }}
+ onRemove={(keys) => list.remove(...keys)}
+ onAdd={(values) => list.append(...values.map(createTag))}
+ isDisabled
+ >
+ {(item) => {item.name} }
+
+ );
+ },
+};
+
+export const ReadOnly: Story = {
+ render: function Render() {
+ const { m } = useBreakpoints();
+
+ const list = useListData({
+ initialItems: [
+ { id: 'react', name: 'React' },
+ { id: 'typescript', name: 'TypeScript' },
+ ],
+ });
+
+ const tagCounter = useRef(0);
+
+ const createTag = (name: string): TagItem => {
+ tagCounter.current += 1;
+
+ return { id: `tag-${tagCounter.current}-${name}`, name };
+ };
+
+ return (
+
+ label="Tags"
+ items={list.items}
+ placeholder="Read-only"
+ style={{ inlineSize: m ? 360 : 240 }}
+ onRemove={(keys) => list.remove(...keys)}
+ onAdd={(values) => list.append(...values.map(createTag))}
+ isReadOnly
+ >
+ {(item) => {item.name} }
+
+ );
+ },
+};
+
+export const Clearable: Story = {
+ render: function Render() {
+ const { m } = useBreakpoints();
+
+ const list = useListData({
+ initialItems: [
+ { id: 'react', name: 'React' },
+ { id: 'typescript', name: 'TypeScript' },
+ { id: 'storybook', name: 'Storybook' },
+ ],
+ });
+
+ const tagCounter = useRef(0);
+
+ const createTag = (name: string): TagItem => {
+ tagCounter.current += 1;
+
+ return { id: `tag-${tagCounter.current}-${name}`, name };
+ };
+
+ return (
+
+ label="Tags"
+ items={list.items}
+ placeholder="Add tag"
+ style={{ inlineSize: m ? 360 : 240 }}
+ onRemove={(keys) => list.remove(...keys)}
+ onAdd={(values) => list.append(...values.map(createTag))}
+ isClearable
+ >
+ {(item) => {item.name} }
+
+ );
+ },
+};
+
+export const Addons: Story = {
+ render: function Render() {
+ const { m } = useBreakpoints();
+
+ const list = useListData({
+ initialItems: [
+ { id: 'react', name: 'React' },
+ { id: 'typescript', name: 'TypeScript' },
+ ],
+ });
+
+ const tagCounter = useRef(0);
+
+ const createTag = (name: string): TagItem => {
+ tagCounter.current += 1;
+
+ return { id: `tag-${tagCounter.current}-${name}`, name };
+ };
+
+ return (
+
+ label="Tags"
+ items={list.items}
+ placeholder="Add"
+ style={{ inlineSize: m ? 360 : 240 }}
+ startAddon={ }
+ endAddon={ }
+ onRemove={(keys) => list.remove(...keys)}
+ onAdd={(values) => list.append(...values.map(createTag))}
+ >
+ {(item) => {item.name} }
+
+ );
+ },
+};
+
+export const SplitPattern: Story = {
+ render: function Render() {
+ const { m } = useBreakpoints();
+
+ const list = useListData({ initialItems: [] });
+ const tagCounter = useRef(0);
+
+ const createTag = (name: string): TagItem => {
+ tagCounter.current += 1;
+
+ return { id: `tag-${tagCounter.current}-${name}`, name };
+ };
+
+ return (
+
+ label="Tags"
+ items={list.items}
+ splitPattern={/[,;\s]/}
+ style={{ inlineSize: m ? 360 : 240 }}
+ caption="Try pasting: foo, bar; baz qux"
+ placeholder="Use comma, semicolon or space"
+ onRemove={(keys) => list.remove(...keys)}
+ onAdd={(values) => list.append(...values.map(createTag))}
+ fullWidth
+ >
+ {(item) => {item.name} }
+
+ );
+ },
+};
+
+export const PreventDuplicates: Story = {
+ render: function Render() {
+ const { m } = useBreakpoints();
+
+ const list = useListData({
+ initialItems: [
+ { id: 'react', name: 'React' },
+ { id: 'typescript', name: 'TypeScript' },
+ ],
+ });
+
+ const tagCounter = useRef(0);
+
+ const createTag = (name: string): TagItem => {
+ tagCounter.current += 1;
+
+ return { id: `tag-${tagCounter.current}-${name}`, name };
+ };
+
+ return (
+
+ label="Tags"
+ items={list.items}
+ style={{ inlineSize: m ? 360 : 240 }}
+ caption="Duplicates are ignored — try typing React again"
+ placeholder="Type and press Enter"
+ onAdd={(values) => {
+ const existing = new Set(
+ list.items.map((item) => item.name.toLowerCase())
+ );
+
+ const fresh = values.filter(
+ (value) => !existing.has(value.toLowerCase())
+ );
+
+ if (fresh.length === 0) return;
+ list.append(...fresh.map(createTag));
+ }}
+ onRemove={(keys) => list.remove(...keys)}
+ fullWidth
+ >
+ {(item) => {item.name} }
+
+ );
+ },
+};
+
+export const Validation: Story = {
+ render: function Render() {
+ const { m } = useBreakpoints();
+
+ const anchorRef = useRef(null);
+
+ const list = useListData({
+ initialItems: [
+ { id: 'first', name: 'First' },
+ { id: 'second', name: 'Second' },
+ ],
+ });
+
+ const tagCounter = useRef(0);
+ const [inputValue, setInputValue] = useState('');
+ const [error, setError] = useState('');
+ const [isErrorOpen, setIsErrorOpen] = useState(false);
+
+ useTimeout(() => setIsErrorOpen(false), isErrorOpen ? 2000 : null);
+
+ const createTag = (name: string): TagItem => {
+ tagCounter.current += 1;
+
+ return { id: `tag-${tagCounter.current}-${name}`, name };
+ };
+
+ return (
+ <>
+
+ label="Tags"
+ items={list.items}
+ slotProps={{
+ group: { ref: anchorRef },
+ }}
+ inputValue={inputValue}
+ placeholder="Letters and digits only"
+ style={{ inlineSize: m ? 360 : 240 }}
+ onInputChange={(next) => {
+ if (!/^[\p{L}\p{N}]*$/u.test(next)) {
+ setError('Only letters and digits');
+ setIsErrorOpen(true);
+
+ return;
+ }
+
+ setIsErrorOpen(false);
+ setInputValue(next);
+ }}
+ onAdd={(values) => list.append(...values.map(createTag))}
+ onRemove={(keys) => list.remove(...keys)}
+ fullWidth
+ >
+ {(item) => {item.name} }
+
+
+ {error}
+
+ >
+ );
+ },
+};
+
+export const WithSelection: Story = {
+ render: function Render() {
+ const { m } = useBreakpoints();
+
+ const list = useListData({
+ initialItems: [
+ { id: 'react', name: 'React' },
+ { id: 'typescript', name: 'TypeScript' },
+ { id: 'storybook', name: 'Storybook' },
+ ],
+ });
+
+ const tagCounter = useRef(0);
+
+ const createTag = (name: string): TagItem => {
+ tagCounter.current += 1;
+
+ return { id: `tag-${tagCounter.current}-${name}`, name };
+ };
+
+ const [selected, setSelected] = useState(new Set());
+
+ return (
+
+
+ label="Tags"
+ items={list.items}
+ selectedKeys={selected}
+ onSelectionChange={setSelected}
+ onRemove={(keys) => list.remove(...keys)}
+ placeholder="Click a tag with Cmd/Ctrl, press Space, or Ctrl+A"
+ onAdd={(values) => list.append(...values.map(createTag))}
+ fullWidth
+ >
+ {(item) => {item.name} }
+
+
+ Selected:{' '}
+ {selected === 'all' ? 'all' : [...selected].join(', ') || '(none)'}
+
+
+ );
+ },
+};
+
+export const InsideForm: Story = {
+ render: function Render() {
+ const { m } = useBreakpoints();
+
+ const list = useListData({
+ initialItems: [{ id: 'react', name: 'React' }],
+ });
+
+ const tagCounter = useRef(0);
+
+ const createTag = (name: string): TagItem => {
+ tagCounter.current += 1;
+
+ return { id: `tag-${tagCounter.current}-${name}`, name };
+ };
+
+ return (
+
+
+ label="Tags"
+ items={list.items}
+ placeholder="Form-wide disabled"
+ onRemove={(keys) => list.remove(...keys)}
+ onAdd={(values) => list.append(...values.map(createTag))}
+ >
+ {(item) => {item.name} }
+
+
+ Submit
+
+
+ );
+ },
+};
+
+export const Autofill: Story = {
+ render: function Render() {
+ const { m } = useBreakpoints();
+
+ const list = useListData({
+ initialItems: [{ id: 'react', name: 'React' }],
+ });
+
+ const tagCounter = useRef(0);
+
+ const createTag = (name: string): TagItem => {
+ tagCounter.current += 1;
+
+ return { id: `tag-${tagCounter.current}-${name}`, name };
+ };
+
+ return (
+
+
+ Click on the text box and choose any option suggested by your browser.
+
+
+ label="Tags"
+ id="autofill"
+ name="autofill"
+ items={list.items}
+ placeholder="Type and press Enter"
+ onRemove={(keys) => list.remove(...keys)}
+ onAdd={(values) => list.append(...values.map(createTag))}
+ fullWidth
+ disableCommitOnBlur
+ >
+ {(item) => {item.name} }
+
+
+ );
+ },
+};
diff --git a/packages/components/src/components/TagInput/TagInput.test.tsx b/packages/components/src/components/TagInput/TagInput.test.tsx
new file mode 100644
index 00000000..d1589e28
--- /dev/null
+++ b/packages/components/src/components/TagInput/TagInput.test.tsx
@@ -0,0 +1,769 @@
+import { createRef, useRef, useState } from 'react';
+
+import type { Key, Selection } from '@koobiq/react-core';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+import { Form } from '../Form';
+import { useListData } from '../index';
+
+import { TagInput } from './TagInput';
+import type { TagInputProps } from './types';
+
+type TagItem = { id: string; name: string };
+
+type WrapperProps = Partial> & {
+ initialItems?: TagItem[];
+};
+
+function Wrapper(props: WrapperProps) {
+ const {
+ initialItems = [],
+ onAdd: userOnAdd,
+ onRemove: userOnRemove,
+ ...rest
+ } = props;
+
+ const list = useListData({
+ initialItems,
+ getKey: (item) => item.id,
+ });
+
+ const counter = useRef(0);
+
+ const newTag = (name: string): TagItem => {
+ counter.current += 1;
+
+ return { id: `t-${counter.current}-${name}`, name };
+ };
+
+ return (
+
+ data-testid="root"
+ label="Tags"
+ slotProps={{
+ input: { 'data-testid': 'input' },
+ clearButton: { 'aria-label': 'clear-button' },
+ }}
+ items={list.items}
+ {...rest}
+ onAdd={(values, ctx) => {
+ list.append(...values.map(newTag));
+ userOnAdd?.(values, ctx);
+ }}
+ onRemove={(keys) => {
+ list.remove(...keys);
+ userOnRemove?.(keys);
+ }}
+ >
+ {(item) => {item.name} }
+
+ );
+}
+
+const getInput = () => screen.getByTestId('input') as HTMLInputElement;
+const getRoot = () => screen.getByTestId('root');
+const getClearButton = () => screen.queryByLabelText('clear-button');
+
+const queryTag = (label: string): HTMLElement | null => {
+ const node = screen.queryByText(label);
+
+ return (node?.closest('[role="row"]') as HTMLElement | null) ?? null;
+};
+
+const seed = (names: string[]): TagItem[] =>
+ names.map((name, i) => ({ id: `seed-${i}-${name}`, name }));
+
+describe('TagInput', () => {
+ it('should forward ref to the underlying input', () => {
+ const ref = createRef();
+ render( );
+ expect(ref.current).toBe(getInput());
+ });
+
+ it('should render the label', () => {
+ render( );
+ expect(screen.getByText('Tags')).toBeInTheDocument();
+ });
+
+ it('should render initial tags from items', () => {
+ render( );
+ expect(queryTag('one')).toBeInTheDocument();
+ expect(queryTag('two')).toBeInTheDocument();
+ });
+
+ describe('addons', () => {
+ const getStartAddon = () => screen.getByTestId('field-addon-start');
+ const getEndAddon = () => screen.getByTestId('field-addon-end');
+
+ it('should render a startAddon', () => {
+ render( );
+
+ expect(getRoot()).toHaveTextContent('addon');
+ });
+
+ it('should render an endAddon', () => {
+ render( );
+
+ expect(getRoot()).toHaveTextContent('addon');
+ });
+
+ it('should render addons in an error state', () => {
+ render(
+
+ );
+
+ expect(getStartAddon()).toHaveAttribute('data-invalid', 'true');
+ expect(getEndAddon()).toHaveAttribute('data-invalid', 'true');
+ });
+
+ it('should render addons in a disabled state', () => {
+ render(
+
+ );
+
+ expect(getStartAddon()).toHaveAttribute('data-disabled', 'true');
+ expect(getEndAddon()).toHaveAttribute('data-disabled', 'true');
+ });
+ });
+
+ describe('onAdd', () => {
+ it("fires with source='enter' on Enter", async () => {
+ const onAdd = vi.fn();
+ render( );
+
+ await userEvent.click(getInput());
+ await userEvent.type(getInput(), 'react{Enter}');
+
+ expect(onAdd).toHaveBeenLastCalledWith(['react'], { source: 'enter' });
+ });
+
+ it("fires with source='separator' on a split character", async () => {
+ const onAdd = vi.fn();
+ render( );
+
+ await userEvent.click(getInput());
+ await userEvent.type(getInput(), 'react,');
+
+ expect(onAdd).toHaveBeenLastCalledWith(['react'], {
+ source: 'separator',
+ });
+
+ expect(getInput()).toHaveValue('');
+ });
+
+ it("fires with source='paste' on a delimited paste", async () => {
+ const user = userEvent.setup();
+ const onAdd = vi.fn();
+ render( );
+
+ await user.click(getInput());
+ await user.paste('one, two, three');
+
+ expect(onAdd).toHaveBeenLastCalledWith(['one', 'two', 'three'], {
+ source: 'paste',
+ });
+ });
+
+ it('replaces the selected input value before splitting pasted tags', async () => {
+ const user = userEvent.setup();
+ const onAdd = vi.fn();
+ render( );
+
+ await user.click(getInput());
+ await user.type(getInput(), 'draft');
+ getInput().select();
+ await user.paste('one, two');
+
+ expect(onAdd).toHaveBeenLastCalledWith(['one', 'two'], {
+ source: 'paste',
+ });
+
+ expect(getInput()).toHaveValue('');
+ });
+
+ it("fires with source='blur' on focus loss with non-empty input", async () => {
+ const onAdd = vi.fn();
+
+ render(
+ <>
+
+ outside
+ >
+ );
+
+ await userEvent.click(getInput());
+ await userEvent.type(getInput(), 'committed');
+ await userEvent.click(screen.getByTestId('outside'));
+
+ expect(onAdd).toHaveBeenLastCalledWith(['committed'], {
+ source: 'blur',
+ });
+ });
+
+ it('does not commit on blur when disableCommitOnBlur is set', async () => {
+ const onAdd = vi.fn();
+
+ render(
+ <>
+
+ outside
+ >
+ );
+
+ await userEvent.click(getInput());
+ await userEvent.type(getInput(), 'pending');
+ await userEvent.click(screen.getByTestId('outside'));
+
+ expect(onAdd).not.toHaveBeenCalled();
+ expect(getInput()).toHaveValue('pending');
+ });
+
+ it('respects a custom splitPattern', async () => {
+ const onAdd = vi.fn();
+ render( );
+
+ await userEvent.click(getInput());
+ await userEvent.type(getInput(), 'a;');
+ expect(onAdd).toHaveBeenLastCalledWith(['a'], { source: 'separator' });
+ });
+
+ it('trims whitespace before committing', async () => {
+ const onAdd = vi.fn();
+ render( );
+
+ await userEvent.click(getInput());
+ await userEvent.type(getInput(), ' spaced {Enter}');
+
+ expect(onAdd).toHaveBeenLastCalledWith(['spaced'], { source: 'enter' });
+ });
+
+ it('does not split a paste without separators', async () => {
+ const user = userEvent.setup();
+ const onAdd = vi.fn();
+ render( );
+
+ await user.click(getInput());
+ await user.paste('plain text');
+
+ expect(onAdd).not.toHaveBeenCalled();
+ expect(getInput()).toHaveValue('plain text');
+ });
+
+ it('does not clear committed input when onAdd is not provided', async () => {
+ render(
+
+ aria-label="Tags"
+ items={[]}
+ slotProps={{ input: { 'data-testid': 'input' } }}
+ >
+ {(item) => {item.name} }
+
+ );
+
+ await userEvent.click(getInput());
+ await userEvent.type(getInput(), 'react{Enter}');
+
+ expect(getInput()).toHaveValue('react');
+ });
+
+ it('does not intercept a delimited paste when onAdd is not provided', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ aria-label="Tags"
+ items={[]}
+ slotProps={{ input: { 'data-testid': 'input' } }}
+ >
+ {(item) => {item.name} }
+
+ );
+
+ await user.click(getInput());
+ await user.paste('one, two');
+
+ expect(getInput()).toHaveValue('one, two');
+ });
+
+ it('keeps focus in the input (no focus ring on the new tag) after Enter', async () => {
+ render( );
+
+ await userEvent.click(getInput());
+ await userEvent.type(getInput(), 'react{Enter}');
+
+ await waitFor(() => expect(queryTag('react')).toBeInTheDocument());
+ expect(queryTag('react')).not.toHaveAttribute('data-focus-visible');
+ expect(getInput()).toHaveFocus();
+ });
+ });
+
+ describe('onRemove', () => {
+ it('fires with the clicked tag key', async () => {
+ const onRemove = vi.fn();
+ render( );
+
+ const tagA = queryTag('a');
+ const button = within(tagA as HTMLElement).getByRole('button');
+ await userEvent.click(button);
+
+ const lastCall = onRemove.mock.lastCall as [Set] | undefined;
+ expect(lastCall?.[0].has('seed-0-a')).toBe(true);
+ expect(getInput()).toHaveFocus();
+ });
+
+ it('removes a focused tag via Delete', async () => {
+ const user = userEvent.setup();
+ const onRemove = vi.fn();
+
+ render(
+
+ );
+
+ await user.click(getInput());
+ await user.keyboard('{Backspace}');
+ await user.keyboard('{ArrowLeft}');
+ await waitFor(() => expect(queryTag('two')).toHaveFocus());
+ await user.keyboard('{Delete}');
+
+ const lastCall = onRemove.mock.lastCall as [Set] | undefined;
+ expect(lastCall?.[0].has('seed-1-two')).toBe(true);
+
+ await waitFor(() => {
+ expect(queryTag('two')).not.toBeInTheDocument();
+ expect(getInput()).not.toHaveFocus();
+ });
+ });
+
+ it('removes all selected tags via Ctrl+A → Backspace from input', async () => {
+ const user = userEvent.setup();
+ const onRemove = vi.fn();
+
+ render(
+
+ );
+
+ await user.click(getInput());
+ await user.keyboard('{Control>}a{/Control}');
+ await user.keyboard('{Backspace}');
+
+ const lastCall = onRemove.mock.lastCall as [Set] | undefined;
+ expect(lastCall?.[0].size).toBe(3);
+ });
+
+ it('returns focus to the input after the last tag is removed', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const button = within(queryTag('only') as HTMLElement).getByRole(
+ 'button'
+ );
+
+ await user.click(button);
+
+ await waitFor(() => {
+ expect(queryTag('only')).not.toBeInTheDocument();
+ expect(getInput()).toHaveFocus();
+ });
+ });
+
+ it('returns focus to the input after the last tag is removed via keyboard', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(getInput());
+ await user.keyboard('{Backspace}');
+ await waitFor(() => expect(queryTag('only')).toHaveFocus());
+ await user.keyboard('{Delete}');
+
+ await waitFor(() => {
+ expect(queryTag('only')).not.toBeInTheDocument();
+ expect(getInput()).toHaveFocus();
+ });
+ });
+ });
+
+ describe('duplicate values', () => {
+ it('does not collide keys: removing one duplicate keeps the other', async () => {
+ const user = userEvent.setup();
+
+ // Two tags with the same NAME but distinct ids (consumer-owned).
+ const initialItems: TagItem[] = [
+ { id: 'a1', name: 'foo' },
+ { id: 'a2', name: 'foo' },
+ ];
+
+ render( );
+
+ // Two role=row elements present.
+ expect(screen.getAllByRole('row')).toHaveLength(2);
+
+ // Click × on the FIRST "foo".
+ const firstRow = screen.getAllByRole('row')[0] as HTMLElement;
+ const firstRemove = within(firstRow).getByRole('button');
+ await user.click(firstRemove);
+
+ // One "foo" remains, the second one with id 'a2'.
+ await waitFor(() => {
+ expect(screen.getAllByRole('row')).toHaveLength(1);
+ expect(screen.getByRole('row')).toHaveAttribute('data-key', 'a2');
+ });
+ });
+
+ it('arrow keys navigate across duplicates', async () => {
+ const user = userEvent.setup();
+
+ const initialItems: TagItem[] = [
+ { id: 'a1', name: 'foo' },
+ { id: 'a2', name: 'foo' },
+ ];
+
+ render( );
+
+ await user.click(getInput());
+ await user.keyboard('{Shift>}{Tab}{/Shift}');
+ await waitFor(() => expect(screen.getAllByRole('row')[0]).toHaveFocus());
+ const rows = screen.getAllByRole('row') as HTMLElement[];
+ await user.keyboard('{ArrowRight}');
+ await waitFor(() => expect(rows[1]).toHaveFocus());
+ });
+ });
+
+ describe('focus navigation', () => {
+ it('focuses the last tag on Backspace from empty input', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(getInput());
+ await user.keyboard('{Backspace}');
+
+ await waitFor(() => expect(queryTag('two')).toHaveFocus());
+ });
+
+ it('focuses the last tag on ArrowLeft from empty input', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(getInput());
+ await user.keyboard('{ArrowLeft}');
+
+ await waitFor(() => expect(queryTag('two')).toHaveFocus());
+ });
+
+ it('focuses the first tag on Shift+Tab from empty input', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(getInput());
+ await user.keyboard('{Shift>}{Tab}{/Shift}');
+
+ await waitFor(() => expect(queryTag('one')).toHaveFocus());
+ });
+
+ it('focuses the input when the canvas between tags is clicked', async () => {
+ const user = userEvent.setup();
+ const { container } = render( );
+ expect(getInput()).not.toHaveFocus();
+
+ const canvas = container.querySelector(
+ '[role="presentation"]'
+ ) as HTMLElement;
+
+ await user.click(canvas);
+ expect(getInput()).toHaveFocus();
+ });
+
+ it('keeps focus in the input when a tag is clicked', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(getInput());
+ await user.click(queryTag('one') as HTMLElement);
+
+ expect(getInput()).toHaveFocus();
+ expect(queryTag('one')).not.toHaveFocus();
+ });
+
+ it('returns to the input when tabbing back into the field after a tag was focused', async () => {
+ const user = userEvent.setup();
+
+ render(
+ <>
+ before
+
+ >
+ );
+
+ await user.click(getInput());
+ await user.keyboard('{Backspace}');
+ await waitFor(() => expect(queryTag('two')).toHaveFocus());
+
+ await user.click(screen.getByTestId('before'));
+ await user.tab();
+
+ expect(getInput()).toHaveFocus();
+ });
+ });
+
+ describe('selection', () => {
+ it('selects all tags on Ctrl+A from empty input', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(getInput());
+ await user.keyboard('{Control>}a{/Control}');
+
+ await waitFor(() => {
+ expect(queryTag('one')).toHaveAttribute('aria-selected', 'true');
+ expect(queryTag('two')).toHaveAttribute('aria-selected', 'true');
+ });
+ });
+
+ it('passes through controlled selectedKeys and emits onSelectionChange', async () => {
+ const user = userEvent.setup();
+ const onSelectionChange = vi.fn();
+
+ function Controlled() {
+ const [selected, setSelected] = useState(new Set());
+
+ return (
+ {
+ setSelected(keys);
+ onSelectionChange(keys);
+ }}
+ />
+ );
+ }
+
+ render( );
+
+ await user.click(getInput());
+ await user.keyboard('{Shift>}{Tab}{/Shift}');
+ await waitFor(() => expect(queryTag('one')).toHaveFocus());
+ await user.keyboard('{Space}');
+
+ expect(onSelectionChange).toHaveBeenCalled();
+ });
+ });
+
+ describe('disabled / read-only', () => {
+ it('does not allow typing when disabled', async () => {
+ const onAdd = vi.fn();
+ render( );
+
+ expect(getInput()).toBeDisabled();
+ await userEvent.type(getInput(), 'x{Enter}');
+ expect(onAdd).not.toHaveBeenCalled();
+ });
+
+ it('marks every tag as disabled when the field is disabled', () => {
+ render( );
+
+ [queryTag('one'), queryTag('two')].forEach((tag) => {
+ expect(tag).toHaveAttribute('data-disabled', 'true');
+ const removeBtn = within(tag as HTMLElement).getByRole('button');
+ expect(removeBtn).toBeDisabled();
+ });
+ });
+
+ it('disables tags rendered as a static JSX list', () => {
+ render(
+
+ aria-label="Static"
+ onAdd={() => undefined}
+ onRemove={() => undefined}
+ isDisabled
+ >
+ React
+ TypeScript
+
+ );
+
+ [queryTag('React'), queryTag('TypeScript')].forEach((tag) => {
+ expect(tag).toHaveAttribute('data-disabled', 'true');
+ });
+ });
+
+ it('does not consume iterable items while disabling the whole field', () => {
+ function* items() {
+ yield { id: 'r', name: 'React' };
+ yield { id: 't', name: 'TypeScript' };
+ }
+
+ render(
+
+ aria-label="Generated"
+ items={items()}
+ onAdd={() => undefined}
+ onRemove={() => undefined}
+ isDisabled
+ >
+ {(item) => {item.name} }
+
+ );
+
+ [queryTag('React'), queryTag('TypeScript')].forEach((tag) => {
+ expect(tag).toBeInTheDocument();
+ expect(tag).toHaveAttribute('data-disabled', 'true');
+ });
+ });
+
+ it('does not allow modification when read-only', async () => {
+ const onAdd = vi.fn();
+ const onRemove = vi.fn();
+
+ render(
+
+ );
+
+ await userEvent.click(getInput());
+ await userEvent.type(getInput(), 'b{Enter}');
+ expect(onAdd).not.toHaveBeenCalled();
+ expect(queryTag('a')).toBeInTheDocument();
+ });
+
+ it('disables tag selection in read-only but keeps focus navigation', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const firstTag = queryTag('one') as HTMLElement;
+ await user.click(getInput());
+ await user.keyboard('{Shift>}{Tab}{/Shift}');
+ await waitFor(() => expect(firstTag).toHaveFocus());
+ await user.keyboard('{Space}');
+ expect(firstTag).not.toHaveAttribute('aria-selected', 'true');
+
+ await user.keyboard('{ArrowRight}');
+ await waitFor(() => expect(queryTag('two')).toHaveFocus());
+ });
+
+ it('marks every tag as disabled via consumer disabledKeys', () => {
+ const initialItems = seed(['one', 'two']);
+
+ render(
+ item.id)}
+ />
+ );
+
+ [queryTag('one'), queryTag('two')].forEach((tag) => {
+ expect(tag).toHaveAttribute('data-disabled', 'true');
+ const removeBtn = within(tag as HTMLElement).getByRole('button');
+ expect(removeBtn).toBeDisabled();
+ });
+ });
+
+ it('renders tags in the error variant when invalid', () => {
+ render(
+
+ );
+
+ [queryTag('one'), queryTag('two')].forEach((tag) => {
+ expect(tag).toHaveAttribute('data-variant', 'error-fade');
+ });
+ });
+
+ it('displays an error message when isInvalid', () => {
+ render( );
+ expect(screen.getByText('Required')).toBeInTheDocument();
+ });
+
+ it('displays a caption', () => {
+ render( );
+ expect(screen.getByText('Helper')).toBeInTheDocument();
+ });
+
+ it('inherits isDisabled from a parent Form', () => {
+ render(
+
+
+
+ );
+
+ expect(getInput()).toBeDisabled();
+ });
+ });
+
+ describe('onInputChange', () => {
+ it('fires on every keystroke', async () => {
+ const onInputChange = vi.fn();
+ render( );
+
+ await userEvent.click(getInput());
+ await userEvent.type(getInput(), 'ab');
+
+ expect(onInputChange).toHaveBeenCalledWith('a');
+ expect(onInputChange).toHaveBeenCalledWith('ab');
+ });
+ });
+
+ describe('Cleaner', () => {
+ it('does not render when isClearable is false', () => {
+ render( );
+ expect(getClearButton()).toBeNull();
+ });
+
+ it('is hidden when there are no tags and no input value', () => {
+ render( );
+ expect(getClearButton()).toHaveAttribute('aria-hidden', 'true');
+ });
+
+ it('is visible when there are tags', () => {
+ render( );
+ expect(getClearButton()).not.toHaveAttribute('aria-hidden', 'true');
+ });
+
+ it('auto-emits onRemove(allKeys) + onClear + refocus on press', async () => {
+ const onRemove = vi.fn();
+ const onClear = vi.fn();
+
+ render(
+
+ );
+
+ const clearBtn = getClearButton();
+ await userEvent.click(clearBtn as HTMLElement);
+
+ const lastCall = onRemove.mock.lastCall as [Set] | undefined;
+ expect(lastCall?.[0].size).toBe(2);
+ expect(onClear).toHaveBeenCalledTimes(1);
+
+ await waitFor(() => {
+ expect(queryTag('a')).not.toBeInTheDocument();
+ expect(queryTag('b')).not.toBeInTheDocument();
+ });
+
+ expect(getInput()).toHaveFocus();
+ });
+
+ it('is hidden when disabled even with tags', () => {
+ render( );
+ expect(getClearButton()).toHaveAttribute('aria-hidden', 'true');
+ });
+ });
+});
diff --git a/packages/components/src/components/TagInput/TagInput.tsx b/packages/components/src/components/TagInput/TagInput.tsx
new file mode 100644
index 00000000..32a85b58
--- /dev/null
+++ b/packages/components/src/components/TagInput/TagInput.tsx
@@ -0,0 +1,255 @@
+'use client';
+
+import { forwardRef } from 'react';
+import type { Ref, RefObject } from 'react';
+
+import { mergeProps } from '@koobiq/react-core';
+import {
+ ButtonContext,
+ DEFAULT_SLOT,
+ FieldErrorContext,
+ Provider,
+ type TagAutocompleteState,
+ type TagFieldState,
+ useTagField,
+ useTagFieldState,
+} from '@koobiq/react-primitives';
+
+import { useForm } from '../Form';
+import type {
+ FormFieldProps,
+ FormFieldInputProps,
+ FormFieldLabelProps,
+ FormFieldErrorProps,
+ FormFieldCaptionProps,
+ FormFieldControlGroupProps,
+} from '../FormField';
+import { FormField, FormFieldClearButton } from '../FormField';
+import { Tag } from '../TagList/Tag';
+import { TagListInner } from '../TagList/TagListInner';
+
+import { useFieldSizingFallback } from './hooks/useFieldSizingFallback';
+import s from './TagInput.module.css';
+import type { TagInputComponent, TagInputProps } from './types';
+
+export type TagInputInnerProps = {
+ state: TagFieldState | TagAutocompleteState;
+ inputRef?: Ref;
+ popoverRef?: RefObject;
+ listBoxRef?: RefObject;
+} & TagInputProps;
+
+/**
+ * An input component that allows users to enter, display, and manage
+ * multiple tags or keywords dynamically. It supports adding and removing
+ * tags.
+ */
+export function TagInputInner(
+ inProps: TagInputInnerProps
+) {
+ const {
+ state,
+ inputRef: forwardedInputRef,
+ isRequired,
+ isDisabled: isDisabledProp,
+ isReadOnly: isReadOnlyProp,
+ ...props
+ } = inProps;
+
+ const { isDisabled: formIsDisabled, isReadOnly: formIsReadOnly } = useForm();
+
+ const isDisabled = isDisabledProp ?? formIsDisabled;
+ const isReadOnly = isReadOnlyProp ?? formIsReadOnly;
+
+ const {
+ variant = 'filled',
+ style,
+ label,
+ caption,
+ endAddon,
+ className,
+ fullWidth,
+ labelAlign,
+ errorMessage,
+ isLabelHidden,
+ labelPlacement,
+ startAddon,
+ 'data-testid': dataTestId,
+ slotProps,
+ placeholder,
+ } = props;
+
+ const {
+ inputRef,
+ isInvalid,
+ inputValue,
+ validationErrors,
+ validationDetails,
+ descriptionProps,
+ errorMessageProps,
+ inputProps: inputPropsAria,
+ labelProps: labelPropsAria,
+ tagListProps: tagListPropsAria,
+ clearButtonProps: clearButtonPropsAria,
+ tagListContainerProps: tagListContainerPropsAria,
+ } = useTagField(
+ {
+ ...props,
+ isDisabled,
+ isReadOnly,
+ },
+ state,
+ forwardedInputRef
+ );
+
+ useFieldSizingFallback(inputRef, {
+ fallbackText: placeholder,
+ text: inputValue,
+ });
+
+ const validation = { isInvalid, validationErrors, validationDetails };
+
+ const rootProps = mergeProps<(FormFieldProps | undefined)[]>(
+ {
+ style,
+ fullWidth,
+ labelAlign,
+ labelPlacement,
+ 'data-testid': dataTestId,
+ 'data-variant': variant,
+ 'data-invalid': isInvalid || undefined,
+ 'data-readonly': isReadOnly || undefined,
+ 'data-disabled': isDisabled || undefined,
+ 'data-required': isRequired || undefined,
+ className,
+ },
+ slotProps?.root
+ );
+
+ const labelProps = mergeProps<(FormFieldLabelProps | undefined)[]>(
+ { isHidden: isLabelHidden, isRequired, children: label },
+ labelPropsAria,
+ slotProps?.label
+ );
+
+ const clearButtonProps = mergeProps(
+ clearButtonPropsAria,
+ slotProps?.clearButton
+ );
+
+ const inputProps = mergeProps<(FormFieldInputProps | undefined)[]>(
+ {
+ ref: inputRef,
+ className: s.input,
+ },
+ inputPropsAria,
+ slotProps?.input
+ );
+
+ const tagListContainerProps = mergeProps(
+ { className: s.tagListContainer },
+ tagListContainerPropsAria
+ );
+
+ const tagListProps = mergeProps(
+ {
+ className: s.tagList,
+ variant: isInvalid ? 'error-fade' : 'contrast-fade',
+ },
+ tagListPropsAria,
+ slotProps?.tagList
+ );
+
+ const groupProps = mergeProps<(FormFieldControlGroupProps | undefined)[]>(
+ {
+ endAddon: (clearButtonPropsAria.isClearable || endAddon) && (
+ <>
+
+ {endAddon}
+ >
+ ),
+ variant,
+ isDisabled,
+ startAddon,
+ onMouseDown: (event) => {
+ if (event.target !== event.currentTarget) return;
+ event.preventDefault();
+ inputRef.current?.focus();
+ },
+ isInvalid,
+ className: s.group,
+ },
+ slotProps?.group
+ );
+
+ const captionProps = mergeProps<(FormFieldCaptionProps | undefined)[]>(
+ { children: caption },
+ descriptionProps,
+ slotProps?.caption
+ );
+
+ const errorProps = mergeProps<(FormFieldErrorProps | undefined)[]>(
+ { children: errorMessage },
+ errorMessageProps,
+ slotProps?.errorMessage
+ );
+
+ return (
+
+
+
+
+
+ {({ focusProps }) => (
+
+ {...tagListProps} />
+ {/* focusProps on the input only: tags don't drive the group ring. */}
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * An input component that allows users to enter, display, and manage
+ * multiple tags or keywords dynamically. It supports adding and removing
+ * tags.
+ */
+function TagInputRender(
+ props: TagInputProps,
+ ref?: Ref
+) {
+ const state = useTagFieldState(props);
+
+ return {...props} state={state} inputRef={ref} />;
+}
+
+const TagInputComponent = forwardRef(TagInputRender) as TagInputComponent;
+
+type CompoundedComponent = typeof TagInputComponent & {
+ Tag: typeof Tag;
+};
+
+export const TagInput = TagInputComponent as CompoundedComponent;
+
+TagInput.Tag = Tag;
diff --git a/packages/components/src/components/TagInput/hooks/useFieldSizingFallback.test.tsx b/packages/components/src/components/TagInput/hooks/useFieldSizingFallback.test.tsx
new file mode 100644
index 00000000..81fa8fe4
--- /dev/null
+++ b/packages/components/src/components/TagInput/hooks/useFieldSizingFallback.test.tsx
@@ -0,0 +1,119 @@
+import { useRef } from 'react';
+
+import { render, screen, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { useFieldSizingFallback } from './useFieldSizingFallback';
+
+type AutosizeInputProps = {
+ value?: string;
+ placeholder?: string;
+ inlineSizeVariable?: `--${string}`;
+ fallbackAttribute?: `data-${string}`;
+};
+
+function AutosizeInput(props: AutosizeInputProps) {
+ const {
+ value = '',
+ placeholder,
+ fallbackAttribute,
+ inlineSizeVariable,
+ } = props;
+
+ const inputRef = useRef(null);
+
+ useFieldSizingFallback(inputRef, {
+ fallbackAttribute,
+ fallbackText: placeholder,
+ inlineSizeVariable,
+ text: value,
+ });
+
+ return (
+
+ );
+}
+
+const getInput = () => screen.getByTestId('input') as HTMLInputElement;
+
+describe('useFieldSizingFallback', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it('enables JS autosize when field-sizing is not supported', async () => {
+ vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
+
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
+ bottom: 0,
+ height: 0,
+ left: 0,
+ right: 120,
+ top: 0,
+ width: 120,
+ x: 0,
+ y: 0,
+ toJSON: () => undefined,
+ });
+
+ render( );
+
+ await waitFor(() => {
+ expect(getInput()).toHaveAttribute('data-field-sizing-fallback', '');
+
+ expect(
+ getInput().style.getPropertyValue('--kbq-autosize-inline-size')
+ ).toBe('122px');
+ });
+ });
+
+ it('allows overriding fallback attribute and inline-size variable names', async () => {
+ vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
+
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
+ bottom: 0,
+ height: 0,
+ left: 0,
+ right: 80,
+ top: 0,
+ width: 80,
+ x: 0,
+ y: 0,
+ toJSON: () => undefined,
+ });
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(getInput()).toHaveAttribute('data-custom-fallback', '');
+
+ expect(getInput().style.getPropertyValue('--custom-inline-size')).toBe(
+ '82px'
+ );
+ });
+ });
+
+ it('keeps native autosize when field-sizing is supported', () => {
+ vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(true) });
+ render( );
+
+ expect(getInput()).not.toHaveAttribute('data-field-sizing-fallback');
+
+ expect(
+ getInput().style.getPropertyValue('--kbq-autosize-inline-size')
+ ).toBe('');
+ });
+});
diff --git a/packages/components/src/components/TagInput/hooks/useFieldSizingFallback.ts b/packages/components/src/components/TagInput/hooks/useFieldSizingFallback.ts
new file mode 100644
index 00000000..5dacde36
--- /dev/null
+++ b/packages/components/src/components/TagInput/hooks/useFieldSizingFallback.ts
@@ -0,0 +1,95 @@
+'use client';
+
+import { useRef } from 'react';
+
+import { useIsomorphicEffect } from '@koobiq/react-core';
+
+const DEFAULT_INLINE_SIZE_VARIABLE = '--kbq-autosize-inline-size';
+const DEFAULT_FALLBACK_ATTRIBUTE = 'data-field-sizing-fallback';
+
+const supportsFieldSizingContent = () =>
+ typeof CSS !== 'undefined' && CSS.supports?.('field-sizing', 'content');
+
+type UseFieldSizingFallbackOptions = {
+ text: string;
+ fallbackText?: string;
+ inlineSizeVariable?: `--${string}`;
+ fallbackAttribute?: `data-${string}`;
+};
+
+export const useFieldSizingFallback = (
+ elementRef: { current: HTMLElement | null },
+ options: UseFieldSizingFallbackOptions
+) => {
+ const mirrorRef = useRef(null);
+
+ const {
+ fallbackAttribute = DEFAULT_FALLBACK_ATTRIBUTE,
+ fallbackText,
+ inlineSizeVariable = DEFAULT_INLINE_SIZE_VARIABLE,
+ text,
+ } = options;
+
+ useIsomorphicEffect(() => {
+ const element = elementRef.current;
+
+ if (!element) return;
+
+ if (supportsFieldSizingContent()) {
+ element.removeAttribute(fallbackAttribute);
+ element.style.removeProperty(inlineSizeVariable);
+
+ return;
+ }
+
+ let mirror = mirrorRef.current;
+
+ if (!mirror) {
+ mirror = document.createElement('span');
+ mirrorRef.current = mirror;
+ mirror.setAttribute('aria-hidden', 'true');
+
+ Object.assign(mirror.style, {
+ inset: '0 auto auto 0',
+ overflow: 'hidden',
+ pointerEvents: 'none',
+ position: 'fixed',
+ visibility: 'hidden',
+ whiteSpace: 'pre',
+ });
+
+ document.body.append(mirror);
+ }
+
+ const styles = getComputedStyle(element);
+
+ mirror.style.fontFamily = styles.fontFamily;
+ mirror.style.fontFeatureSettings = styles.fontFeatureSettings;
+ mirror.style.fontKerning = styles.fontKerning;
+ mirror.style.fontSize = styles.fontSize;
+ mirror.style.fontStretch = styles.fontStretch;
+ mirror.style.fontStyle = styles.fontStyle;
+ mirror.style.fontVariant = styles.fontVariant;
+ mirror.style.fontWeight = styles.fontWeight;
+ mirror.style.letterSpacing = styles.letterSpacing;
+ mirror.style.lineHeight = styles.lineHeight;
+ mirror.style.paddingInlineStart = styles.paddingInlineStart;
+ mirror.style.paddingInlineEnd = styles.paddingInlineEnd;
+ mirror.style.textTransform = styles.textTransform;
+ mirror.style.wordSpacing = styles.wordSpacing;
+ mirror.textContent = text || fallbackText || '';
+
+ const width = Math.ceil(mirror.getBoundingClientRect().width) + 2;
+
+ element.setAttribute(fallbackAttribute, '');
+ element.style.setProperty(inlineSizeVariable, `${width}px`);
+ }, [elementRef, text, fallbackText, fallbackAttribute, inlineSizeVariable]);
+
+ useIsomorphicEffect(
+ () => () => {
+ mirrorRef.current?.remove();
+ mirrorRef.current = null;
+ },
+ []
+ );
+};
diff --git a/packages/components/src/components/TagInput/index.ts b/packages/components/src/components/TagInput/index.ts
new file mode 100644
index 00000000..f80ef38a
--- /dev/null
+++ b/packages/components/src/components/TagInput/index.ts
@@ -0,0 +1,2 @@
+export * from './TagInput';
+export * from './types';
diff --git a/packages/components/src/components/TagInput/types.ts b/packages/components/src/components/TagInput/types.ts
new file mode 100644
index 00000000..eb99c371
--- /dev/null
+++ b/packages/components/src/components/TagInput/types.ts
@@ -0,0 +1,104 @@
+import type {
+ Ref,
+ CSSProperties,
+ ComponentRef,
+ ReactNode,
+ ReactElement,
+} from 'react';
+
+import type {
+ AriaTagFieldProps,
+ TagFieldAddContext,
+ TagFieldAddSource,
+} from '@koobiq/react-primitives';
+
+import type {
+ FormFieldProps,
+ FormFieldInputProps,
+ FormFieldLabelProps,
+ FormFieldErrorProps,
+ FormFieldCaptionProps,
+ FormFieldPropLabelAlign,
+ FormFieldControlGroupProps,
+ FormFieldPropLabelPlacement,
+ FormFieldControlGroupPropVariant,
+} from '../FormField';
+import {
+ formFieldControlGroupPropVariant,
+ formFieldPropLabelAlign,
+ formFieldPropLabelPlacement,
+} from '../FormField';
+import type { IconButtonProps } from '../IconButton';
+import type { TagListInnerProps } from '../TagList';
+
+export const tagInputPropVariant = formFieldControlGroupPropVariant;
+export type TagInputPropVariant = FormFieldControlGroupPropVariant;
+
+export const tagInputPropLabelPlacement = formFieldPropLabelPlacement;
+export type TagInputPropLabelPlacement = FormFieldPropLabelPlacement;
+
+export const tagInputPropLabelAlign = formFieldPropLabelAlign;
+export type TagInputPropLabelAlign = FormFieldPropLabelAlign;
+
+/** How the user's input ended up as new tags. */
+export type TagInputAddSource = TagFieldAddSource;
+
+export type TagInputAddContext = TagFieldAddContext;
+
+type TagInputBaseProps = Omit<
+ AriaTagFieldProps,
+ 'description' | 'validate' | 'validationState'
+>;
+
+export type TagInputProps = TagInputBaseProps & {
+ /** Helper text below the field. */
+ caption?: ReactNode;
+ /** Addon placed before the tags/input content. */
+ startAddon?: ReactNode;
+ /** Addon placed after the tags/input content. */
+ endAddon?: ReactNode;
+ /** Whether the label is visually hidden. */
+ isLabelHidden?: boolean;
+ /** Whether the field takes up the full width of its container. */
+ fullWidth?: boolean;
+ /**
+ * The variant to use.
+ * @default 'filled'
+ */
+ variant?: TagInputPropVariant;
+ /**
+ * The label's overall position relative to the element it is labeling.
+ * @default 'top'
+ */
+ labelPlacement?: TagInputPropLabelPlacement;
+ /**
+ * The label's horizontal alignment relative to the element it is labeling.
+ * @default 'start'
+ */
+ labelAlign?: TagInputPropLabelAlign;
+ /** Additional CSS-classes. */
+ className?: string;
+ /** Inline styles. */
+ style?: CSSProperties;
+ /** Unique identifier for testing purposes. */
+ 'data-testid'?: string | number;
+ /** Ref forwarded to the underlying text input. */
+ ref?: Ref;
+ /** Props used for each slot inside. */
+ slotProps?: {
+ root?: FormFieldProps;
+ label?: FormFieldLabelProps;
+ caption?: FormFieldCaptionProps;
+ group?: FormFieldControlGroupProps;
+ errorMessage?: FormFieldErrorProps;
+ clearButton?: IconButtonProps;
+ input?: FormFieldInputProps;
+ tagList?: Partial, 'state' | 'isDisabled'>>;
+ };
+};
+
+export type TagInputComponent = (
+ props: TagInputProps & { ref?: Ref }
+) => ReactElement | null;
+
+export type TagInputRef = ComponentRef<'input'>;
diff --git a/packages/components/src/components/TagList/Tag.tsx b/packages/components/src/components/TagList/Tag.tsx
new file mode 100644
index 00000000..f667ba23
--- /dev/null
+++ b/packages/components/src/components/TagList/Tag.tsx
@@ -0,0 +1,69 @@
+'use client';
+
+import type {
+ ReactNode,
+ CSSProperties,
+ FC,
+ ComponentPropsWithRef,
+} from 'react';
+
+import type { ItemProps as AriaItemProps } from '@koobiq/react-core';
+import { Item as AriaItem } from '@koobiq/react-primitives';
+
+import type { IconButtonProps } from '../IconButton';
+
+import type { TagListPropVariant } from './types';
+
+type ItemComponent = FC> & {
+ getCollectionNode: unknown;
+};
+
+const TagInner = AriaItem as ItemComponent;
+
+type TagSlotProps = {
+ root?: ComponentPropsWithRef<'div'>;
+ icon?: ComponentPropsWithRef<'span'>;
+ content?: ComponentPropsWithRef<'span'>;
+ removeIcon?: IconButtonProps;
+};
+
+type AriaTagItemProps = Omit<
+ AriaItemProps,
+ | 'children'
+ | 'href'
+ | 'hrefLang'
+ | 'target'
+ | 'rel'
+ | 'download'
+ | 'ping'
+ | 'referrerPolicy'
+ | 'title'
+ | 'childItems'
+ | 'hasChildItems'
+>;
+
+export type TagProps = AriaTagItemProps & {
+ /** Additional CSS-classes. */
+ className?: string;
+ /** Inline styles. */
+ style?: CSSProperties;
+ /** Unique identifier for testing purposes. */
+ 'data-testid'?: string | number;
+ /** Icon placed before the children. */
+ icon?: ReactNode;
+ /** Whether the tag is disabled. */
+ isDisabled?: boolean;
+ /** Visual variant for this tag. */
+ variant?: TagListPropVariant;
+ /** The props used for each slot inside. */
+ slotProps?: TagSlotProps;
+ /** Rendered contents of the tag. */
+ children?: ReactNode;
+};
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export function Tag(_props: TagProps) {
+ return null;
+}
+
+Tag.getCollectionNode = TagInner.getCollectionNode;
diff --git a/packages/components/src/components/TagList/TagList.mdx b/packages/components/src/components/TagList/TagList.mdx
new file mode 100644
index 00000000..30f64767
--- /dev/null
+++ b/packages/components/src/components/TagList/TagList.mdx
@@ -0,0 +1,126 @@
+import {
+ Props,
+ Story,
+ Meta,
+ Status,
+} from '../../../../../.storybook/components';
+
+import * as Stories from './TagList.stories';
+
+
+
+# TagList
+
+
+
+A focusable tag list with explicit modifier-based selection, designed as
+a foundation for `TagInput`, `TagAutocomplete` and multi-select composers.
+
+## Import
+
+```tsx
+import { TagList } from '@koobiq/react-components';
+```
+
+## Usage
+
+
+
+## Props
+
+
+
+## Selection
+
+TagList keeps pointer selection explicit: a regular click only focuses a tag.
+To select tags, use Ctrl / Cmd + click, Ctrl / Cmd + A , or press Space while a tag is focused.
+
+
+
+## Controlled selection
+
+When you need the parent to own selection (form binding, syncing with
+external state, derived UI), pass `selectedKeys` and `onSelectionChange`.
+`selectedKeys` takes a `Set` or the literal `'all'`. For uncontrolled
+use, switch to `defaultSelectedKeys`.
+
+
+
+## Variant
+
+To change the visual state of all tags, use the `variant` prop on `TagList`.
+
+An individual `TagList.Tag` can set its own `variant`.
+
+
+
+## Icon
+
+You can add an icon to each tag by using the `icon` prop.
+
+Prop expects an icon component from our [icon library](?path=/docs/icons--docs).
+
+
+
+## Remove tags
+
+The `onRemove` prop can be used to include a remove button which can be used to remove a tag.
+This allows the user to press the remove button, or press Backspace / Delete while the tag
+is focused to remove the tag from the group. If the focused tag is part of the current
+selection, every selected tag is removed in one call.
+
+The remove button's `aria-label` and the screen-reader hint announcing the
+
+Backspace / Delete shortcut are both localized — pass a
+`locale` to `Provider` to switch between languages. The hint is only attached
+for keyboard / assistive modalities, so pointer users won't hear redundant
+guidance.
+
+
+
+## Disabled tags
+
+TagList supports marking items as disabled using the `disabledKeys` prop.
+Each key in this group corresponds with the `key` prop passed to the `TagList.Tag` component,
+or automatically derived from the values passed to the `items` prop.
+
+
+
+## Accessibility
+
+- The root element uses `role="grid"` when the collection has at least one tag,
+ and `role="group"` when empty. Each tag is a `role="row"` containing a single
+ `role="gridcell"`.
+- `aria-selected` is exposed on a tag only when `selectionMode !== 'none'`.
+- When `onRemove` is provided, every removable tag carries an
+ `aria-describedby` link to a screen-reader-only message announcing the
+ Backspace / Delete shortcut. The hint is attached only
+ for keyboard / virtual interaction modalities, so pointer users won't hear
+ redundant guidance.
+- The remove button's `aria-label` and the screen-reader hint are localized
+ through the active ``.
+
+## Keyboard
+
+| Key | Behavior |
+| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
+| → / ← | Move focus to the next / previous tag (respects RTL direction). |
+| Home / End | Move focus to the first / last tag. |
+| Space | Toggle selection on the focused tag. |
+| Ctrl / Cmd + click | Toggle selection without clearing the previous one. |
+| Ctrl / Cmd + A | Select all tags. |
+| Escape | Clear selection. Override with `escapeKeyBehavior="none"`. |
+| Backspace / Delete | Remove the focused tag, or every selected tag if the focused tag is part of the selection. Requires `onRemove`. |
+| Tab | Move focus into / out of the group as a single tab stop. |
+
+## CSS Variables
+
+Use CSS variables to customize the appearance of each tag.
+
+| Variable |
+| ------------------------- |
+| `--kbq-tag-color` |
+| `--kbq-tag-bg-color` |
+| `--kbq-tag-icon-color` |
+| `--kbq-tag-outline-color` |
+| `--kbq-tag-outline-width` |
diff --git a/packages/components/src/components/TagList/TagList.module.css b/packages/components/src/components/TagList/TagList.module.css
new file mode 100644
index 00000000..af2884f5
--- /dev/null
+++ b/packages/components/src/components/TagList/TagList.module.css
@@ -0,0 +1,10 @@
+.base {
+ flex-wrap: wrap;
+ gap: var(--kbq-size-xxs);
+ display: flex;
+ outline: none;
+}
+
+.base [role='gridcell'] {
+ display: contents;
+}
diff --git a/packages/components/src/components/TagList/TagList.stories.tsx b/packages/components/src/components/TagList/TagList.stories.tsx
new file mode 100644
index 00000000..68f261df
--- /dev/null
+++ b/packages/components/src/components/TagList/TagList.stories.tsx
@@ -0,0 +1,239 @@
+import { useState } from 'react';
+
+import { IconGlobe16 } from '@koobiq/react-icons';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { FlexBox } from '../FlexBox';
+import { Typography, useListData } from '../index';
+
+import { TagList } from './TagList';
+import { tagListPropVariant } from './types';
+
+const meta = {
+ title: 'Components/TagList',
+ component: TagList,
+ subcomponents: { 'TagList.Tag': TagList.Tag },
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['status:new', 'date:2026-05-22'],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Base: Story = {
+ render: (args) => (
+
+ React
+ Typescript
+ Storybook
+ Tailwind
+
+ ),
+};
+
+export const Variant: Story = {
+ render: (args) => (
+
+ {tagListPropVariant.map((variant) => (
+ undefined}
+ {...args}
+ >
+ React
+ Typescript
+ Storybook
+ Tailwind
+
+ ))}
+
+ ),
+};
+
+export const ModifierSelection: Story = {
+ render: (args) => (
+
+ React
+ Typescript
+ Storybook
+ Tailwind
+
+ ),
+};
+
+export const RemoveTags: Story = {
+ render: function Render(args) {
+ const list = useListData<{ id: number; name: string }>({
+ initialItems: [
+ { id: 1, name: 'React' },
+ { id: 2, name: 'Typescript' },
+ { id: 3, name: 'Storybook' },
+ { id: 4, name: 'Tailwind' },
+ ],
+ });
+
+ return (
+
+ items={list.items}
+ disabledKeys={[4]}
+ aria-label="Libraries"
+ selectionMode="multiple"
+ onRemove={(keys) => {
+ args.onRemove?.(keys);
+ list.remove(...keys);
+ }}
+ >
+ {(item) => {item.name} }
+
+ );
+ },
+};
+
+export const DisabledTags: Story = {
+ render: (args) => (
+
+ GET
+ POST
+ PUT
+ PATCH
+ DELETE
+
+ ),
+};
+
+export const Icon: Story = {
+ render: (args) => (
+
+ }>
+ GET
+
+ }>
+ POST
+
+ }>
+ PUT
+
+ }>
+ PATCH
+
+ }>
+ DELETE
+
+
+ ),
+};
+
+export const ControlledSelection: Story = {
+ render: function Render() {
+ const [selected, setSelected] = useState>(
+ new Set(['react'])
+ );
+
+ return (
+
+
+ setSelected(
+ keys === 'all'
+ ? new Set(['react', 'typescript', 'storybook', 'tailwind'])
+ : new Set(keys)
+ )
+ }
+ >
+ React
+ Typescript
+ Storybook
+ Tailwind
+
+
+ Selected: {[...selected].join(', ') || '(none)'}
+
+
+ );
+ },
+};
+
+export const LongTextEllipsis: Story = {
+ render: () => (
+
+ undefined}>
+
+ A very long tag value that should be truncated with an ellipsis
+
+
+ Another extremely lengthy entry inside a narrow container
+
+ Short
+
+
+ ),
+};
+
+export const EmptyState: Story = {
+ render: () => (
+
+ aria-label="Empty list"
+ items={[] as Iterable<{ id: string }>}
+ >
+ {(item) => {item.id} }
+
+ ),
+};
+
+export const DynamicItems: Story = {
+ render: function Render() {
+ const list = useListData<{ id: string; name: string }>({
+ initialItems: [
+ { id: 'react', name: 'React' },
+ { id: 'typescript', name: 'Typescript' },
+ ],
+ });
+
+ const [counter, setCounter] = useState(1);
+
+ return (
+
+
+ {
+ const id = `item-${counter}`;
+ list.append({ id, name: `Item ${counter}` });
+ setCounter((n) => n + 1);
+ }}
+ >
+ Add item
+
+ {
+ const last = list.items[list.items.length - 1];
+ if (last) list.remove(last.id);
+ }}
+ >
+ Remove last
+
+
+
+ aria-label="Dynamic"
+ items={list.items}
+ onRemove={(keys) => list.remove(...keys)}
+ >
+ {(item) => {item.name} }
+
+
+ );
+ },
+};
diff --git a/packages/components/src/components/TagList/TagList.test.tsx b/packages/components/src/components/TagList/TagList.test.tsx
new file mode 100644
index 00000000..c6e3f201
--- /dev/null
+++ b/packages/components/src/components/TagList/TagList.test.tsx
@@ -0,0 +1,540 @@
+import { createRef, useState } from 'react';
+
+import { isInteractiveTarget } from '@koobiq/react-primitives';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+import { Provider } from '../Provider';
+
+import { TagList, type TagListProps } from './index';
+
+const TAG_LIST_TEST_ID = 'TAG_LIST_TAG';
+
+const renderComponent = (props: Omit, 'children'>) => (
+
+ one
+
+ two
+
+ three
+ four
+
+);
+
+const removableItems = [
+ { id: '1', name: 'one' },
+ { id: '2', name: 'two' },
+ { id: '3', name: 'three' },
+ { id: '4', name: 'four' },
+];
+
+function RemovableTagList() {
+ const [items, setItems] = useState(removableItems);
+
+ return (
+
+ aria-label="removable-tag-list"
+ selectionMode="multiple"
+ items={items}
+ onRemove={(keys) => {
+ setItems((currentItems) =>
+ currentItems.filter((item) => !keys.has(item.id))
+ );
+ }}
+ >
+ {(item) => (
+
+ {item.name}
+
+ )}
+
+ );
+}
+
+describe('TagList', () => {
+ const getTag = () => screen.getByTestId(TAG_LIST_TEST_ID);
+
+ it('should detect nested focusable interaction targets', () => {
+ const root = document.createElement('div');
+ const button = document.createElement('button');
+ const buttonIcon = document.createElement('span');
+ const disabledButton = document.createElement('button');
+
+ disabledButton.disabled = true;
+ button.append(buttonIcon);
+ root.append(button, disabledButton);
+ document.body.append(root);
+
+ try {
+ expect(isInteractiveTarget(buttonIcon, root)).toBe(true);
+ expect(isInteractiveTarget(button, root)).toBe(true);
+ expect(isInteractiveTarget(root, root)).toBe(false);
+ expect(isInteractiveTarget(disabledButton, root)).toBe(false);
+ } finally {
+ root.remove();
+ }
+ });
+
+ it('should not select on plain click', async () => {
+ const onSelectionChange = vi.fn();
+
+ render(
+ renderComponent({
+ selectionMode: 'multiple',
+ onSelectionChange,
+ })
+ );
+
+ await userEvent.click(getTag());
+
+ expect(onSelectionChange).not.toHaveBeenCalled();
+ expect(getTag()).not.toHaveAttribute('data-selected');
+ });
+
+ it('should toggle selection on ctrl+click', async () => {
+ const user = userEvent.setup();
+ const onSelectionChange = vi.fn();
+
+ render(
+ renderComponent({
+ selectionMode: 'multiple',
+ onSelectionChange,
+ })
+ );
+
+ await user.keyboard('{Control>}');
+ await user.click(getTag());
+ await user.keyboard('{/Control}');
+
+ expect(onSelectionChange).toHaveBeenCalledTimes(1);
+ expect(getTag()).toHaveAttribute('data-selected', 'true');
+ });
+
+ it('should toggle selection on cmd+click', async () => {
+ const user = userEvent.setup();
+ const onSelectionChange = vi.fn();
+
+ render(
+ renderComponent({
+ selectionMode: 'multiple',
+ onSelectionChange,
+ })
+ );
+
+ await user.keyboard('{Meta>}');
+ await user.click(getTag());
+ await user.keyboard('{/Meta}');
+
+ expect(onSelectionChange).toHaveBeenCalledTimes(1);
+ expect(getTag()).toHaveAttribute('data-selected', 'true');
+ });
+
+ it('should select focused tag on Space', async () => {
+ const user = userEvent.setup();
+ const onSelectionChange = vi.fn();
+
+ render(
+ renderComponent({
+ selectionMode: 'multiple',
+ onSelectionChange,
+ })
+ );
+
+ await user.click(getTag());
+ await user.keyboard('{Space}');
+
+ expect(onSelectionChange).toHaveBeenCalledTimes(1);
+ expect(getTag()).toHaveAttribute('data-selected', 'true');
+ });
+
+ it('should move focus with arrow keys', async () => {
+ const user = userEvent.setup();
+
+ render(renderComponent({ selectionMode: 'multiple' }));
+
+ await user.click(getTag());
+ await user.keyboard('{ArrowRight}');
+
+ await waitFor(() =>
+ expect(screen.getByText('three').closest('[role="row"]')).toHaveFocus()
+ );
+
+ await user.keyboard('{ArrowLeft}');
+
+ await waitFor(() => expect(getTag()).toHaveFocus());
+ });
+
+ it('should not wrap focus past the last tag with ArrowRight', async () => {
+ const user = userEvent.setup();
+
+ render(renderComponent({ selectionMode: 'multiple' }));
+
+ const lastTag = screen.getByText('four').closest('[role="row"]');
+
+ await user.click(lastTag as HTMLElement);
+ await user.keyboard('{ArrowRight}');
+
+ await waitFor(() => expect(lastTag).toHaveFocus());
+ });
+
+ it('should not wrap focus past the first tag with ArrowLeft', async () => {
+ const user = userEvent.setup();
+
+ render(renderComponent({ selectionMode: 'multiple' }));
+
+ const firstTag = screen.getByText('one').closest('[role="row"]');
+
+ await user.click(firstTag as HTMLElement);
+ await user.keyboard('{ArrowLeft}');
+
+ await waitFor(() => expect(firstTag).toHaveFocus());
+ });
+
+ it('should not select focused tag on Enter', async () => {
+ const user = userEvent.setup();
+ const onSelectionChange = vi.fn();
+
+ render(
+ renderComponent({
+ selectionMode: 'multiple',
+ onSelectionChange,
+ })
+ );
+
+ await user.click(getTag());
+ await user.keyboard('{Enter}');
+
+ expect(onSelectionChange).not.toHaveBeenCalled();
+ expect(getTag()).not.toHaveAttribute('data-selected');
+ });
+
+ it('should select all tags on Ctrl+A', async () => {
+ const user = userEvent.setup();
+ const onSelectionChange = vi.fn();
+
+ render(
+ renderComponent({
+ selectionMode: 'multiple',
+ onSelectionChange,
+ })
+ );
+
+ await user.click(getTag());
+ await user.keyboard('{Control>}a{/Control}');
+
+ expect(onSelectionChange).toHaveBeenCalledTimes(1);
+
+ screen
+ .getAllByRole('row')
+ .forEach((tag) => expect(tag).toHaveAttribute('aria-selected', 'true'));
+ });
+
+ it('should keep selection when focus moves inside tag group', async () => {
+ const user = userEvent.setup();
+
+ render(renderComponent({ selectionMode: 'multiple' }));
+
+ await user.click(getTag());
+ await user.keyboard('{Space}');
+ const firstRow = screen.getAllByRole('row')[0];
+ if (firstRow) await user.click(firstRow);
+
+ expect(getTag()).toHaveAttribute('data-selected', 'true');
+ });
+
+ it('should let an individual tag override the parent variant', () => {
+ render(
+
+ Default
+
+ Custom
+
+
+ );
+
+ expect(screen.getByText('Default').closest('[role="row"]')).toHaveAttribute(
+ 'data-variant',
+ 'theme-fade'
+ );
+
+ expect(screen.getByText('Custom').closest('[role="row"]')).toHaveAttribute(
+ 'data-variant',
+ 'error-fade'
+ );
+ });
+
+ it('should clear selection when focus leaves tag group', async () => {
+ const user = userEvent.setup();
+
+ render(
+ <>
+ {renderComponent({ selectionMode: 'multiple' })}
+ outside
+ >
+ );
+
+ await user.click(getTag());
+ await user.keyboard('{Space}');
+ await user.click(screen.getByTestId('outside'));
+
+ expect(getTag()).not.toHaveAttribute('data-selected');
+ });
+
+ it('should remove focused tag after selected tags were removed', async () => {
+ const user = userEvent.setup();
+
+ render( );
+
+ await user.keyboard('{Control>}');
+ await user.click(screen.getByTestId('tag-2'));
+ await user.click(screen.getByTestId('tag-3'));
+ await user.keyboard('{/Control}');
+
+ const selectedTag = screen.getByTestId('tag-3');
+
+ await user.click(selectedTag);
+ await user.keyboard('{Delete}');
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('tag-2')).toBeNull();
+ expect(screen.queryByTestId('tag-3')).toBeNull();
+ });
+
+ const focusedTag = screen.getByTestId('tag-4');
+
+ await user.click(focusedTag);
+ await user.keyboard('{Delete}');
+
+ await waitFor(() => expect(screen.queryByTestId('tag-4')).toBeNull());
+ });
+
+ it('should localize the remove button aria-label (en-US by default)', () => {
+ const { container } = render(
+ renderComponent({
+ onRemove: vi.fn(),
+ })
+ );
+
+ const buttons = container.querySelectorAll('button[aria-label="Remove"]');
+ expect(buttons.length).toBeGreaterThan(0);
+ });
+
+ it('should localize the remove button aria-label under ru-RU locale', () => {
+ const { container } = render(
+
+ {renderComponent({ onRemove: vi.fn() })}
+
+ );
+
+ const buttons = container.querySelectorAll('button[aria-label="Удалить"]');
+ expect(buttons.length).toBeGreaterThan(0);
+ });
+
+ it('should not add aria-describedby when onRemove is not provided', () => {
+ render(renderComponent({}));
+
+ expect(getTag()).not.toHaveAttribute('aria-describedby');
+ });
+
+ it('should announce the removal shortcut to screen readers after keyboard interaction', async () => {
+ const user = userEvent.setup();
+
+ render(renderComponent({ onRemove: vi.fn() }));
+
+ // Force keyboard modality — without a user keyboard event the modality
+ // stays at its initial value and the description is intentionally not
+ // attached (pointer users see the visible remove button).
+ await user.tab();
+
+ await waitFor(() => {
+ const tag = getTag();
+ const describedBy = tag.getAttribute('aria-describedby');
+ expect(describedBy).toBeTruthy();
+
+ const description = document.getElementById(describedBy ?? '');
+ expect(description?.textContent).toMatch(/delete or backspace/i);
+ });
+ });
+
+ it('should auto-focus the first tag when autoFocus="first"', async () => {
+ render(renderComponent({ autoFocus: 'first' }));
+
+ await waitFor(() =>
+ expect(screen.getByText('one').closest('[role="row"]')).toHaveFocus()
+ );
+ });
+
+ it('should auto-focus the last tag when autoFocus="last"', async () => {
+ render(renderComponent({ autoFocus: 'last' }));
+
+ await waitFor(() =>
+ expect(screen.getByText('four').closest('[role="row"]')).toHaveFocus()
+ );
+ });
+
+ it('should reflect controlled selectedKeys from props', () => {
+ render(
+ renderComponent({
+ selectionMode: 'multiple',
+ // React stringifies element keys, so the collection node id is '2'.
+ selectedKeys: new Set(['2']),
+ })
+ );
+
+ expect(getTag()).toHaveAttribute('aria-selected', 'true');
+ expect(getTag()).toHaveAttribute('data-selected', 'true');
+ });
+
+ it('should call onSelectionChange when user toggles a controlled key', async () => {
+ const user = userEvent.setup();
+ const onSelectionChange = vi.fn();
+
+ render(
+ renderComponent({
+ selectionMode: 'multiple',
+ selectedKeys: new Set(),
+ onSelectionChange,
+ })
+ );
+
+ await user.keyboard('{Control>}');
+ await user.click(getTag());
+ await user.keyboard('{/Control}');
+
+ expect(onSelectionChange).toHaveBeenCalledTimes(1);
+ const next = onSelectionChange.mock.calls[0]?.[0] as Set;
+ expect([...next]).toContain('2');
+ });
+
+ it('should keep selection on Escape when escapeKeyBehavior="none"', async () => {
+ const user = userEvent.setup();
+
+ render(
+ renderComponent({
+ selectionMode: 'multiple',
+ escapeKeyBehavior: 'none',
+ })
+ );
+
+ await user.click(getTag());
+ await user.keyboard('{Space}');
+ expect(getTag()).toHaveAttribute('data-selected', 'true');
+
+ await user.keyboard('{Escape}');
+ expect(getTag()).toHaveAttribute('data-selected', 'true');
+ });
+
+ it('should render role="group" when collection is empty', () => {
+ render(
+ }>
+ {() => unused }
+
+ );
+
+ expect(screen.getByRole('group')).toBeInTheDocument();
+ expect(screen.queryByRole('grid')).toBeNull();
+ });
+
+ it('should react to items prop mutations', () => {
+ type Item = { id: string; name: string };
+
+ const initial: Item[] = [
+ { id: '1', name: 'one' },
+ { id: '2', name: 'two' },
+ ];
+
+ const extended: Item[] = [...initial, { id: '3', name: 'three' }];
+
+ const Wrapper = ({ items }: { items: Item[] }) => (
+ aria-label="mutating" items={items}>
+ {(item) => (
+
+ {item.name}
+
+ )}
+
+ );
+
+ const { rerender } = render( );
+ expect(screen.queryByTestId('tag-3')).toBeNull();
+
+ rerender( );
+ expect(screen.getByTestId('tag-3')).toBeInTheDocument();
+ });
+
+ it('should render a disabled remove button on a disabled tag', async () => {
+ const onRemove = vi.fn();
+
+ render(
+ renderComponent({
+ onRemove,
+ disabledKeys: ['2'],
+ })
+ );
+
+ // The affordance stays — the button is rendered but in a disabled
+ // state. Every tag in the fixture still gets its remove button.
+ const disabledTag = getTag();
+ const button = within(disabledTag).getByRole('button');
+ expect(button).toBeDisabled();
+ expect(screen.queryAllByRole('button')).toHaveLength(4);
+
+ // Clicking the disabled button must not trigger onRemove.
+ await userEvent.click(button);
+ expect(onRemove).not.toHaveBeenCalled();
+ });
+
+ describe('prop forwarding', () => {
+ it('should forward className to the root element', () => {
+ const { container } = render(
+ renderComponent({ className: 'custom-root' })
+ );
+
+ expect(container.firstElementChild).toHaveClass('custom-root');
+ });
+
+ it('should forward style to the root element', () => {
+ const { container } = render(renderComponent({ style: { padding: 20 } }));
+
+ expect(container.firstElementChild).toHaveStyle({ padding: '20px' });
+ });
+
+ it('should forward ref to the root element', () => {
+ const ref = createRef();
+ const { container } = render(renderComponent({ ref }));
+
+ expect(ref.current).toBe(container.firstElementChild);
+ });
+
+ it('should forward data-testid to the root element', () => {
+ render(renderComponent({ 'data-testid': 'TAG_LIST_ROOT' }));
+
+ expect(screen.getByTestId('TAG_LIST_ROOT')).toBeInTheDocument();
+ });
+
+ it('should forward className to a Tag element', () => {
+ render(
+
+
+ one
+
+
+ );
+
+ expect(screen.getByTestId('single-tag')).toHaveClass('tag-class');
+ });
+
+ it('should forward style to a Tag element', () => {
+ render(
+
+
+ one
+
+
+ );
+
+ expect(screen.getByTestId('single-tag')).toHaveStyle({ padding: '5px' });
+ });
+ });
+});
diff --git a/packages/components/src/components/TagList/TagList.tsx b/packages/components/src/components/TagList/TagList.tsx
new file mode 100644
index 00000000..8b8c80a6
--- /dev/null
+++ b/packages/components/src/components/TagList/TagList.tsx
@@ -0,0 +1,33 @@
+'use client';
+
+import { forwardRef } from 'react';
+import type { Ref } from 'react';
+
+import { useTagListState } from '@koobiq/react-primitives';
+
+import { Tag } from './Tag';
+import { TagListInner } from './TagListInner';
+import type { TagListComponent, TagListProps } from './types';
+
+function TagListRender(
+ props: TagListProps,
+ ref?: Ref
+) {
+ const state = useTagListState(props);
+
+ return ;
+}
+
+const TagListComponent = forwardRef(TagListRender) as TagListComponent;
+
+type CompoundedComponent = typeof TagListComponent & {
+ Tag: typeof Tag;
+};
+
+/**
+ * A focusable tag list with explicit modifier-based selection, designed as
+ * a foundation for `TagInput`, `TagAutocomplete` and multi-select composers.
+ */
+export const TagList = TagListComponent as CompoundedComponent;
+
+TagList.Tag = Tag;
diff --git a/packages/components/src/components/TagList/TagListInner.tsx b/packages/components/src/components/TagList/TagListInner.tsx
new file mode 100644
index 00000000..cd7e85cb
--- /dev/null
+++ b/packages/components/src/components/TagList/TagListInner.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import { clsx, mergeProps, useDOMRef } from '@koobiq/react-core';
+import { useTagList } from '@koobiq/react-primitives';
+
+import { TagItem } from './components';
+import s from './TagList.module.css';
+import type { TagListInnerProps } from './types';
+
+export function TagListInner(props: TagListInnerProps) {
+ const {
+ variant = 'theme-fade',
+ state,
+ onRemove,
+ className,
+ isDisabled,
+ autoFocus,
+ tagListRef,
+ escapeKeyBehavior,
+ tabIndex,
+ style,
+ 'data-testid': testId,
+ 'aria-label': ariaLabel,
+ } = props;
+
+ const domRef = useDOMRef(tagListRef);
+
+ const { gridProps, collectionId } = useTagList(
+ { escapeKeyBehavior, autoFocus },
+ state,
+ domRef
+ );
+
+ const rootProps = mergeProps(
+ {
+ style,
+ ref: domRef,
+ 'data-testid': testId,
+ 'aria-label': ariaLabel,
+ className: clsx(s.base, className),
+ 'data-disabled': isDisabled || undefined,
+ },
+ gridProps,
+ { tabIndex }
+ );
+
+ return (
+
+ {[...state.collection].map((item) => (
+
+ ))}
+
+ );
+}
diff --git a/packages/components/src/components/TagList/components/TagItem/TagItem.module.css b/packages/components/src/components/TagList/components/TagItem/TagItem.module.css
new file mode 100644
index 00000000..c7c0fabc
--- /dev/null
+++ b/packages/components/src/components/TagList/components/TagItem/TagItem.module.css
@@ -0,0 +1,200 @@
+@import url('../../../../styles/mixins.css');
+
+.base {
+ --tag-color: ;
+ --tag-bg-color: ;
+ --tag-icon-color: ;
+ --tag-outline-color: transparent;
+ --tag-outline-width: var(--kbq-size-3xs);
+
+ border: none;
+ max-inline-size: 100%;
+ cursor: default;
+ align-items: center;
+ vertical-align: top;
+ display: inline-flex;
+ text-decoration: none;
+ box-sizing: border-box;
+ color: var(--kbq-tag-color, var(--tag-color));
+ gap: var(--kbq-size-3xs);
+ block-size: var(--kbq-size-xxl);
+ border-radius: var(--kbq-size-xxs);
+ padding-inline: var(--kbq-size-xxs);
+ background-color: var(--kbq-tag-bg-color, var(--tag-bg-color));
+ outline-offset: calc(
+ -1 * var(--kbq-tag-outline-width, var(--tag-outline-width)) / 2
+ );
+ outline: var(--kbq-tag-outline-width, var(--tag-outline-width)) solid
+ var(--kbq-tag-outline-color, var(--tag-outline-color));
+ transition:
+ outline-color var(--kbq-transition-default),
+ box-shadow var(--kbq-transition-default),
+ background-color var(--kbq-transition-default),
+ color var(--kbq-transition-default);
+}
+
+.content {
+ @mixin ellipsis;
+
+ max-inline-size: 100%;
+ margin-inline: var(--kbq-size-3xs);
+}
+
+.icon {
+ display: flex;
+ flex: none;
+ align-items: center;
+ justify-content: center;
+ color: var(--kbq-tag-icon-color, var(--tag-icon-color));
+ margin-inline-start: var(--kbq-size-3xs);
+}
+
+.cancelIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-inline-end: var(--kbq-size-3xs);
+}
+
+.theme-fade {
+ --tag-icon-color: var(--kbq-icon-theme);
+ --tag-bg-color: var(--kbq-background-theme-fade);
+ --tag-color: var(--kbq-foreground-theme);
+}
+
+.contrast-fade {
+ --tag-icon-color: var(--kbq-icon-contrast-fade);
+ --tag-bg-color: var(--kbq-background-contrast-fade);
+ --tag-color: var(--kbq-foreground-contrast);
+}
+
+.error-fade {
+ --tag-icon-color: var(--kbq-icon-error);
+ --tag-bg-color: var(--kbq-background-error-fade);
+ --tag-color: var(--kbq-foreground-error);
+}
+
+.warning-fade {
+ --tag-icon-color: var(--kbq-icon-warning);
+ --tag-bg-color: var(--kbq-background-warning-fade);
+ --tag-color: var(--kbq-foreground-warning);
+}
+
+/* hovered */
+.theme-fade:where(.hovered) {
+ --tag-bg-color: var(--kbq-states-background-theme-fade-hover);
+}
+
+.contrast-fade:where(.hovered) {
+ --tag-bg-color: var(--kbq-states-background-contrast-fade-hover);
+}
+
+.error-fade:where(.hovered) {
+ --tag-bg-color: var(--kbq-states-background-error-fade-hover);
+}
+
+.warning-fade:where(.hovered) {
+ --tag-bg-color: var(--kbq-states-background-warning-fade-hover);
+}
+
+/* selected */
+.theme-fade:where(.selected) {
+ --tag-bg-color: var(--kbq-background-theme);
+ --tag-color: var(--kbq-foreground-white);
+ --tag-icon-color: var(--kbq-icon-white);
+
+ .cancelIcon {
+ --icon-button-color: var(--kbq-icon-white);
+ --icon-button-color-hover: var(--kbq-icon-white);
+ --icon-button-color-active: var(--kbq-icon-white);
+ }
+}
+
+.contrast-fade:where(.selected) {
+ --tag-bg-color: var(--kbq-background-theme);
+ --tag-color: var(--kbq-foreground-white);
+ --tag-icon-color: var(--kbq-icon-white);
+
+ .cancelIcon {
+ --icon-button-color: var(--kbq-icon-white);
+ --icon-button-color-hover: var(--kbq-icon-white);
+ --icon-button-color-active: var(--kbq-icon-white);
+ }
+}
+
+.error-fade:where(.selected) {
+ --tag-bg-color: var(--kbq-background-error);
+ --tag-color: var(--kbq-foreground-white);
+ --tag-icon-color: var(--kbq-icon-white);
+
+ .cancelIcon {
+ --icon-button-color: var(--kbq-icon-white);
+ --icon-button-color-hover: var(--kbq-icon-white);
+ --icon-button-color-active: var(--kbq-icon-white);
+ }
+}
+
+.warning-fade:where(.selected) {
+ --tag-bg-color: var(--kbq-background-warning);
+ --tag-color: var(--kbq-foreground-contrast);
+ --tag-icon-color: var(--kbq-icon-contrast);
+
+ .cancelIcon {
+ --icon-button-color: var(--kbq-icon-contrast);
+ --icon-button-color-hover: var(--kbq-icon-contrast);
+ --icon-button-color-active: var(--kbq-icon-contrast);
+ }
+}
+
+/* selected + hovered */
+.theme-fade:where(.selected.hovered) {
+ --tag-bg-color: var(--kbq-states-background-theme-hover);
+ --tag-color: var(--kbq-foreground-white);
+}
+
+.contrast-fade:where(.selected.hovered) {
+ --tag-bg-color: var(--kbq-states-background-theme-hover);
+ --tag-color: var(--kbq-foreground-white);
+}
+
+.error-fade:where(.selected.hovered) {
+ --tag-bg-color: var(--kbq-states-background-error-hover);
+ --tag-color: var(--kbq-foreground-white);
+}
+
+.warning-fade:where(.selected.hovered) {
+ --tag-bg-color: var(--kbq-background-warning);
+ --tag-color: var(--kbq-foreground-contrast);
+}
+
+/* focus-visible */
+.theme-fade:where(.focusVisible) {
+ --tag-outline-color: var(--kbq-states-line-focus-theme);
+}
+
+.contrast-fade:where(.focusVisible) {
+ --tag-outline-color: var(--kbq-states-line-focus-theme);
+}
+
+.error-fade:where(.focusVisible) {
+ --tag-outline-color: var(--kbq-states-line-focus-error);
+}
+
+.warning-fade:where(.focusVisible) {
+ --tag-outline-color: var(--kbq-states-line-focus-theme);
+}
+
+/* focus-visible + selected */
+.focusVisible.selected {
+ box-shadow: inset 0 0 0 2px var(--kbq-background-bg);
+}
+
+/* disabled */
+.disabled {
+ --tag-icon-color: ;
+ --tag-bg-color: var(--kbq-states-background-disabled);
+ --tag-color: var(--kbq-states-foreground-disabled);
+ --tag-outline-color: none;
+
+ cursor: default;
+}
diff --git a/packages/components/src/components/TagList/components/TagItem/TagItem.tsx b/packages/components/src/components/TagList/components/TagItem/TagItem.tsx
new file mode 100644
index 00000000..20b22c01
--- /dev/null
+++ b/packages/components/src/components/TagList/components/TagItem/TagItem.tsx
@@ -0,0 +1,140 @@
+import { useRef } from 'react';
+
+import type { Key, Node as CollectionNode } from '@koobiq/react-core';
+import {
+ clsx,
+ isNotNil,
+ mergeProps,
+ useFocusRing,
+ useHover,
+} from '@koobiq/react-core';
+import { IconXmarkS16 } from '@koobiq/react-icons';
+import type { ListState } from '@koobiq/react-primitives';
+import { useTagListItem } from '@koobiq/react-primitives';
+
+import { utilClasses } from '../../../../styles/utility';
+import { IconButton } from '../../../IconButton';
+import type { IconButtonProps } from '../../../IconButton';
+import type { TagProps } from '../../Tag';
+import type { TagListPropVariant } from '../../types';
+
+import s from './TagItem.module.css';
+import { matchVariantToIconButton } from './utils';
+
+type TagItemProps = {
+ state: ListState;
+ item: CollectionNode;
+ variant: TagListPropVariant;
+ onRemove?: (keys: Set) => void;
+ isDisabled?: boolean;
+ collectionId?: string;
+};
+
+const textNormalMedium = utilClasses.typography['text-normal-medium'];
+
+export function TagItem(props: TagItemProps) {
+ const {
+ item,
+ onRemove,
+ state,
+ isDisabled: isDisabledProp,
+ variant: groupVariant,
+ collectionId,
+ } = props;
+
+ const itemProps = item.props as TagProps;
+ const variant = itemProps.variant ?? groupVariant;
+ const ref = useRef(null);
+
+ const {
+ rowProps,
+ isPressed,
+ isSelected,
+ isDisabled,
+ gridCellProps,
+ allowsRemoving,
+ removeButtonProps: removeButtonPropsAria,
+ } = useTagListItem(
+ {
+ key: item.key,
+ onRemove,
+ isDisabled: isDisabledProp,
+ collectionId,
+ },
+ state,
+ ref
+ );
+
+ const { focusProps, isFocusVisible, isFocused } = useFocusRing({
+ within: false,
+ });
+
+ const { hoverProps, isHovered } = useHover({ isDisabled });
+
+ const {
+ icon,
+ style,
+ className,
+ slotProps,
+ 'data-testid': testId,
+ } = itemProps;
+
+ const rootProps = mergeProps(
+ rowProps,
+ hoverProps,
+ focusProps,
+ slotProps?.root,
+ {
+ style,
+ className: clsx(
+ s.base,
+ s[variant],
+ textNormalMedium,
+ isHovered && s.hovered,
+ isSelected && s.selected,
+ isDisabled && s.disabled,
+ isFocusVisible && s.focusVisible,
+ className
+ ),
+ 'data-testid': testId,
+ 'data-variant': variant,
+ 'data-focused': isFocused || undefined,
+ 'data-pressed': isPressed || undefined,
+ 'data-hovered': isHovered || undefined,
+ 'data-selected': isSelected || undefined,
+ 'data-disabled': isDisabled || undefined,
+ 'data-focus-visible': isFocusVisible || undefined,
+ }
+ );
+
+ const removeButtonProps = mergeProps<
+ [IconButtonProps, IconButtonProps | undefined, IconButtonProps]
+ >(
+ {
+ isCompact: true,
+ className: s.cancelIcon,
+ variant: matchVariantToIconButton[variant],
+ },
+ slotProps?.removeIcon,
+ removeButtonPropsAria
+ );
+
+ const contentProps = mergeProps({ className: s.content }, slotProps?.content);
+ const iconProps = mergeProps({ className: s.icon }, slotProps?.icon);
+
+ return (
+
+
+ {isNotNil(icon) && {icon} }
+ {isNotNil(item.rendered) && (
+ {item.rendered}
+ )}
+ {allowsRemoving && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/packages/components/src/components/TagList/components/TagItem/index.ts b/packages/components/src/components/TagList/components/TagItem/index.ts
new file mode 100644
index 00000000..afb0315a
--- /dev/null
+++ b/packages/components/src/components/TagList/components/TagItem/index.ts
@@ -0,0 +1 @@
+export * from './TagItem';
diff --git a/packages/components/src/components/TagList/components/TagItem/utils.ts b/packages/components/src/components/TagList/components/TagItem/utils.ts
new file mode 100644
index 00000000..7beac353
--- /dev/null
+++ b/packages/components/src/components/TagList/components/TagItem/utils.ts
@@ -0,0 +1,12 @@
+import type { IconButtonPropVariant } from '../../../IconButton';
+import type { TagListPropVariant } from '../../types';
+
+export const matchVariantToIconButton: Record<
+ TagListPropVariant,
+ IconButtonPropVariant
+> = {
+ 'theme-fade': 'theme',
+ 'contrast-fade': 'fade-contrast',
+ 'error-fade': 'error',
+ 'warning-fade': 'warning',
+};
diff --git a/packages/components/src/components/TagList/components/index.ts b/packages/components/src/components/TagList/components/index.ts
new file mode 100644
index 00000000..afb0315a
--- /dev/null
+++ b/packages/components/src/components/TagList/components/index.ts
@@ -0,0 +1 @@
+export * from './TagItem';
diff --git a/packages/components/src/components/TagList/index.ts b/packages/components/src/components/TagList/index.ts
new file mode 100644
index 00000000..f22a3aad
--- /dev/null
+++ b/packages/components/src/components/TagList/index.ts
@@ -0,0 +1,2 @@
+export * from './TagList';
+export * from './types';
diff --git a/packages/components/src/components/TagList/types.ts b/packages/components/src/components/TagList/types.ts
new file mode 100644
index 00000000..5603ffd6
--- /dev/null
+++ b/packages/components/src/components/TagList/types.ts
@@ -0,0 +1,97 @@
+import type {
+ Ref,
+ ReactElement,
+ ComponentRef,
+ CSSProperties,
+ ComponentPropsWithRef,
+} from 'react';
+
+import type {
+ Key,
+ CollectionBase,
+ ExtendableProps,
+ FocusStrategy,
+ MultipleSelection,
+} from '@koobiq/react-core';
+import type { ListState } from '@koobiq/react-primitives';
+
+export const tagListPropVariant = [
+ 'theme-fade',
+ 'contrast-fade',
+ 'error-fade',
+ 'warning-fade',
+] as const;
+
+export type TagListPropVariant = (typeof tagListPropVariant)[number];
+
+type TagListDOMProps = Omit<
+ ComponentPropsWithRef<'div'>,
+ 'children' | 'defaultValue' | 'onChange' | 'onSelect' | 'ref' | 'autoFocus'
+>;
+
+type TagListCollectionProps = CollectionBase &
+ Omit;
+
+type TagListKeyboardProps = {
+ /**
+ * Whether pressing the Escape key should clear selection.
+ * @default 'clearSelection'
+ */
+ escapeKeyBehavior?: 'clearSelection' | 'none';
+ /** Focus the first (`true` / `'first'`) or last (`'last'`) tag on mount. */
+ autoFocus?: boolean | FocusStrategy;
+};
+
+type TagListBaseProps = {
+ /**
+ * The variant to use.
+ * @default 'theme-fade'
+ */
+ variant?: TagListPropVariant;
+ /** Ref to the root element. */
+ ref?: Ref;
+ /** Additional CSS-classes. */
+ className?: string;
+ /** Unique identifier for testing purposes. */
+ 'data-testid'?: string | number;
+ /** Inline styles. */
+ style?: CSSProperties;
+ /** Handler that is called when a user deletes a tag. */
+ onRemove?: (keys: Set) => void;
+};
+
+type TagListInheritedProps = TagListCollectionProps &
+ TagListKeyboardProps &
+ TagListDOMProps;
+
+export type TagListProps = ExtendableProps<
+ TagListBaseProps,
+ TagListInheritedProps
+>;
+
+export type TagListInnerProps = {
+ /** Pre-built collection state, e.g. from `useTagListState`. */
+ state: ListState;
+ /** Whether all tags are disabled by an owning composite component. */
+ isDisabled?: boolean;
+ /** Ref to the root element. */
+ tagListRef?: Ref;
+} & Omit<
+ TagListProps,
+ // Collection / selection inputs are baked into `state` already.
+ | 'ref'
+ | 'children'
+ | 'items'
+ | 'disabledKeys'
+ | 'selectionMode'
+ | 'disallowEmptySelection'
+ | 'selectedKeys'
+ | 'defaultSelectedKeys'
+ | 'onSelectionChange'
+>;
+
+export type TagListComponent = (
+ props: TagListProps
+) => ReactElement | null;
+
+export type TagListRef = ComponentRef<'div'>;
diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts
index 58af13bd..20561b89 100644
--- a/packages/components/src/components/index.ts
+++ b/packages/components/src/components/index.ts
@@ -33,6 +33,9 @@ export * from './Divider';
export * from './Menu';
export * from './ButtonToggleGroup';
export * from './TagGroup';
+export * from './TagList';
+export * from './TagInput';
+export * from './TagAutocomplete';
export * from './Table';
export * from './Calendar';
export * from './DateInput';
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 971d561b..19a8f2c1 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -9,6 +9,7 @@ export {
useLinkProps,
mergeRefs,
useObjectRef,
+ useDescription,
} from '@react-aria/utils';
export type {
@@ -39,6 +40,7 @@ export type {
RouterOptions,
SortDescriptor,
Selection,
+ FocusStrategy,
} from '@react-types/shared';
export * from '@react-aria/i18n';
diff --git a/packages/primitives/src/behaviors/index.ts b/packages/primitives/src/behaviors/index.ts
index c110f591..c7e5f197 100644
--- a/packages/primitives/src/behaviors/index.ts
+++ b/packages/primitives/src/behaviors/index.ts
@@ -10,3 +10,8 @@ export * from './useNumberField';
export * from './useMultiSelect';
export * from './useMultiSelectState';
export * from './useMultiSelectListState';
+export * from './useTagList';
+export * from './useTagListItem';
+export * from './useTagListState';
+export * from './useTagField';
+export * from './useTagAutocomplete';
diff --git a/packages/primitives/src/behaviors/useTagAutocomplete.ts b/packages/primitives/src/behaviors/useTagAutocomplete.ts
new file mode 100644
index 00000000..c76eebf0
--- /dev/null
+++ b/packages/primitives/src/behaviors/useTagAutocomplete.ts
@@ -0,0 +1,360 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import type { RefObject } from 'react';
+
+import type { FocusStrategy, Key, Node } from '@koobiq/react-core';
+import { useInteractOutside } from '@koobiq/react-core';
+import { listData } from '@react-aria/listbox';
+import { useId } from '@react-aria/utils';
+import type { ListState } from '@react-stately/list';
+import { useListState } from '@react-stately/list';
+import type { OverlayTriggerState } from '@react-stately/overlays';
+import { useOverlayTriggerState } from '@react-stately/overlays';
+
+import type { TagFieldState, TagFieldStateProps } from './useTagField';
+import { useTagFieldState } from './useTagField';
+
+export type TagAutocompleteFilter = (
+ textValue: string,
+ inputValue: string
+) => boolean;
+
+const normalizeTextValue = (value: string) => value.trim().toLocaleLowerCase();
+
+type TagAutocompleteRefProps = {
+ /** Ref to the field the popover anchors to. */
+ anchorRef: RefObject;
+ /** Ref to the popover container. */
+ popoverRef: RefObject;
+ /** Ref to the listbox element. */
+ listBoxRef: RefObject;
+};
+
+const tagAutocompletePropKeys = [
+ 'listItems',
+ 'renderListItem',
+ 'defaultFilter',
+ 'isOpen',
+ 'defaultOpen',
+ 'onOpenChange',
+ 'allowsEmptyCollection',
+ 'disableCloseOnSelect',
+ 'anchorRef',
+ 'popoverRef',
+ 'listBoxRef',
+] as const;
+
+type TagAutocompletePropKey = (typeof tagAutocompletePropKeys)[number];
+
+function getTagFieldProps(
+ props: TagAutocompleteProps
+): TagFieldStateProps {
+ const tagFieldProps = { ...props };
+
+ for (const key of tagAutocompletePropKeys) {
+ delete (tagFieldProps as Partial>)[
+ key
+ ];
+ }
+
+ return tagFieldProps;
+}
+
+export type TagAutocompleteState =
+ TagFieldState & {
+ /** State for the filtered suggestions listbox. */
+ listState: ListState;
+ /** Open/close state for the suggestions popover. */
+ overlayState: OverlayTriggerState;
+ /** Fires when a suggestion is picked by mouse or keyboard. */
+ onAction: (key: Key) => void;
+ /** DOM id for the listbox element. */
+ listBoxId: string;
+ /** Which item should receive virtual focus when the listbox opens. */
+ focusStrategy: FocusStrategy | undefined;
+ /** Opens the suggestions popover and optionally focuses the first/last item. */
+ open: (focusStrategy?: FocusStrategy) => void;
+ /** Closes the suggestions popover. */
+ close: () => void;
+ };
+
+export type TagAutocompleteStateProps =
+ TagFieldStateProps & {
+ /** Collection of autocomplete suggestions. */
+ listItems?: Iterable;
+ /** Render function for each suggestion. */
+ renderListItem: TagFieldStateProps['children'];
+ /** Filters suggestions by the current input value. */
+ defaultFilter?: TagAutocompleteFilter;
+ /** Controlled open state for the suggestions popover. */
+ isOpen?: boolean;
+ /** Uncontrolled initial open state for the suggestions popover. */
+ defaultOpen?: boolean;
+ /** Fires when the suggestions popover opens or closes. */
+ onOpenChange?: (isOpen: boolean) => void;
+ /** Whether the suggestions popover can be open when the collection is empty. */
+ allowsEmptyCollection?: boolean;
+ /**
+ * Keep the popover open after a suggestion is committed. Useful for picking
+ * several tags in a row.
+ */
+ disableCloseOnSelect?: boolean;
+ };
+
+export function useTagAutocompleteState(
+ props: TagAutocompleteStateProps
+): TagAutocompleteState {
+ const {
+ listItems,
+ renderListItem,
+ defaultFilter,
+ onAdd,
+ isDisabled,
+ isReadOnly,
+ isOpen,
+ defaultOpen,
+ onOpenChange,
+ allowsEmptyCollection = false,
+ disableCloseOnSelect = false,
+ } = props;
+
+ const listBoxId = useId();
+
+ const tagFieldState = useTagFieldState(props);
+
+ const selectedTextValues = useMemo(() => {
+ const values = new Set();
+
+ for (const item of tagFieldState.collection) {
+ const textValue = normalizeTextValue(item.textValue ?? '');
+
+ if (textValue) {
+ values.add(textValue);
+ }
+ }
+
+ return values;
+ }, [tagFieldState.collection]);
+
+ const filter = useCallback(
+ (nodes: Iterable>): Iterable> => {
+ const filtered: Node[] = [];
+
+ for (const node of nodes) {
+ if (tagFieldState.collection.getItem(node.key)) continue;
+
+ const normalizedTextValue = normalizeTextValue(node.textValue ?? '');
+
+ if (normalizedTextValue && selectedTextValues.has(normalizedTextValue))
+ continue;
+
+ if (
+ tagFieldState.inputValue &&
+ defaultFilter &&
+ !defaultFilter(node.textValue ?? '', tagFieldState.inputValue)
+ )
+ continue;
+
+ filtered.push(node);
+ }
+
+ return filtered;
+ },
+ [
+ defaultFilter,
+ selectedTextValues,
+ tagFieldState.collection,
+ tagFieldState.inputValue,
+ ]
+ );
+
+ const listState = useListState({
+ items: listItems,
+ children: renderListItem,
+ selectionMode: 'none',
+ filter,
+ });
+
+ const overlayState = useOverlayTriggerState({
+ isOpen,
+ defaultOpen,
+ onOpenChange,
+ });
+
+ const canShowCollection =
+ !isDisabled &&
+ !isReadOnly &&
+ (allowsEmptyCollection || listState.collection.size > 0);
+
+ const visibleOverlayState = useMemo(
+ () => ({
+ ...overlayState,
+ isOpen: overlayState.isOpen && canShowCollection,
+ }),
+ [canShowCollection, overlayState]
+ );
+
+ const { setInputValue } = tagFieldState;
+
+ const [focusStrategy, setFocusStrategy] = useState<
+ FocusStrategy | undefined
+ >();
+
+ const {
+ open: openOverlay,
+ close: closeOverlay,
+ isOpen: isOverlayOpen,
+ } = visibleOverlayState;
+
+ const open = useCallback(
+ (focusStrategy?: FocusStrategy) => {
+ if (isDisabled || isReadOnly) return;
+
+ setFocusStrategy(focusStrategy);
+ openOverlay();
+ },
+ [isDisabled, isReadOnly, openOverlay]
+ );
+
+ const close = useCallback(() => {
+ setFocusStrategy(undefined);
+ closeOverlay();
+ }, [closeOverlay]);
+
+ useEffect(() => {
+ if (!isOverlayOpen) {
+ setFocusStrategy(undefined);
+ }
+ }, [isOverlayOpen]);
+
+ const onAction = useCallback(
+ (key: Key) => {
+ const node = listState.collection.getItem(key);
+
+ if (node?.value == null || !onAdd || isDisabled || isReadOnly) {
+ return;
+ }
+
+ onAdd([node.textValue ?? ''], {
+ source: 'suggestion',
+ suggestion: node.value,
+ });
+
+ setInputValue('');
+ listState.selectionManager.setFocusedKey(null);
+
+ if (!disableCloseOnSelect) {
+ close();
+ }
+ },
+ [
+ close,
+ disableCloseOnSelect,
+ isDisabled,
+ isReadOnly,
+ listState.collection,
+ listState.selectionManager,
+ onAdd,
+ setInputValue,
+ ]
+ );
+
+ return {
+ ...tagFieldState,
+ listState,
+ overlayState: visibleOverlayState,
+ onAction,
+ listBoxId,
+ focusStrategy,
+ open,
+ close,
+ };
+}
+
+export type TagAutocompleteAria = {
+ /** Props to spread on `TagInputField`. */
+ tagFieldProps: TagFieldStateProps & {
+ state: TagAutocompleteState;
+ popoverRef: RefObject;
+ listBoxRef: RefObject;
+ };
+ /** Props to spread on the suggestion popover. */
+ popoverProps: {
+ state: OverlayTriggerState;
+ anchorRef: RefObject;
+ popoverRef: RefObject;
+ type: 'listbox';
+ isNonModal: true;
+ };
+ /** Props to spread on the suggestion listbox. */
+ listProps: {
+ onAction: (key: Key) => void;
+ state: ListState;
+ listRef: RefObject;
+ autoFocus: true | FocusStrategy;
+ shouldUseVirtualFocus: true;
+ 'aria-label': string;
+ id: string;
+ };
+};
+
+export type AriaTagAutocompleteProps =
+ TagAutocompleteStateProps;
+
+export type TagAutocompleteProps =
+ AriaTagAutocompleteProps & TagAutocompleteRefProps;
+
+export function useTagAutocomplete(
+ props: TagAutocompleteProps,
+ state: TagAutocompleteState
+): TagAutocompleteAria {
+ const { anchorRef, popoverRef, listBoxRef } = props;
+
+ const tagFieldProps = getTagFieldProps(props);
+
+ const { overlayState, listState, onAction, listBoxId, focusStrategy, close } =
+ state;
+
+ useInteractOutside({
+ ref: popoverRef,
+ isDisabled: !overlayState.isOpen,
+ onInteractOutside: (event) => {
+ const target = event.target as Element | null;
+ if (target && anchorRef.current?.contains(target)) return;
+
+ close();
+ },
+ });
+
+ listData.set(listState, {
+ id: listBoxId,
+ shouldUseVirtualFocus: true,
+ onAction,
+ });
+
+ return {
+ tagFieldProps: {
+ ...tagFieldProps,
+ state,
+ popoverRef,
+ listBoxRef,
+ },
+ popoverProps: {
+ state: overlayState,
+ anchorRef,
+ popoverRef,
+ type: 'listbox',
+ isNonModal: true,
+ },
+ listProps: {
+ onAction,
+ id: listBoxId,
+ state: listState,
+ listRef: listBoxRef,
+ shouldUseVirtualFocus: true,
+ 'aria-label': 'suggestions',
+ autoFocus: focusStrategy || true,
+ },
+ };
+}
diff --git a/packages/primitives/src/behaviors/useTagField.ts b/packages/primitives/src/behaviors/useTagField.ts
new file mode 100644
index 00000000..d3d84868
--- /dev/null
+++ b/packages/primitives/src/behaviors/useTagField.ts
@@ -0,0 +1,773 @@
+import { useCallback, useMemo, useRef } from 'react';
+import type {
+ ClipboardEvent,
+ FocusEvent,
+ HTMLAttributes,
+ PointerEvent,
+ Ref,
+ RefObject,
+} from 'react';
+
+import type {
+ CollectionChildren,
+ FocusStrategy,
+ Key,
+ Selection,
+ ValidationResult,
+} from '@koobiq/react-core';
+import {
+ mergeProps,
+ useCollator,
+ useControlledState,
+ useDOMRef,
+ useKeyboard,
+} from '@koobiq/react-core';
+import { getItemId } from '@react-aria/listbox';
+import {
+ ListKeyboardDelegate,
+ useSelectableCollection,
+} from '@react-aria/selection';
+import type { AriaTextFieldProps, TextFieldAria } from '@react-aria/textfield';
+import { useTextField } from '@react-aria/textfield';
+import { useSlottedContext } from 'react-aria-components';
+
+import { FormContext } from '../components';
+import { removeDataAttributes } from '../utils';
+
+import type { TagAutocompleteState } from './useTagAutocomplete';
+import {
+ isInteractiveTarget,
+ type TagListItemRemoveContext,
+} from './useTagListItem';
+import type { TagListState } from './useTagListState';
+import { useTagListState } from './useTagListState';
+
+const DEFAULT_SPLIT_PATTERN = /,/;
+
+const splitInputValue = (raw: string, splitPattern: RegExp): string[] =>
+ raw
+ .split(splitPattern)
+ .map((value) => value.trim())
+ .filter(Boolean);
+
+const testSplitPattern = (splitPattern: RegExp, value: string): boolean => {
+ splitPattern.lastIndex = 0;
+
+ return splitPattern.test(value);
+};
+
+/** How the user's input ended up as new tags. */
+export type TagFieldAddSource =
+ | 'enter'
+ | 'separator'
+ | 'paste'
+ | 'blur'
+ | 'suggestion';
+
+export type TagFieldAddContext =
+ | { source: Exclude }
+ | { source: 'suggestion'; suggestion: T };
+
+export type AriaTagFieldProps = Omit<
+ AriaTextFieldProps,
+ 'value' | 'defaultValue' | 'onChange'
+> & {
+ /** Tag collection items. */
+ items?: Iterable;
+ /** Render function for each item in the collection. */
+ children: CollectionChildren;
+ /** Keys of tags rendered as disabled. */
+ disabledKeys?: Iterable;
+ /** Controlled set of selected tag keys. */
+ selectedKeys?: Selection;
+ /** Uncontrolled initial set of selected tag keys. */
+ defaultSelectedKeys?: Iterable;
+ /** Fires when the selected tag keys change. */
+ onSelectionChange?: (keys: Selection) => void;
+ /** Fires when the user commits one or more new values from the text input. */
+ onAdd?: (values: string[], context: TagFieldAddContext) => void;
+ /** Fires when the user removes one or more tags. */
+ onRemove?: (keys: Set) => void;
+ /** Controlled value of the text input. */
+ inputValue?: string;
+ /** Uncontrolled initial value of the text input. */
+ defaultInputValue?: string;
+ /** Fires whenever the text input value changes. */
+ onInputChange?: (value: string) => void;
+ /**
+ * Characters (besides Enter) that commit the current input as a new tag.
+ * @default /,/
+ */
+ splitPattern?: RegExp;
+ /**
+ * Whether to suppress committing the input value as a new tag when focus leaves the field.
+ * @default false
+ */
+ disableCommitOnBlur?: boolean;
+ /** Whether to show the cleaner button that removes all tags and the input. */
+ isClearable?: boolean;
+ /** Fires after the cleaner is pressed and the field is reset. */
+ onClear?: () => void;
+};
+
+export type TagFieldClearButtonProps = {
+ isClearable: boolean;
+ tabIndex: -1 | undefined;
+ isHidden: boolean;
+ onPress: () => void;
+};
+
+export type TagFieldTagListProps = {
+ state: TagListState;
+ isDisabled: boolean | undefined;
+ tabIndex: -1;
+ onRemove:
+ | ((keys: Set, context?: TagListItemRemoveContext) => void)
+ | undefined;
+ 'aria-label': string;
+};
+
+export type TagFieldTagListContainerProps = HTMLAttributes & {
+ ref: RefObject;
+ role: 'presentation';
+};
+
+export type TagFieldStateProps = AriaTagFieldProps;
+
+export type TagFieldState = TagListState & {
+ /** Current input value. */
+ inputValue: string;
+ /** Initial input value. */
+ defaultInputValue: string;
+ /** Updates the input value. */
+ setInputValue: (value: string) => void;
+ /** Whether adding new values is currently allowed. */
+ allowsAdding: boolean;
+ /** Resolved disabled state. */
+ isDisabled: boolean | undefined;
+ /** Resolved read-only state. */
+ isReadOnly: boolean | undefined;
+ /** Adds raw values with the provided context and clears the input. */
+ addValues: (values: string[], context: TagFieldAddContext) => boolean;
+ /** Splits and adds the current input value, or a provided raw value. */
+ addFromInput: (
+ source: Exclude,
+ rawValue?: string
+ ) => boolean;
+ /** Whether the provided text should split the input into tags. */
+ isSeparator: (value: string) => boolean;
+ /** Removes the provided tag keys. */
+ remove: (keys: Set) => boolean;
+ /** Clears all tags and the input value. */
+ clear: () => boolean;
+};
+
+type TagFieldAutocompleteProps = {
+ popoverRef?: RefObject;
+ listBoxRef?: RefObject;
+};
+
+export function useTagFieldState(
+ props: TagFieldStateProps
+): TagFieldState {
+ const {
+ items,
+ children,
+ disabledKeys,
+ selectedKeys,
+ defaultSelectedKeys,
+ onSelectionChange,
+ inputValue: inputValueProp,
+ defaultInputValue: defaultInputValueProp,
+ onInputChange,
+ splitPattern = DEFAULT_SPLIT_PATTERN,
+ onAdd,
+ onRemove,
+ onClear,
+ isDisabled,
+ isReadOnly,
+ } = props;
+
+ const tagListState = useTagListState({
+ items,
+ children,
+ disabledKeys,
+ selectedKeys,
+ defaultSelectedKeys,
+ onSelectionChange,
+ selectionMode: isReadOnly ? 'none' : 'multiple',
+ });
+
+ const [inputValue, setInputValue] = useControlledState(
+ inputValueProp,
+ defaultInputValueProp ?? '',
+ onInputChange
+ );
+
+ const defaultInputValue = defaultInputValueProp ?? '';
+ const allowsAdding = Boolean(onAdd && !isDisabled && !isReadOnly);
+
+ const addValues = useCallback(
+ (values: string[], context: TagFieldAddContext) => {
+ if (!allowsAdding || values.length === 0) return false;
+
+ onAdd?.(values, context);
+ setInputValue('');
+
+ return true;
+ },
+ [allowsAdding, onAdd, setInputValue]
+ );
+
+ const addFromInput = useCallback(
+ (
+ source: Exclude,
+ rawValue = inputValue
+ ) => {
+ const candidates = splitInputValue(rawValue, splitPattern);
+
+ return addValues(candidates, { source });
+ },
+ [addValues, inputValue, splitPattern]
+ );
+
+ const isSeparator = useCallback(
+ (value: string) => testSplitPattern(splitPattern, value),
+ [splitPattern]
+ );
+
+ const remove = useCallback(
+ (keys: Set) => {
+ if (isDisabled || isReadOnly || !onRemove) return false;
+
+ onRemove(keys);
+
+ return true;
+ },
+ [isDisabled, isReadOnly, onRemove]
+ );
+
+ const clear = useCallback(() => {
+ if (isDisabled || isReadOnly) return false;
+
+ const allKeys = new Set(tagListState.collection.getKeys());
+ if (allKeys.size > 0) onRemove?.(allKeys);
+
+ setInputValue('');
+ onClear?.();
+
+ return true;
+ }, [
+ isDisabled,
+ isReadOnly,
+ onClear,
+ onRemove,
+ setInputValue,
+ tagListState.collection,
+ ]);
+
+ return {
+ ...tagListState,
+ inputValue,
+ defaultInputValue,
+ setInputValue,
+ allowsAdding,
+ isDisabled,
+ isReadOnly,
+ addValues,
+ addFromInput,
+ isSeparator,
+ remove,
+ clear,
+ };
+}
+
+export type TagFieldAria = ValidationResult & {
+ /** Props for the label element. */
+ labelProps: TextFieldAria<'input'>['labelProps'];
+ /** Props for the input element. */
+ inputProps: TextFieldAria<'input'>['inputProps'];
+ /** Props for the description element, if any. */
+ descriptionProps: TextFieldAria<'input'>['descriptionProps'];
+ /** Props for the error message element, if any. */
+ errorMessageProps: TextFieldAria<'input'>['errorMessageProps'];
+ /** Props for a clear button that clears tags and input text. */
+ clearButtonProps: TagFieldClearButtonProps;
+ /** Props for the owning tag list. */
+ tagListProps: TagFieldTagListProps;
+ /** Props for the tag list/input canvas. */
+ tagListContainerProps: TagFieldTagListContainerProps;
+ /** Ref for the input element. */
+ inputRef: RefObject;
+ /** Current input value. */
+ inputValue: string;
+};
+
+export function useTagField(
+ props: AriaTagFieldProps & TagFieldAutocompleteProps,
+ state: TagFieldState | TagAutocompleteState,
+ ref?: Ref
+): TagFieldAria {
+ const {
+ disableCommitOnBlur,
+ isClearable,
+ isRequired,
+ validationBehavior: validationBehaviorProp,
+ 'aria-label': ariaLabel,
+ popoverRef,
+ listBoxRef,
+ isDisabled,
+ isReadOnly,
+ ...textFieldProps
+ } = props;
+
+ const {
+ inputValue,
+ defaultInputValue,
+ setInputValue,
+ allowsAdding,
+ collection,
+ selectionManager,
+ addFromInput,
+ isSeparator,
+ remove,
+ clear,
+ } = state;
+
+ const {
+ overlayState: autocompleteOverlayState,
+ listState: autocompleteListState,
+ onAction: autocompleteOnAction,
+ listBoxId,
+ open: autocompleteOpen,
+ close: autocompleteClose,
+ } = state as Partial>;
+
+ const inputRef = useDOMRef(ref);
+ const innerRef = useRef(null);
+ const collator = useCollator({ usage: 'search', sensitivity: 'base' });
+
+ const handleRemove = useCallback(
+ (keys: Set, context?: TagListItemRemoveContext) => {
+ if (
+ remove(keys) &&
+ (context?.source === 'press' || keys.size >= collection.size)
+ ) {
+ inputRef.current?.focus({ preventScroll: true });
+ }
+ },
+ [collection, inputRef, remove]
+ );
+
+ const focusTagAt = useCallback(
+ (position: 'first' | 'last') => {
+ const key =
+ position === 'first'
+ ? collection.getFirstKey()
+ : collection.getLastKey();
+
+ if (key == null) return;
+ selectionManager.setFocused(true);
+ selectionManager.setFocusedKey(key);
+ },
+ [collection, selectionManager]
+ );
+
+ const resetTagListFocus = useCallback(() => {
+ selectionManager.setFocused(false);
+ selectionManager.setFocusedKey(null);
+ }, [selectionManager]);
+
+ const focusInput = useCallback(() => {
+ inputRef.current?.focus();
+ }, [inputRef]);
+
+ const openAutocomplete = useCallback(
+ (focusStrategy?: FocusStrategy) => {
+ autocompleteOpen?.(focusStrategy);
+ },
+ [autocompleteOpen]
+ );
+
+ const closeAutocomplete = useCallback(() => {
+ autocompleteClose?.();
+ }, [autocompleteClose]);
+
+ const handleClear = useCallback(() => {
+ if (clear()) focusInput();
+ }, [clear, focusInput]);
+
+ const collectionState = autocompleteListState ?? state;
+ const collectionRef = listBoxRef ?? innerRef;
+
+ const keyboardDelegate = useMemo(
+ () =>
+ new ListKeyboardDelegate({
+ collection: collectionState.collection,
+ collator,
+ disabledBehavior: collectionState.selectionManager.disabledBehavior,
+ disabledKeys: collectionState.disabledKeys,
+ ref: collectionRef,
+ }),
+ [
+ collectionRef,
+ collectionState.collection,
+ collectionState.disabledKeys,
+ collectionState.selectionManager.disabledBehavior,
+ collator,
+ ]
+ );
+
+ const { collectionProps: autocompleteCollectionProps } =
+ useSelectableCollection({
+ selectionManager: collectionState.selectionManager,
+ keyboardDelegate,
+ disallowEmptySelection: true,
+ disallowTypeAhead: true,
+ isVirtualized: true,
+ ref: inputRef,
+ selectOnFocus: false,
+ shouldUseVirtualFocus: true,
+ });
+
+ const isAutocompleteOpen = Boolean(
+ autocompleteOverlayState?.isOpen && autocompleteListState
+ );
+
+ const focusedAutocompleteKey =
+ isAutocompleteOpen && autocompleteListState
+ ? autocompleteListState.selectionManager.focusedKey
+ : null;
+
+ const autocompleteActiveDescendant =
+ focusedAutocompleteKey != null && autocompleteListState
+ ? getItemId(autocompleteListState, focusedAutocompleteKey)
+ : undefined;
+
+ const selectFocusedAutocompleteOption = useCallback(() => {
+ if (
+ !isAutocompleteOpen ||
+ !autocompleteListState ||
+ !autocompleteOnAction
+ ) {
+ return false;
+ }
+
+ const { focusedKey } = autocompleteListState.selectionManager;
+ if (focusedKey == null) return false;
+
+ autocompleteOnAction(focusedKey);
+
+ return true;
+ }, [autocompleteListState, autocompleteOnAction, isAutocompleteOpen]);
+
+ const { keyboardProps: inputKeyboardProps } = useKeyboard({
+ isDisabled,
+ onKeyDown: (event) => {
+ if (event.nativeEvent.isComposing) {
+ event.continuePropagation();
+
+ return;
+ }
+
+ if (event.key === 'Escape' && autocompleteOverlayState?.isOpen) {
+ event.preventDefault();
+ closeAutocomplete();
+
+ return;
+ }
+
+ if (event.key === 'Enter') {
+ if (selectFocusedAutocompleteOption()) {
+ event.preventDefault();
+
+ return;
+ }
+
+ if (inputValue.trim() && addFromInput('enter')) {
+ event.preventDefault();
+
+ return;
+ }
+
+ event.continuePropagation();
+
+ return;
+ }
+
+ if (
+ autocompleteOverlayState &&
+ (event.key === 'ArrowDown' || event.key === 'ArrowUp')
+ ) {
+ event.preventDefault();
+
+ if (!autocompleteOverlayState.isOpen) {
+ openAutocomplete(event.key === 'ArrowDown' ? 'first' : 'last');
+
+ return;
+ }
+
+ event.continuePropagation();
+
+ return;
+ }
+
+ if (event.key.length === 1 && isSeparator(event.key)) {
+ if (!allowsAdding) {
+ event.continuePropagation();
+
+ return;
+ }
+
+ if (inputValue.trim()) {
+ addFromInput('separator');
+ }
+
+ // Don't let a bare separator end up in the input either way.
+ event.preventDefault();
+
+ return;
+ }
+
+ const input = event.currentTarget;
+ const { selectionStart, selectionEnd } = input;
+
+ const isCaretAtStart =
+ selectionStart === 0 && selectionEnd === 0 && !inputValue;
+
+ const hasTags = collection.size > 0;
+
+ if (
+ isCaretAtStart &&
+ (event.key === 'Backspace' || event.key === 'ArrowLeft')
+ ) {
+ if (!hasTags) {
+ event.continuePropagation();
+
+ return;
+ }
+
+ event.preventDefault();
+ focusTagAt('last');
+
+ return;
+ }
+
+ if (
+ !inputValue &&
+ hasTags &&
+ (event.ctrlKey || event.metaKey) &&
+ (event.key === 'a' || event.key === 'A')
+ ) {
+ event.preventDefault();
+ selectionManager.selectAll();
+ focusTagAt('last');
+
+ return;
+ }
+
+ // Shift+Tab from empty input -> focus the first tag.
+ if (isCaretAtStart && event.key === 'Tab' && event.shiftKey && hasTags) {
+ event.preventDefault();
+ focusTagAt('first');
+
+ return;
+ }
+
+ event.continuePropagation();
+ },
+ });
+
+ const handlePaste = useCallback(
+ (event: ClipboardEvent) => {
+ const text = event.clipboardData.getData('text/plain');
+ const input = event.currentTarget;
+ const selectionStart = input.selectionStart ?? input.value.length;
+ const selectionEnd = input.selectionEnd ?? selectionStart;
+ const nextValue = `${input.value.slice(0, selectionStart)}${text}${input.value.slice(selectionEnd)}`;
+
+ if (!text || !allowsAdding || !isSeparator(nextValue)) {
+ return;
+ }
+
+ event.preventDefault();
+ addFromInput('paste', nextValue);
+ },
+ [addFromInput, allowsAdding, isSeparator]
+ );
+
+ const handleBlur = useCallback(
+ (event: FocusEvent) => {
+ const next = event.relatedTarget;
+
+ if (next && innerRef.current?.contains(next)) return;
+ if (next && popoverRef?.current?.contains(next)) return;
+
+ if (next) {
+ closeAutocomplete();
+ }
+
+ if (isDisabled || isReadOnly || disableCommitOnBlur) return;
+
+ if (inputValue.trim()) {
+ addFromInput('blur');
+ }
+ },
+ [
+ addFromInput,
+ closeAutocomplete,
+ disableCommitOnBlur,
+ inputValue,
+ isDisabled,
+ isReadOnly,
+ popoverRef,
+ ]
+ );
+
+ const handleTagListContainerBlur = useCallback(
+ (event: FocusEvent) => {
+ const next = event.relatedTarget;
+ if (next && event.currentTarget.contains(next)) return;
+
+ resetTagListFocus();
+ },
+ [resetTagListFocus]
+ );
+
+ const handleTagListContainerPointerDown = useCallback(
+ (event: PointerEvent) => {
+ const target = event.target;
+ if (!(target instanceof Element)) return;
+
+ const isPlainPrimaryClick =
+ event.button === 0 &&
+ !event.altKey &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.shiftKey;
+
+ if (!isPlainPrimaryClick || inputRef.current?.contains(target)) return;
+
+ if (event.target !== event.currentTarget) {
+ const row = target.closest('[role="row"]');
+
+ if (!row || !event.currentTarget.contains(row)) return;
+ if (target !== row && isInteractiveTarget(target, row)) return;
+
+ event.stopPropagation();
+ }
+
+ event.preventDefault();
+ resetTagListFocus();
+ focusInput();
+ },
+ [focusInput, inputRef, resetTagListFocus]
+ );
+
+ const { validationBehavior: formValidationBehavior } =
+ useSlottedContext(FormContext) || {};
+
+ const validationBehavior =
+ validationBehaviorProp ?? formValidationBehavior ?? 'aria';
+
+ const {
+ labelProps,
+ inputProps: inputPropsAria,
+ descriptionProps,
+ errorMessageProps,
+ ...validation
+ } = useTextField<'input'>(
+ {
+ ...removeDataAttributes({
+ ...textFieldProps,
+ isDisabled,
+ isReadOnly,
+ isRequired,
+ value: inputValue,
+ defaultValue: defaultInputValue,
+ onChange: setInputValue,
+ validationBehavior,
+ 'aria-label': ariaLabel,
+ }),
+ inputElementType: 'input',
+ },
+ inputRef
+ );
+
+ const hasTags = collection.size > 0;
+ const hasInputValue = inputValue !== '';
+ const showCleaner = Boolean(isClearable);
+
+ const cleanerIsHidden = Boolean(
+ !showCleaner || (!hasTags && !hasInputValue) || isDisabled || isReadOnly
+ );
+
+ const clearButtonProps: TagFieldClearButtonProps = {
+ isClearable: showCleaner,
+ tabIndex: cleanerIsHidden ? -1 : undefined,
+ isHidden: cleanerIsHidden,
+ onPress: handleClear,
+ };
+
+ const tagListProps = {
+ state,
+ isDisabled,
+ tabIndex: -1,
+ onRemove: isReadOnly ? undefined : handleRemove,
+ 'aria-label': ariaLabel ?? 'Selected tags',
+ } as const;
+
+ const tagListContainerProps = {
+ ref: innerRef,
+ role: 'presentation',
+ onPointerDownCapture: handleTagListContainerPointerDown,
+ onBlur: handleTagListContainerBlur,
+ } as const;
+
+ const comboboxInputProps = autocompleteOverlayState
+ ? {
+ onFocus: () => openAutocomplete(),
+ role: 'combobox',
+ 'aria-expanded': autocompleteOverlayState.isOpen,
+ 'aria-controls': autocompleteOverlayState.isOpen
+ ? listBoxId
+ : undefined,
+ 'aria-autocomplete': 'list',
+ autoComplete: 'off',
+ autoCorrect: 'off',
+ spellCheck: 'false',
+ }
+ : undefined;
+
+ const autocompleteKeyboardProps = isAutocompleteOpen
+ ? {
+ onKeyDown: autocompleteCollectionProps.onKeyDown,
+ 'aria-activedescendant': autocompleteActiveDescendant,
+ }
+ : undefined;
+
+ const inputProps = mergeProps(
+ inputPropsAria,
+ comboboxInputProps,
+ autocompleteKeyboardProps,
+ {
+ onFocus: resetTagListFocus,
+ onPaste: handlePaste,
+ onBlur: handleBlur,
+ },
+ inputKeyboardProps
+ );
+
+ return {
+ ...validation,
+ clearButtonProps,
+ descriptionProps,
+ errorMessageProps,
+ inputProps,
+ inputRef,
+ inputValue,
+ labelProps,
+ tagListContainerProps,
+ tagListProps,
+ };
+}
diff --git a/packages/primitives/src/behaviors/useTagList.ts b/packages/primitives/src/behaviors/useTagList.ts
new file mode 100644
index 00000000..28340a31
--- /dev/null
+++ b/packages/primitives/src/behaviors/useTagList.ts
@@ -0,0 +1,105 @@
+import { useMemo } from 'react';
+import type { KeyboardEvent, RefObject } from 'react';
+
+import type {
+ DOMAttributes,
+ FocusStrategy,
+ RefObject as AriaRefObject,
+} from '@koobiq/react-core';
+import { mergeProps, useFocusWithin, useLocale } from '@koobiq/react-core';
+import { ListKeyboardDelegate, useSelectableList } from '@react-aria/selection';
+import type { ListState } from '@react-stately/list';
+
+export type AriaTagListProps = {
+ /**
+ * Whether pressing the Escape key should clear selection.
+ * @default 'clearSelection'
+ */
+ escapeKeyBehavior?: 'clearSelection' | 'none';
+ /** Focus the first (`true` / `'first'`) or last (`'last'`) tag on mount. */
+ autoFocus?: boolean | FocusStrategy;
+};
+
+export type TagListAria = {
+ /** Props for the root grid/group element. */
+ gridProps: DOMAttributes;
+ collectionId: string | undefined;
+};
+
+/**
+ * Provides behavior and accessibility wiring for a TagList root.
+ * Pair with `useTagListState` for state and `useTagListItem` for tags.
+ */
+export function useTagList(
+ props: AriaTagListProps,
+ state: ListState,
+ ref: RefObject
+): TagListAria {
+ const { escapeKeyBehavior, autoFocus } = props;
+ const { direction } = useLocale();
+
+ const keyboardDelegate = useMemo(
+ () =>
+ new ListKeyboardDelegate({
+ collection: state.collection,
+ direction,
+ disabledBehavior: state.selectionManager.disabledBehavior,
+ disabledKeys: state.disabledKeys,
+ orientation: 'horizontal',
+ ref: ref as AriaRefObject,
+ }),
+ [
+ ref,
+ direction,
+ state.collection,
+ state.disabledKeys,
+ state.selectionManager.disabledBehavior,
+ ]
+ );
+
+ const { listProps } = useSelectableList({
+ keyboardDelegate,
+ // Spec: arrow navigation in the tag list is not cyclic.
+ shouldFocusWrap: false,
+ escapeKeyBehavior,
+ autoFocus,
+ collection: state.collection,
+ disabledKeys: state.disabledKeys,
+ selectionManager: state.selectionManager,
+ ref: ref as AriaRefObject,
+ });
+
+ // Clear selection when focus leaves the group.
+ const { focusWithinProps } = useFocusWithin({
+ onBlurWithin: () => state.selectionManager.clearSelection(),
+ });
+
+ const { 'data-collection': collectionId } = listProps as {
+ 'data-collection'?: string;
+ };
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.defaultPrevented) return;
+
+ if (
+ event.key === 'ArrowDown' ||
+ event.key === 'ArrowUp' ||
+ event.key === 'ArrowLeft' ||
+ event.key === 'ArrowRight'
+ ) {
+ event.preventDefault();
+ }
+ };
+
+ return {
+ gridProps: mergeProps(
+ {
+ role: state.collection.size ? 'grid' : 'group',
+ },
+ listProps,
+ { onKeyDown },
+ focusWithinProps
+ ),
+ collectionId,
+ };
+}
diff --git a/packages/primitives/src/behaviors/useTagListItem.ts b/packages/primitives/src/behaviors/useTagListItem.ts
new file mode 100644
index 00000000..05c72079
--- /dev/null
+++ b/packages/primitives/src/behaviors/useTagListItem.ts
@@ -0,0 +1,295 @@
+import type { FocusEvent, RefObject } from 'react';
+import { useEffect } from 'react';
+
+import type { DOMAttributes, Key, PressEvent } from '@koobiq/react-core';
+import {
+ filterDOMProps,
+ isFocusable,
+ mergeProps,
+ useDescription,
+ useId,
+ useInteractionModality,
+ useKeyboard,
+ useLocalizedStringFormatter,
+ usePress,
+} from '@koobiq/react-core';
+import type { ListState } from '@react-stately/list';
+
+const intlMessages = {
+ 'en-US': {
+ removeButtonLabel: 'Remove',
+ removeDescription: 'Press Delete or Backspace to remove.',
+ },
+ 'ru-RU': {
+ removeButtonLabel: 'Удалить',
+ removeDescription: 'Нажмите Delete или Backspace, чтобы удалить.',
+ },
+};
+
+/** True if Ctrl (Windows/Linux) or Cmd (macOS) is held during the event. */
+export function isCommandModifier(event: {
+ ctrlKey: boolean;
+ metaKey: boolean;
+}) {
+ return event.ctrlKey || event.metaKey;
+}
+
+/**
+ * Walks up from `target` to `root` (exclusive) checking whether any element
+ * along the way is itself focusable. Used to let nested focusable controls
+ * (links, buttons) handle their own interactions instead of being swallowed
+ * by the tag's `usePress`.
+ */
+export function isInteractiveTarget(target: Element, root: Element) {
+ let element: Element | null = target;
+
+ while (element && element !== root) {
+ if (isFocusable(element)) return true;
+
+ element = element.parentElement;
+ }
+
+ return false;
+}
+
+export function isSpaceKey(key: string) {
+ return key === ' ' || key === 'Space' || key === 'Spacebar';
+}
+
+/**
+ * Minimal shape `useTagListItem` reads from `item.props`. Renderers can use
+ * a richer prop type (icons, slot props, etc.) — only `isDisabled` matters
+ * to the headless behavior.
+ */
+interface AriaTagListItemNodeProps {
+ isDisabled?: boolean;
+}
+
+export type TagListItemRemoveContext = {
+ source: 'keyboard' | 'press';
+};
+
+export type AriaTagListItemProps = {
+ /** The unique key for the tag. */
+ key: Key;
+ collectionId?: string;
+ onRemove?: (keys: Set, context?: TagListItemRemoveContext) => void;
+ isDisabled?: boolean;
+};
+
+export type TagListItemAria = {
+ /** Props for the tag row element. */
+ rowProps: DOMAttributes;
+ /** Props for the tag cell element. */
+ gridCellProps: DOMAttributes;
+ /** Props for the tag remove button. */
+ removeButtonProps: {
+ isDisabled: boolean | undefined;
+ tabIndex: -1;
+ id: string;
+ 'aria-label': string;
+ 'aria-labelledby': string;
+ onPress: () => void;
+ };
+ isPressed: boolean;
+ isSelected: boolean;
+ isDisabled: boolean | undefined;
+ allowsRemoving: boolean;
+};
+
+export function useTagListItem(
+ props: AriaTagListItemProps,
+ state: ListState,
+ ref: RefObject
+): TagListItemAria {
+ const { key, collectionId, onRemove, isDisabled: isDisabledProp } = props;
+
+ const rowId = useId();
+ const removeButtonId = useId();
+
+ const item = state.collection.getItem(key);
+ const itemProps = item?.props as AriaTagListItemNodeProps | undefined;
+
+ const { selectionManager } = state;
+
+ const isSelected = selectionManager.isSelected(key);
+
+ const isDisabled =
+ isDisabledProp || selectionManager.isDisabled(key) || itemProps?.isDisabled;
+
+ // The remove affordance stays visible on disabled tags — the button just
+ // renders disabled (state propagated via `removeButtonProps.isDisabled`).
+ const allowsRemoving = !!onRemove;
+
+ const allowsSelection = !isDisabled && selectionManager.canSelectItem(key);
+
+ const stringFormatter = useLocalizedStringFormatter(intlMessages);
+
+ // Screen-reader hint announcing the Delete/Backspace shortcut. We only
+ // surface it for keyboard/virtual modalities — pointer users already see
+ // the remove button, and reading the hint out loud would be redundant.
+ // The `'ontouchstart' in window` heuristic re-classifies touch devices as
+ // pointer (same approach as React Aria's `useTag`).
+ let modality = useInteractionModality();
+
+ if (
+ modality === 'virtual' &&
+ typeof window !== 'undefined' &&
+ 'ontouchstart' in window
+ ) {
+ modality = 'pointer';
+ }
+
+ const description =
+ allowsRemoving &&
+ !isDisabled &&
+ (modality === 'keyboard' || modality === 'virtual')
+ ? stringFormatter.format('removeDescription')
+ : '';
+
+ const descProps = useDescription(description);
+
+ // Move DOM focus to this tag when it becomes the focused item.
+ useEffect(() => {
+ if (
+ !isDisabled &&
+ selectionManager.isFocused &&
+ selectionManager.focusedKey === key &&
+ document.activeElement !== ref.current
+ ) {
+ ref.current?.focus();
+ }
+ }, [isDisabled, key, ref, selectionManager]);
+
+ const focusTag = () => {
+ if (isDisabled) return;
+
+ selectionManager.setFocused(true);
+ selectionManager.setFocusedKey(key);
+ ref.current?.focus();
+ };
+
+ const toggleSelection = () => {
+ if (allowsSelection) selectionManager.toggleSelection(key);
+ };
+
+ const getKeysToRemove = () => {
+ if (!isSelected) {
+ return new Set([key]);
+ }
+
+ const selectedKeys = new Set(
+ [...selectionManager.selectedKeys].filter((key) =>
+ Boolean(state.collection.getItem(key))
+ )
+ );
+
+ return selectedKeys.size ? selectedKeys : new Set([key]);
+ };
+
+ const handlePressStart = (event: PressEvent) => {
+ if (isInteractiveTarget(event.target, ref.current ?? event.target)) {
+ event.continuePropagation();
+
+ return;
+ }
+
+ focusTag();
+
+ if (event.pointerType === 'keyboard') return;
+
+ if (isCommandModifier(event)) {
+ toggleSelection();
+ }
+ };
+
+ const { pressProps, isPressed } = usePress({
+ ref,
+ isDisabled,
+ onPressStart: handlePressStart,
+ });
+
+ const { keyboardProps } = useKeyboard({
+ isDisabled,
+ onKeyDown: (event) => {
+ if (isSpaceKey(event.key)) {
+ event.preventDefault();
+ toggleSelection();
+
+ return;
+ }
+
+ if (event.key === 'Enter') {
+ event.preventDefault();
+
+ return;
+ }
+
+ if (event.key === 'Backspace' || event.key === 'Delete') {
+ if (!allowsRemoving) {
+ event.continuePropagation();
+
+ return;
+ }
+
+ event.preventDefault();
+
+ onRemove?.(getKeysToRemove(), { source: 'keyboard' });
+
+ return;
+ }
+
+ event.continuePropagation();
+ },
+ });
+
+ const handleFocus = (event: FocusEvent) => {
+ if (event.target !== event.currentTarget || isDisabled) return;
+
+ selectionManager.setFocused(true);
+ selectionManager.setFocusedKey(key);
+ };
+
+ const rowProps = mergeProps(
+ filterDOMProps(item?.props ?? {}, { global: true }),
+ {
+ ref,
+ id: rowId,
+ role: 'row',
+ tabIndex: selectionManager.focusedKey === key && !isDisabled ? 0 : -1,
+ 'aria-disabled': isDisabled || undefined,
+ 'aria-label': item?.['aria-label'] || item?.textValue || undefined,
+ 'aria-selected': allowsSelection ? isSelected : undefined,
+ 'aria-describedby': descProps['aria-describedby'],
+ 'data-collection': collectionId,
+ 'data-key': key,
+ onFocus: handleFocus,
+ },
+ pressProps,
+ keyboardProps
+ );
+
+ const gridCellProps: DOMAttributes = {
+ role: 'gridcell',
+ 'aria-colindex': 1,
+ };
+
+ const removeButtonProps = {
+ isDisabled,
+ tabIndex: -1 as const,
+ id: removeButtonId,
+ 'aria-label': stringFormatter.format('removeButtonLabel'),
+ 'aria-labelledby': `${removeButtonId} ${rowId}`,
+ onPress: () => onRemove?.(new Set([key]), { source: 'press' }),
+ };
+
+ return {
+ rowProps,
+ isPressed,
+ isSelected,
+ isDisabled,
+ gridCellProps,
+ allowsRemoving,
+ removeButtonProps,
+ };
+}
diff --git a/packages/primitives/src/behaviors/useTagListState.ts b/packages/primitives/src/behaviors/useTagListState.ts
new file mode 100644
index 00000000..9c997d1d
--- /dev/null
+++ b/packages/primitives/src/behaviors/useTagListState.ts
@@ -0,0 +1,13 @@
+import type { ListProps, ListState } from '@react-stately/list';
+import { useListState } from '@react-stately/list';
+
+export type AriaTagListStateProps = ListProps;
+
+export type TagListState = ListState;
+
+/** Builds the collection / selection state for a TagList. */
+export function useTagListState(
+ props: AriaTagListStateProps
+): TagListState {
+ return useListState(props);
+}
diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts
index f7551058..a3cca5f7 100644
--- a/packages/primitives/src/index.ts
+++ b/packages/primitives/src/index.ts
@@ -75,6 +75,8 @@ export * from '@react-stately/table';
export * from '@react-aria/searchfield';
export * from '@react-stately/searchfield';
+export * from '@react-aria/textfield';
+
export * from '@react-aria/progress';
export * from '@react-aria/combobox';
diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json
index a5ba29a2..0726a93c 100644
--- a/tools/api-extractor/config.json
+++ b/tools/api-extractor/config.json
@@ -44,6 +44,8 @@
"Table",
"Tabs",
"TagGroup",
+ "TagList",
+ "TagInput",
"Textarea",
"TimePicker",
"ToastProvider",
diff --git a/tools/public_api_guard/components/TagInput.api.md b/tools/public_api_guard/components/TagInput.api.md
new file mode 100644
index 00000000..895f4a1c
--- /dev/null
+++ b/tools/public_api_guard/components/TagInput.api.md
@@ -0,0 +1,133 @@
+## API Report File for "koobiq-react"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import type { AriaTagFieldProps } from '@koobiq/react-primitives';
+import type { ButtonBaseProps } from '@koobiq/react-primitives';
+import type { CollectionBase } from '@koobiq/react-core';
+import type { ComponentPropsWithRef } from 'react';
+import type { ComponentRef } from 'react';
+import type { CSSProperties } from 'react';
+import type { DataAttributeProps } from '@koobiq/react-core';
+import type { DOMAttributes } from '@koobiq/react-core';
+import type { ElementType } from 'react';
+import { ExtendableComponentPropsWithRef } from '@koobiq/react-core';
+import type { ExtendableProps } from '@koobiq/react-core';
+import type { FocusStrategy } from '@koobiq/react-core';
+import { ForwardRefExoticComponent } from 'react';
+import type { ItemProps } from '@koobiq/react-core';
+import { JSX } from 'react/jsx-runtime';
+import type { Key } from '@koobiq/react-core';
+import type { ListState } from '@koobiq/react-primitives';
+import type { MultipleSelection } from '@koobiq/react-core';
+import { PolyForwardComponent } from '@koobiq/react-core';
+import type { ReactElement } from 'react';
+import { ReactNode } from 'react';
+import { Ref } from 'react';
+import { RefAttributes } from 'react';
+import type { RefObject } from 'react';
+import { TagAutocompleteState } from '@koobiq/react-primitives';
+import type { TagFieldAddContext } from '@koobiq/react-primitives';
+import type { TagFieldAddSource } from '@koobiq/react-primitives';
+import { TagFieldState } from '@koobiq/react-primitives';
+import { TextProps } from '@koobiq/react-primitives';
+import { ValidationResult } from '@koobiq/react-core';
+
+// Warning: (ae-forgotten-export) The symbol "CompoundedComponent_2" needs to be exported by the entry point index.d.ts
+//
+// @public (undocumented)
+export const TagInput: CompoundedComponent_2;
+
+// @public (undocumented)
+export type TagInputAddContext = TagFieldAddContext;
+
+// @public
+export type TagInputAddSource = TagFieldAddSource;
+
+// @public (undocumented)
+export type TagInputComponent = (props: TagInputProps & {
+ ref?: Ref;
+}) => ReactElement | null;
+
+// @public
+export function TagInputInner(inProps: TagInputInnerProps): JSX.Element;
+
+// @public (undocumented)
+export type TagInputInnerProps = {
+ state: TagFieldState | TagAutocompleteState;
+ inputRef?: Ref;
+ popoverRef?: RefObject;
+ listBoxRef?: RefObject;
+} & TagInputProps;
+
+// Warning: (ae-forgotten-export) The symbol "FormFieldPropLabelAlign" needs to be exported by the entry point index.d.ts
+//
+// @public (undocumented)
+export type TagInputPropLabelAlign = FormFieldPropLabelAlign;
+
+// @public (undocumented)
+export const tagInputPropLabelAlign: readonly ["start", "end"];
+
+// Warning: (ae-forgotten-export) The symbol "FormFieldPropLabelPlacement" needs to be exported by the entry point index.d.ts
+//
+// @public (undocumented)
+export type TagInputPropLabelPlacement = FormFieldPropLabelPlacement;
+
+// @public (undocumented)
+export const tagInputPropLabelPlacement: readonly ["top", "side"];
+
+// Warning: (ae-forgotten-export) The symbol "TagInputBaseProps" needs to be exported by the entry point index.d.ts
+//
+// @public (undocumented)
+export type TagInputProps = TagInputBaseProps & {
+ caption?: ReactNode;
+ startAddon?: ReactNode;
+ endAddon?: ReactNode;
+ isLabelHidden?: boolean;
+ fullWidth?: boolean;
+ variant?: TagInputPropVariant;
+ labelPlacement?: TagInputPropLabelPlacement;
+ labelAlign?: TagInputPropLabelAlign;
+ className?: string;
+ style?: CSSProperties;
+ 'data-testid'?: string | number;
+ ref?: Ref;
+ slotProps?: {
+ root?: FormFieldProps;
+ label?: FormFieldLabelProps;
+ caption?: FormFieldCaptionProps;
+ group?: FormFieldControlGroupProps;
+ errorMessage?: FormFieldErrorProps;
+ clearButton?: IconButtonProps;
+ input?: FormFieldInputProps;
+ tagList?: Partial, 'state' | 'isDisabled'>>;
+ };
+};
+
+// Warning: (ae-forgotten-export) The symbol "FormFieldControlGroupPropVariant" needs to be exported by the entry point index.d.ts
+//
+// @public (undocumented)
+export type TagInputPropVariant = FormFieldControlGroupPropVariant;
+
+// @public (undocumented)
+export const tagInputPropVariant: readonly ["filled", "transparent"];
+
+// @public (undocumented)
+export type TagInputRef = ComponentRef<'input'>;
+
+// Warnings were encountered during analysis:
+//
+// packages/components/dist/components/TagInput/types.d.ts:52:9 - (ae-forgotten-export) The symbol "FormFieldProps" needs to be exported by the entry point index.d.ts
+// packages/components/dist/components/TagInput/types.d.ts:53:9 - (ae-forgotten-export) The symbol "FormFieldLabelProps" needs to be exported by the entry point index.d.ts
+// packages/components/dist/components/TagInput/types.d.ts:54:9 - (ae-forgotten-export) The symbol "FormFieldCaptionProps" needs to be exported by the entry point index.d.ts
+// packages/components/dist/components/TagInput/types.d.ts:55:9 - (ae-forgotten-export) The symbol "FormFieldControlGroupProps" needs to be exported by the entry point index.d.ts
+// packages/components/dist/components/TagInput/types.d.ts:56:9 - (ae-forgotten-export) The symbol "FormFieldErrorProps" needs to be exported by the entry point index.d.ts
+// packages/components/dist/components/TagInput/types.d.ts:57:9 - (ae-forgotten-export) The symbol "IconButtonProps" needs to be exported by the entry point index.d.ts
+// packages/components/dist/components/TagInput/types.d.ts:58:9 - (ae-forgotten-export) The symbol "FormFieldInputProps" needs to be exported by the entry point index.d.ts
+// packages/components/dist/components/TagInput/types.d.ts:59:9 - (ae-forgotten-export) The symbol "TagListInnerProps" needs to be exported by the entry point index.d.ts
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/tools/public_api_guard/components/TagList.api.md b/tools/public_api_guard/components/TagList.api.md
new file mode 100644
index 00000000..456d6a50
--- /dev/null
+++ b/tools/public_api_guard/components/TagList.api.md
@@ -0,0 +1,56 @@
+## API Report File for "koobiq-react"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import type { ButtonBaseProps } from '@koobiq/react-primitives';
+import type { CollectionBase } from '@koobiq/react-core';
+import type { ComponentPropsWithRef } from 'react';
+import type { ComponentRef } from 'react';
+import type { CSSProperties } from 'react';
+import type { ElementType } from 'react';
+import type { ExtendableProps } from '@koobiq/react-core';
+import type { FocusStrategy } from '@koobiq/react-core';
+import type { ItemProps } from '@koobiq/react-core';
+import type { Key } from '@koobiq/react-core';
+import type { ListState } from '@koobiq/react-primitives';
+import type { MultipleSelection } from '@koobiq/react-core';
+import { PolyForwardComponent } from '@koobiq/react-core';
+import type { ReactElement } from 'react';
+import type { ReactNode } from 'react';
+import type { Ref } from 'react';
+
+// Warning: (ae-forgotten-export) The symbol "CompoundedComponent" needs to be exported by the entry point index.d.ts
+//
+// @public
+export const TagList: CompoundedComponent;
+
+// @public (undocumented)
+export type TagListComponent = (props: TagListProps) => ReactElement | null;
+
+// @public (undocumented)
+export type TagListInnerProps = {
+ state: ListState;
+ isDisabled?: boolean;
+ tagListRef?: Ref;
+} & Omit, 'ref' | 'children' | 'items' | 'disabledKeys' | 'selectionMode' | 'disallowEmptySelection' | 'selectedKeys' | 'defaultSelectedKeys' | 'onSelectionChange'>;
+
+// Warning: (ae-forgotten-export) The symbol "TagListBaseProps" needs to be exported by the entry point index.d.ts
+// Warning: (ae-forgotten-export) The symbol "TagListInheritedProps" needs to be exported by the entry point index.d.ts
+//
+// @public (undocumented)
+export type TagListProps = ExtendableProps>;
+
+// @public (undocumented)
+export type TagListPropVariant = (typeof tagListPropVariant)[number];
+
+// @public (undocumented)
+export const tagListPropVariant: readonly ["theme-fade", "contrast-fade", "error-fade", "warning-fade"];
+
+// @public (undocumented)
+export type TagListRef = ComponentRef<'div'>;
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/tools/public_api_guard/react-core.api.md b/tools/public_api_guard/react-core.api.md
index cc8db359..59573770 100644
--- a/tools/public_api_guard/react-core.api.md
+++ b/tools/public_api_guard/react-core.api.md
@@ -18,6 +18,7 @@ import type { ElementType } from 'react';
import { filterDOMProps } from '@react-aria/utils';
import { FocusableElement } from '@react-types/shared';
import { FocusableProps } from '@react-types/shared';
+import { FocusStrategy } from '@react-types/shared';
import { FormProps } from '@react-types/form';
import type { ForwardRefExoticComponent } from 'react';
import type { ForwardRefRenderFunction } from 'react';
@@ -47,6 +48,7 @@ import { Selection as Selection_2 } from '@react-types/shared';
import type { SetStateAction } from 'react';
import { SortDescriptor } from '@react-types/shared';
import { TextInputBase } from '@react-types/shared';
+import { useDescription } from '@react-aria/utils';
import { useEffect } from 'react';
import { useId } from '@react-aria/utils';
import { useLinkProps } from '@react-aria/utils';
@@ -107,6 +109,8 @@ export { FocusableElement }
export { FocusableProps }
+export { FocusStrategy }
+
export { FormProps }
export { GlobalDOMAttributes }
@@ -263,6 +267,8 @@ CB,
() => void
];
+export { useDescription }
+
// @public (undocumented)
export function useDOMRef(ref?: RefObject_2 | Ref_2): RefObject_2;
diff --git a/tools/public_api_guard/react-primitives.api.md b/tools/public_api_guard/react-primitives.api.md
index 23532b40..2d461699 100644
--- a/tools/public_api_guard/react-primitives.api.md
+++ b/tools/public_api_guard/react-primitives.api.md
@@ -45,6 +45,7 @@ import { CheckboxGroupState } from '@react-stately/checkbox';
import { ClipboardEventHandler } from 'react';
import { Collection } from 'react-aria-components';
import { CollectionBase } from '@koobiq/react-core';
+import type { CollectionChildren } from '@koobiq/react-core';
import { ComboBoxState } from '@react-stately/combobox';
import { ComboBoxStateOptions } from '@react-stately/combobox';
import type { ComponentPropsWithoutRef } from 'react';
@@ -68,6 +69,7 @@ import type { ExtendableProps } from '@koobiq/react-core';
import { FocusableElement } from '@react-types/shared';
import { FocusableProps } from '@koobiq/react-core';
import { FocusEventHandler } from 'react';
+import type { FocusStrategy } from '@koobiq/react-core';
import { FormEventHandler } from 'react';
import type { FormProps as FormProps_2 } from '@koobiq/react-core';
import type { FormValidationState } from '@react-stately/form';
@@ -83,9 +85,11 @@ import { InputEventHandler } from 'react';
import { InputHTMLAttributes } from 'react';
import { Item } from '@react-stately/collections';
import type { Key } from 'react';
+import type { Key as Key_2 } from '@koobiq/react-core';
import { KeyboardEventHandler } from 'react';
import { LabelableProps } from '@koobiq/react-core';
import { LabelHTMLAttributes } from 'react';
+import type { ListProps } from '@react-stately/list';
import type { ListState } from '@react-stately/list';
import type { MenuTriggerState } from '@react-stately/menu';
import { MouseEventHandler } from 'react';
@@ -106,6 +110,8 @@ import { RefAttributes } from 'react';
import { RefObject } from 'react';
import type { RenderProps as RenderProps_2 } from 'react-aria-components';
import type { RouterOptions } from '@koobiq/react-core';
+import type { Selection as Selection_2 } from '@koobiq/react-core';
+import type { TextFieldAria } from '@react-aria/textfield';
import { TextInputBase } from '@koobiq/react-core';
import { ToggleEventHandler } from 'react';
import type { ToggleState } from '@react-stately/toggle';
@@ -160,6 +166,45 @@ export { AriaCheckboxGroupProps }
export { AriaCheckboxProps }
+// @public (undocumented)
+export type AriaTagAutocompleteProps = TagAutocompleteStateProps;
+
+// @public (undocumented)
+export type AriaTagFieldProps = Omit, 'value' | 'defaultValue' | 'onChange'> & {
+ items?: Iterable