Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
b38c065
feat(components): draft `TagList` implementation
KamilEmeleev May 19, 2026
3192a08
fix(TagList): update Tag variants and states
KamilEmeleev May 20, 2026
b6fa186
feat(TagGroupNext): localize remove button label and announce shortcu…
KamilEmeleev May 20, 2026
951dd8f
feat(TagGroupNext): expose public --kbq-tag-* CSS variables
KamilEmeleev May 20, 2026
223f40f
refactor(TagGroupNext): replace hand-rolled handleBlur with useFocusW…
KamilEmeleev May 20, 2026
07fc531
refactor(TagGroupNext): drop unimplemented link/section props from Ta…
KamilEmeleev May 20, 2026
0608ec3
feat(TagList): improve structure, tests and documentation, rename the…
KamilEmeleev May 21, 2026
c991586
docs(TagList): mark keyboard keys with <kbd> in MDX
KamilEmeleev May 21, 2026
d62967d
docs(TagList): pair Ctrl with Cmd in keyboard docs
KamilEmeleev May 21, 2026
5c83b78
refactor(TagList): extract behavior into useTagList and useTagListState
KamilEmeleev May 21, 2026
db60548
test(TagList): add prop forwarding tests and fix data-testid/aria leak
KamilEmeleev May 21, 2026
ea4800b
refactor(TagList): drop prop duplication between TagList and TagListI…
KamilEmeleev May 21, 2026
9a7f841
refactor(primitives): move TagList hooks to behaviors
KamilEmeleev May 21, 2026
b9fb716
docs(TagList): polish MDX, add JSDoc, register storybook tags and roa…
KamilEmeleev May 22, 2026
7112d35
chore(api-extractor): add TagList API guard and refresh primitives re…
KamilEmeleev May 22, 2026
b8c7233
fix(TagList): keep remove button visible on disabled tags
KamilEmeleev May 25, 2026
94a9432
feat(components): draft `TagInput` implementation
KamilEmeleev May 26, 2026
4232c56
docs(TagInput): mark as a draft
KamilEmeleev May 26, 2026
d22c76a
feat(TagList): handle inherited disabled state
KamilEmeleev May 28, 2026
f4789f4
feat(TagList): allow per-tag variant
KamilEmeleev May 28, 2026
e94a077
chore(lint): fix code style
KamilEmeleev May 28, 2026
fa648b2
feat(TagInput): update tags collection API
KamilEmeleev May 28, 2026
6d484be
fix(TagInput): prevent long tag from expanding the field
KamilEmeleev May 28, 2026
afc2cd9
fix(TagInput): add field sizing fallback
KamilEmeleev May 28, 2026
4403119
fix(TagInput): set 'contrast-fade' by default
KamilEmeleev May 29, 2026
7c06e7a
docs(TagInput): improve the layout of stories
KamilEmeleev May 29, 2026
af458bc
feat(TagInput): add `disableCommitOnBlur` prop
KamilEmeleev May 29, 2026
912a6dd
fix(TagInput): forward `id` to the underlying input
KamilEmeleev May 29, 2026
9ca6cdd
fix(TagList): disable focus wrap on arrow navigation
KamilEmeleev May 29, 2026
062b55c
docs(TagInput): add args to Base story
KamilEmeleev May 29, 2026
5d53c19
docs(TagInput): document the duplicate-prevention pattern
KamilEmeleev May 29, 2026
283f612
docs(TagInput): add input validation example
KamilEmeleev May 29, 2026
036face
refactor(TagList): drop unused collectionId forwarding
KamilEmeleev Jun 1, 2026
34c286c
refactor(TagList): expose collectionId from useTagList
KamilEmeleev Jun 1, 2026
d985a09
feat(primitives): add `useTagField` hook
KamilEmeleev Jun 2, 2026
6523d2d
refactor(TagInput): reuse `useTagField` behavior hook
KamilEmeleev Jun 2, 2026
f46de1c
feat(components): start `TagAutocomplete` implementation
KamilEmeleev Jun 2, 2026
10348d8
docs(TagInput): fix 'Autofill' story
KamilEmeleev Jun 3, 2026
d326e9e
fix(TagAutocomplete): improve keyboard navigation
KamilEmeleev Jun 3, 2026
2bb7a8d
fix(TagAutocomplete): close popover on focus loss
KamilEmeleev Jun 3, 2026
9d2a7d0
fix(TagInput): keep focus in input after tag interactions
KamilEmeleev Jun 3, 2026
2c965bf
refactor(TagAutocomplete): unify suggestion picks into TagInput's onAdd
KamilEmeleev Jun 3, 2026
387c3bb
feat(TagAutocomplete): rework component API
KamilEmeleev Jun 3, 2026
bdb1161
feat(TagAutocomplete): rework API and improve internal logic
KamilEmeleev Jun 4, 2026
77c33ce
docs(TagAutocomplete): add basic examples and usage docs
KamilEmeleev Jun 4, 2026
3b8155d
refactor(TagAutocomplete, TagInput): simplify props
KamilEmeleev Jun 5, 2026
9d3bcbe
feat(TagAutocomplete): add allowsEmptyCollection prop
KamilEmeleev Jun 5, 2026
e41a7c6
fix(TagAutocomplete): allow custom input after keyboard selection
KamilEmeleev Jun 5, 2026
50d12dc
fix(TagAutocomplete): allow custom input after keyboard selection (ro…
KamilEmeleev Jun 5, 2026
8c54969
fix(TagList): stabilize keyboard focus flow
KamilEmeleev Jun 5, 2026
9d79fa1
fix(TagAutocomplete): prevent suggestions in read-only
KamilEmeleev Jun 5, 2026
65a7c02
feat(TagAutocomplete): add disableCloseOnSelect prop
KamilEmeleev Jun 5, 2026
02c3898
feat(TagInput): add start and end addons
KamilEmeleev Jun 5, 2026
2e7efa0
chore: approve api
KamilEmeleev Jun 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .storybook/components/Roadmap/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,4 +332,10 @@ export const rows: Rows = [
stage: '🔵 experimental',
planned: 'Q2 2026',
},
{
component: 'TagList',
status: '✅ Done',
stage: '🔵 experimental',
planned: 'Q2 2026',
},
];
32 changes: 31 additions & 1 deletion packages/components/src/components/Form/Form.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type FormEvent, useState } from 'react';
import { type FormEvent, useRef, useState } from 'react';

import type { Meta, StoryObj } from '@storybook/react';

Expand All @@ -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';
Expand Down Expand Up @@ -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 (
<Form
labelPlacement={{ xs: 'top', m: 'side' }}
Expand All @@ -438,6 +459,15 @@ export const FormFields: Story = {
<Select.Item key="3">Option 3</Select.Item>
</Select>
<Input label="Input" placeholder="Type a word..." />
<TagInput<{ id: string; name: string }>
label="Tag input"
placeholder="Type and press Enter"
items={tags.items}
onAdd={addTags}
onRemove={(keys) => tags.remove(...keys)}
>
{(item) => <TagInput.Tag key={item.id}>{item.name}</TagInput.Tag>}
</TagInput>
<Textarea label="Textarea" placeholder="Type a word..." />
<InputNumber label="InputNumber" placeholder="Type a number..." />
<SearchInput label="SearchInput" placeholder="Type a word..." />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import {
Props,
Story,
Meta,
Status,
} from '../../../../../.storybook/components';

import * as Stories from './TagAutocomplete.stories';

<Meta of={Stories} />

# TagAutocomplete

<Status variant="draft" />

A multi-tag entry with autocomplete suggestions. It behaves like `TagInput`
for free-form values and adds a combobox popover for selecting known items.

## Import

```tsx
import { TagAutocomplete } from '@koobiq/react-components';
```

## Usage

<Story of={Stories.Base} />

## Props

<Props of={Stories.Base} />

## Addons

You can add extra content using the `startAddon` and `endAddon` props.

<Story of={Stories.Addons} />

## Items

The tag collection is **owned by the consumer**. `TagAutocomplete` renders
selected tags from `items`, renders suggestions from `listItems`, and emits
intent events through `onAdd` / `onRemove`.

Use stable keys for both selected tags and suggestions:

```tsx
<TagAutocomplete
items={tags.items}
onRemove={(keys) => tags.remove(...keys)}
onAdd={(values, context) => {
if (context.source === 'suggestion') {
tags.append(context.suggestion);
} else {
tags.append(...values.map(createTag));
}
}}
listItems={suggestions}
renderListItem={(item) => (
<TagAutocomplete.ListItem key={item.id} textValue={item.name}>
{item.name}
</TagAutocomplete.ListItem>
)}
>
{(item) => (
<TagAutocomplete.Tag key={item.id} textValue={item.name}>
{item.name}
</TagAutocomplete.Tag>
)}
</TagAutocomplete>
```

`TagAutocomplete.ListItem` provides the suggestion key and `textValue` used
for filtering, exclusion of already selected tags, and the suggestion context.

## Suggestions

Suggestions open when the input receives focus. `defaultFilter(textValue,
inputValue)` filters the popover list, while selected tags are excluded
automatically by key and normalized `textValue`.

Selecting a suggestion calls `onAdd(values, context)` with
`context.source === 'suggestion'` and exposes the original item as
`context.suggestion`.

## Adding tags

Free-form entry keeps the same behavior as `TagInput`: press <kbd>Enter</kbd>,
type a split character, paste a delimited string, or blur the field with a
non-empty input value. In those cases, `context.source` is `'enter'`,
`'separator'`, `'paste'`, or `'blur'`.

To also split on `;` or whitespace, pass a custom `splitPattern`.

<Story of={Stories.SplitPattern} />

## Preventing duplicates

Suggestions that are already selected are hidden automatically. Free-form
values are passed to `onAdd` as-is, so apply your own duplicate policy in the
handler:

```tsx
onAdd={(values, context) => {
if (context.source === 'suggestion') {
tags.append(context.suggestion);

return;
}

const existing = new Set(tags.items.map((item) => item.name.toLowerCase()));
const fresh = values.filter((value) => !existing.has(value.toLowerCase()));

if (fresh.length === 0) return;
tags.append(...fresh.map(createTag));
}}
```

<Story of={Stories.PreventDuplicates} />

## Variant

<Story of={Stories.Variant} />

## Form field

`TagAutocomplete` is a full form-field on par with `TagInput`. It accepts a
visible or hidden `label`, a helper `caption`, `errorMessage`, `isInvalid`,
`isRequired`, `fullWidth`, `labelPlacement`, and `labelAlign`.

<Story of={Stories.FormField} />

## Disabled

When `isDisabled` is set, the input is disabled, tags render in a disabled
state, and suggestions cannot be selected.

<Story of={Stories.Disabled} />

## Read-only

When `isReadOnly` is set, the input remains focusable for navigation but no
modifications are allowed. Typing is blocked, suggestions cannot be selected,
and remove buttons are not rendered.

<Story of={Stories.ReadOnly} />

## Cleaner

Enable the cleaner button with `isClearable`. Pressing it removes all tags,
clears the text input, calls `onClear`, and returns focus to the input.

<Story of={Stories.Clearable} />

## Inside a Form

When wrapped in a `<Form>`, `TagAutocomplete` inherits `isDisabled`,
`isReadOnly`, `labelPlacement`, and `labelAlign` from the form context.

<Story of={Stories.InsideForm} />

## 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 |
| -------------------- | -------------------------------------------------------------------------------- |
| <kbd>ArrowDown</kbd> | Open suggestions and move virtual focus to the first option. |
| <kbd>ArrowUp</kbd> | Open suggestions and move virtual focus to the last option. |
| <kbd>Enter</kbd> | Select the focused suggestion, or commit the current input value as a new tag. |
| <kbd>Escape</kbd> | Close the suggestion popover. |
| <kbd>Backspace</kbd> | If the input is empty and there are tags, focus the last tag. |
| _split character_ | Commit and reset the input — same effect as <kbd>Enter</kbd> for free-form text. |
| <kbd>Tab</kbd> | Move focus to the cleaner button (if visible) or out of the field. |
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading