From b38c065e18e142b343d1c250fc9fb2961c596727 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Tue, 19 May 2026 19:25:14 +0300 Subject: [PATCH 01/54] feat(components): draft `TagList` implementation --- .../src/components/TagGroupNext/Tag.tsx | 50 ++++ .../components/TagGroupNext/TagGroupNext.mdx | 75 +++++ .../TagGroupNext/TagGroupNext.module.css | 9 + .../TagGroupNext/TagGroupNext.stories.tsx | 219 +++++++++++++++ .../TagGroupNext/TagGroupNext.test.tsx | 262 ++++++++++++++++++ .../components/TagGroupNext/TagGroupNext.tsx | 118 ++++++++ .../components/TagItem/TagItem.module.css | 137 +++++++++ .../components/TagItem/TagItem.tsx | 117 ++++++++ .../TagGroupNext/components/TagItem/index.ts | 1 + .../TagGroupNext/components/TagItem/utils.ts | 19 ++ .../TagGroupNext/components/index.ts | 1 + .../components/TagGroupNext/hooks/index.ts | 1 + .../TagGroupNext/hooks/useTagItem.ts | 211 ++++++++++++++ .../src/components/TagGroupNext/index.ts | 2 + .../src/components/TagGroupNext/types.ts | 77 +++++ packages/components/src/components/index.ts | 1 + 16 files changed, 1300 insertions(+) create mode 100644 packages/components/src/components/TagGroupNext/Tag.tsx create mode 100644 packages/components/src/components/TagGroupNext/TagGroupNext.mdx create mode 100644 packages/components/src/components/TagGroupNext/TagGroupNext.module.css create mode 100644 packages/components/src/components/TagGroupNext/TagGroupNext.stories.tsx create mode 100644 packages/components/src/components/TagGroupNext/TagGroupNext.test.tsx create mode 100644 packages/components/src/components/TagGroupNext/TagGroupNext.tsx create mode 100644 packages/components/src/components/TagGroupNext/components/TagItem/TagItem.module.css create mode 100644 packages/components/src/components/TagGroupNext/components/TagItem/TagItem.tsx create mode 100644 packages/components/src/components/TagGroupNext/components/TagItem/index.ts create mode 100644 packages/components/src/components/TagGroupNext/components/TagItem/utils.ts create mode 100644 packages/components/src/components/TagGroupNext/components/index.ts create mode 100644 packages/components/src/components/TagGroupNext/hooks/index.ts create mode 100644 packages/components/src/components/TagGroupNext/hooks/useTagItem.ts create mode 100644 packages/components/src/components/TagGroupNext/index.ts create mode 100644 packages/components/src/components/TagGroupNext/types.ts diff --git a/packages/components/src/components/TagGroupNext/Tag.tsx b/packages/components/src/components/TagGroupNext/Tag.tsx new file mode 100644 index 000000000..c3fb41895 --- /dev/null +++ b/packages/components/src/components/TagGroupNext/Tag.tsx @@ -0,0 +1,50 @@ +'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'; + +type ItemComponent = FC> & { + getCollectionNode: unknown; +}; + +const TagInner = AriaItem as ItemComponent; + +export type TagSlotProps = { + root?: ComponentPropsWithRef<'div'>; + icon?: ComponentPropsWithRef<'span'>; + content?: ComponentPropsWithRef<'span'>; + removeIcon?: IconButtonProps; +}; + +export type ItemProps = Omit, 'children'> & { + /** 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; + /** The props used for each slot inside. */ + slotProps?: TagSlotProps; + /** Rendered contents of the item or child items. */ + children?: ReactNode; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function Tag(_props: ItemProps) { + return null; +} + +Tag.getCollectionNode = TagInner.getCollectionNode; diff --git a/packages/components/src/components/TagGroupNext/TagGroupNext.mdx b/packages/components/src/components/TagGroupNext/TagGroupNext.mdx new file mode 100644 index 000000000..985424891 --- /dev/null +++ b/packages/components/src/components/TagGroupNext/TagGroupNext.mdx @@ -0,0 +1,75 @@ +import { + Props, + Story, + Meta, + Status, +} from '../../../../../.storybook/components'; + +import * as Stories from './TagGroupNext.stories'; + + + +# TagGroupNext + + + +A tag group is a focusable list of labels, categories, keywords, filters, +or other items, with support for keyboard navigation and removal. + +## Import + +```tsx +import { TagGroupNext } from '@koobiq/react-components'; +``` + +## Usage + + + +## Props + + + +## Selection + +TagGroupNext keeps pointer selection explicit: a regular click only focuses a tag. +To select tags, use `Ctrl`/`Cmd` + click, `Ctrl+A`, or press `Space` while a tag is focused. + + + +## Variant + +To change the visual state of tags, use the `variant` prop. + + + +## 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 the backspace key while the tag +is focused to remove the tag from the group. + + + +## Input composition + +TagGroupNext can be composed with an input placed next to the tags. +The input can add tags on `Enter`, and move focus back to the tag group with `ArrowLeft`. + + + +## Disabled tags + +TagGroupNext supports marking items as disabled using the `disabledKeys` prop. +Each key in this group corresponds with the `key` prop passed to the `TagGroupNext.Tag` component, +or automatically derived from the values passed to the `items` prop. + + diff --git a/packages/components/src/components/TagGroupNext/TagGroupNext.module.css b/packages/components/src/components/TagGroupNext/TagGroupNext.module.css new file mode 100644 index 000000000..4e3b0717a --- /dev/null +++ b/packages/components/src/components/TagGroupNext/TagGroupNext.module.css @@ -0,0 +1,9 @@ +.base { + flex-wrap: wrap; + gap: var(--kbq-size-xxs); + display: flex; +} + +.base [role='gridcell'] { + display: contents; +} diff --git a/packages/components/src/components/TagGroupNext/TagGroupNext.stories.tsx b/packages/components/src/components/TagGroupNext/TagGroupNext.stories.tsx new file mode 100644 index 000000000..0c9fc7d50 --- /dev/null +++ b/packages/components/src/components/TagGroupNext/TagGroupNext.stories.tsx @@ -0,0 +1,219 @@ +import { useRef, useState, type KeyboardEvent } from 'react'; + +import { IconGlobe16 } from '@koobiq/react-icons'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { FlexBox } from '../FlexBox'; +import { useListData } from '../index'; +import { Input } from '../Input'; + +import { TagGroupNext } from './TagGroupNext'; +import { tagGroupNextPropVariant } from './types'; + +const meta = { + title: 'Components/TagGroupNext', + component: TagGroupNext, + subcomponents: { Tag: TagGroupNext.Tag }, + parameters: { + layout: 'centered', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +type EditableTagItem = { + id: string; + name: string; +}; + +const editableInitialItems: EditableTagItem[] = [ + { id: 'react', name: 'React' }, + { id: 'typescript', name: 'Typescript' }, + { id: 'storybook', name: 'Storybook' }, +]; + +export const Base: Story = { + render: (args) => ( + + React + Typescript + Storybook + Tailwind + + ), +}; + +export const Variant: Story = { + render: (args) => ( + + {tagGroupNextPropVariant.map((variant) => ( + undefined} + aria-label="Libraries" + {...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 ( + + aria-label="Libraries" + selectionMode="multiple" + items={list.items} + disabledKeys={[4]} + onRemove={(keys) => { + args.onRemove?.(keys); + list.remove(...keys); + }} + > + {(item) => {item.name}} + + ); + }, +}; + +export const WithInput: Story = { + render: function Render() { + const tagGroupRef = useRef(null); + const nextIdRef = useRef(1); + + const list = useListData({ + initialItems: editableInitialItems, + }); + const [value, setValue] = useState(''); + + const focusLastTag = () => { + const tags = + tagGroupRef.current?.querySelectorAll('[role="row"]'); + + tags?.[tags.length - 1]?.focus(); + }; + + const handleInputKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + const name = value.trim(); + + if (!name) return; + + event.preventDefault(); + const id = `custom-${nextIdRef.current}`; + nextIdRef.current += 1; + + list.append({ id, name }); + setValue(''); + + return; + } + + if ( + event.key === 'ArrowLeft' && + event.currentTarget.selectionStart === 0 && + event.currentTarget.selectionEnd === 0 + ) { + event.preventDefault(); + focusLastTag(); + } + }; + + return ( + + + + ref={tagGroupRef} + items={list.items} + selectionMode="multiple" + aria-label="Selected libraries" + 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 + + + ), +}; diff --git a/packages/components/src/components/TagGroupNext/TagGroupNext.test.tsx b/packages/components/src/components/TagGroupNext/TagGroupNext.test.tsx new file mode 100644 index 000000000..9c5cfba0b --- /dev/null +++ b/packages/components/src/components/TagGroupNext/TagGroupNext.test.tsx @@ -0,0 +1,262 @@ +import { useState } from 'react'; + +import { render, screen, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { isInteractiveTarget } from './hooks/useTagItem'; +import { TagGroupNext, type TagGroupNextProps } from './index'; + +const TAG_GROUP_NEXT_TEST_ID = 'TAG_GROUP_NEXT_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 RemovableTagGroupNext() { + const [items, setItems] = useState(removableItems); + + return ( + + aria-label="removable-tag-group-next" + selectionMode="multiple" + items={items} + onRemove={(keys) => { + setItems((currentItems) => + currentItems.filter((item) => !keys.has(item.id)) + ); + }} + > + {(item) => ( + + {item.name} + + )} + + ); +} + +describe('TagGroupNext', () => { + const getTag = () => screen.getByTestId(TAG_GROUP_NEXT_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 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}'); + await user.click(screen.getAllByRole('row')[0]); + + expect(getTag()).toHaveAttribute('data-selected', 'true'); + }); + + it('should clear selection when focus leaves tag group', async () => { + const user = userEvent.setup(); + + render( + <> + {renderComponent({ selectionMode: 'multiple' })} + + + ); + + 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()); + }); +}); diff --git a/packages/components/src/components/TagGroupNext/TagGroupNext.tsx b/packages/components/src/components/TagGroupNext/TagGroupNext.tsx new file mode 100644 index 000000000..37bb617ee --- /dev/null +++ b/packages/components/src/components/TagGroupNext/TagGroupNext.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { forwardRef, useMemo } from 'react'; +import type { FocusEvent, RefObject, Ref } from 'react'; + +import { clsx, mergeProps, useDOMRef, useLocale } from '@koobiq/react-core'; +import { + ListKeyboardDelegate, + useListState, + useSelectableList, +} from '@koobiq/react-primitives'; + +import { TagItem } from './components'; +import { Tag } from './Tag'; +import groupStyles from './TagGroupNext.module.css'; +import type { TagGroupNextComponent, TagGroupNextProps } from './types'; + +function TagGroupNextRender( + props: TagGroupNextProps, + ref?: Ref +) { + const { + variant = 'theme-fade', + style, + className, + slotProps, + onRemove, + escapeKeyBehavior, + } = props; + const domRef = useDOMRef(ref); + const state = useListState(props); + const { direction } = useLocale(); + + const keyboardDelegate = useMemo( + () => + new ListKeyboardDelegate({ + collection: state.collection, + direction, + disabledBehavior: state.selectionManager.disabledBehavior, + disabledKeys: state.disabledKeys, + orientation: 'horizontal', + ref: domRef as RefObject, + }), + [ + domRef, + direction, + state.collection, + state.disabledKeys, + state.selectionManager.disabledBehavior, + ] + ); + + const { listProps } = useSelectableList({ + keyboardDelegate, + shouldFocusWrap: true, + escapeKeyBehavior, + collection: state.collection, + disabledKeys: state.disabledKeys, + selectionManager: state.selectionManager, + ref: domRef as RefObject, + }); + + const handleBlur = (event: FocusEvent) => { + const nextFocusedElement = event.relatedTarget; + + if ( + nextFocusedElement instanceof Node && + event.currentTarget.contains(nextFocusedElement) + ) { + return; + } + + state.selectionManager.clearSelection(); + }; + + const collectionId = (listProps as Record)[ + 'data-collection' + ] as string | undefined; + + const rootProps = mergeProps( + { + style, + ref: domRef, + onBlur: handleBlur, + className: clsx(groupStyles.base, className), + role: state.collection.size ? 'grid' : 'group', + }, + listProps, + slotProps?.root + ); + + return ( +
+ {[...state.collection].map((item) => ( + + ))} +
+ ); +} + +const TagGroupNextComponent = forwardRef( + TagGroupNextRender +) as TagGroupNextComponent; + +type CompoundedComponent = typeof TagGroupNextComponent & { + Tag: typeof Tag; +}; + +export const TagGroupNext = TagGroupNextComponent as CompoundedComponent; + +TagGroupNext.Tag = Tag; diff --git a/packages/components/src/components/TagGroupNext/components/TagItem/TagItem.module.css b/packages/components/src/components/TagGroupNext/components/TagItem/TagItem.module.css new file mode 100644 index 000000000..57f78688f --- /dev/null +++ b/packages/components/src/components/TagGroupNext/components/TagItem/TagItem.module.css @@ -0,0 +1,137 @@ +@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(--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(--tag-bg-color); + outline-offset: calc(-1 * var(--tag-outline-width) / 2); + outline: var(--tag-outline-width) solid var(--tag-outline-color); + transition: + outline-color 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(--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); +} + +.theme-fade:where(.selected), +.contrast-fade:where(.selected) { + --tag-icon-color: var(--kbq-foreground-white); + --tag-bg-color: var(--kbq-background-theme); + --tag-color: var(--kbq-foreground-white); +} + +.theme-fade:where(.selected.hovered), +.contrast-fade:where(.selected.hovered) { + --tag-bg-color: var(--kbq-states-background-theme-hover); +} + +.error-fade:where(.selected) { + --tag-icon-color: var(--kbq-foreground-white); + --tag-bg-color: var(--kbq-background-error); + --tag-color: var(--kbq-foreground-white); +} + +.error-fade:where(.selected.hovered) { + --tag-bg-color: var(--kbq-states-background-error-hover); +} + +.warning-fade:where(.selected) { + --tag-icon-color: var(--kbq-foreground-contrast); + --tag-bg-color: var(--kbq-background-warning); + --tag-color: var(--kbq-foreground-contrast); +} + +/* focus-visible */ +.focusVisible { + --tag-outline-color: var(--kbq-states-line-focus-theme); +} + +/* 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/TagGroupNext/components/TagItem/TagItem.tsx b/packages/components/src/components/TagGroupNext/components/TagItem/TagItem.tsx new file mode 100644 index 000000000..ec31933c7 --- /dev/null +++ b/packages/components/src/components/TagGroupNext/components/TagItem/TagItem.tsx @@ -0,0 +1,117 @@ +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 { utilClasses } from '../../../../styles/utility'; +import { IconButton } from '../../../IconButton'; +import type { IconButtonProps } from '../../../IconButton'; +import { useTagItem } from '../../hooks'; +import type { TagGroupNextPropVariant } from '../../types'; + +import s from './TagItem.module.css'; +import { getTagGroupNextItemProps, matchVariantToIconButton } from './utils'; + +type TagItemProps = { + state: ListState; + collectionId?: string; + item: CollectionNode; + variant: TagGroupNextPropVariant; + onRemove?: (keys: Set) => void; +}; + +const textNormalMedium = utilClasses.typography['text-normal-medium']; + +export function TagItem(props: TagItemProps) { + const { collectionId, item, onRemove, state, variant: groupVariant } = props; + const itemProps = getTagGroupNextItemProps(item); + const variant = groupVariant; + + const { + tagProps, + isPressed, + isSelected, + isDisabled, + gridCellProps, + allowsRemoving, + removeButtonProps: removeButtonPropsAria, + } = useTagItem({ collectionId, item, onRemove, state }); + + const { focusProps, isFocusVisible, isFocused } = useFocusRing({ + within: false, + }); + const { hoverProps, isHovered } = useHover({ isDisabled }); + + const { + icon, + style, + className, + slotProps, + 'data-testid': testId, + } = itemProps; + + const rootProps = mergeProps( + tagProps, + 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/TagGroupNext/components/TagItem/index.ts b/packages/components/src/components/TagGroupNext/components/TagItem/index.ts new file mode 100644 index 000000000..afb0315aa --- /dev/null +++ b/packages/components/src/components/TagGroupNext/components/TagItem/index.ts @@ -0,0 +1 @@ +export * from './TagItem'; diff --git a/packages/components/src/components/TagGroupNext/components/TagItem/utils.ts b/packages/components/src/components/TagGroupNext/components/TagItem/utils.ts new file mode 100644 index 000000000..263c1319c --- /dev/null +++ b/packages/components/src/components/TagGroupNext/components/TagItem/utils.ts @@ -0,0 +1,19 @@ +import type { Node } from '@koobiq/react-core'; + +import type { IconButtonPropVariant } from '../../../IconButton'; +import type { ItemProps } from '../../Tag'; +import type { TagGroupNextPropVariant } from '../../types'; + +export function getTagGroupNextItemProps(node: Node) { + return node.props as ItemProps; +} + +export const matchVariantToIconButton: Record< + TagGroupNextPropVariant, + IconButtonPropVariant +> = { + 'theme-fade': 'theme', + 'contrast-fade': 'fade-contrast', + 'error-fade': 'error', + 'warning-fade': 'warning', +}; diff --git a/packages/components/src/components/TagGroupNext/components/index.ts b/packages/components/src/components/TagGroupNext/components/index.ts new file mode 100644 index 000000000..afb0315aa --- /dev/null +++ b/packages/components/src/components/TagGroupNext/components/index.ts @@ -0,0 +1 @@ +export * from './TagItem'; diff --git a/packages/components/src/components/TagGroupNext/hooks/index.ts b/packages/components/src/components/TagGroupNext/hooks/index.ts new file mode 100644 index 000000000..c50cd790d --- /dev/null +++ b/packages/components/src/components/TagGroupNext/hooks/index.ts @@ -0,0 +1 @@ +export * from './useTagItem'; diff --git a/packages/components/src/components/TagGroupNext/hooks/useTagItem.ts b/packages/components/src/components/TagGroupNext/hooks/useTagItem.ts new file mode 100644 index 000000000..0e676acd1 --- /dev/null +++ b/packages/components/src/components/TagGroupNext/hooks/useTagItem.ts @@ -0,0 +1,211 @@ +import type { FocusEvent } from 'react'; +import { useEffect, useRef } from 'react'; + +import { + useId, + usePress, + useKeyboard, + mergeProps, + isFocusable, + filterDOMProps, +} from '@koobiq/react-core'; +import type { Key, Node, PressEvent, DOMAttributes } from '@koobiq/react-core'; +import type { ListState } from '@koobiq/react-primitives'; + +import { getTagGroupNextItemProps } from '../components/TagItem/utils'; + +export type UseTagItemProps = { + state: ListState; + collectionId?: string; + item: Node; + onRemove?: (keys: Set) => void; +}; + +export function isCommandModifier(event: { + ctrlKey: boolean; + metaKey: boolean; +}) { + return event.ctrlKey || event.metaKey; +} + +// Nested focusable controls handle their own interactions. +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; +} + +function isSpaceKey(key: string) { + return key === ' ' || key === 'Space' || key === 'Spacebar'; +} + +export function useTagItem(props: UseTagItemProps) { + const { collectionId, item, onRemove, state } = props; + + const ref = useRef(null); + const rowId = useId(); + const removeButtonId = useId(); + + const itemProps = getTagGroupNextItemProps(item); + + const { selectionManager } = state; + + const isSelected = selectionManager.isSelected(item.key); + const isDisabled = + selectionManager.isDisabled(item.key) || itemProps.isDisabled; + + const allowsRemoving = !!onRemove && !isDisabled; + + const allowsSelection = + !isDisabled && selectionManager.canSelectItem(item.key); + + // Move DOM focus to this tag when it becomes the focused item. + useEffect(() => { + if ( + !isDisabled && + selectionManager.isFocused && + selectionManager.focusedKey === item.key && + document.activeElement !== ref.current + ) { + ref.current?.focus(); + } + }, [isDisabled, item.key, selectionManager]); + + const focusTag = () => { + if (isDisabled) return; + + selectionManager.setFocused(true); + selectionManager.setFocusedKey(item.key); + ref.current?.focus(); + }; + + const toggleSelection = () => { + if (allowsSelection) selectionManager.toggleSelection(item.key); + }; + + const getKeysToRemove = () => { + if (!isSelected) { + return new Set([item.key]); + } + + const selectedKeys = new Set( + [...selectionManager.selectedKeys].filter((key) => + Boolean(state.collection.getItem(key)) + ) + ); + + return selectedKeys.size ? selectedKeys : new Set([item.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()); + + return; + } + + event.continuePropagation(); + }, + }); + + const handleFocus = (event: FocusEvent) => { + if (event.target !== event.currentTarget || isDisabled) return; + + selectionManager.setFocused(true); + selectionManager.setFocusedKey(item.key); + }; + + const tagProps = mergeProps( + filterDOMProps(item.props, { global: true }), + { + ref, + id: rowId, + role: 'row', + tabIndex: + selectionManager.focusedKey === item.key && !isDisabled ? 0 : -1, + 'aria-disabled': isDisabled || undefined, + 'aria-label': item['aria-label'] || item.textValue || undefined, + 'aria-selected': allowsSelection ? isSelected : undefined, + 'data-collection': collectionId, + 'data-key': item.key, + onFocus: handleFocus, + }, + pressProps, + keyboardProps + ); + + const gridCellProps: DOMAttributes = { + role: 'gridcell', + 'aria-colindex': 1, + }; + + const removeButtonProps = { + isDisabled, + tabIndex: -1, + id: removeButtonId, + 'aria-label': 'Remove', + 'aria-labelledby': `${removeButtonId} ${rowId}`, + onPress: () => onRemove?.(new Set([item.key])), + }; + + return { + tagProps, + isPressed, + isSelected, + isDisabled, + gridCellProps, + allowsRemoving, + removeButtonProps, + }; +} diff --git a/packages/components/src/components/TagGroupNext/index.ts b/packages/components/src/components/TagGroupNext/index.ts new file mode 100644 index 000000000..bfcb6eb56 --- /dev/null +++ b/packages/components/src/components/TagGroupNext/index.ts @@ -0,0 +1,2 @@ +export * from './TagGroupNext'; +export * from './types'; diff --git a/packages/components/src/components/TagGroupNext/types.ts b/packages/components/src/components/TagGroupNext/types.ts new file mode 100644 index 000000000..4cb2d8025 --- /dev/null +++ b/packages/components/src/components/TagGroupNext/types.ts @@ -0,0 +1,77 @@ +import type { + Ref, + ReactElement, + ComponentRef, + CSSProperties, + ComponentPropsWithRef, +} from 'react'; + +import type { + Key, + CollectionBase, + ExtendableProps, + MultipleSelection, +} from '@koobiq/react-core'; + +export const tagGroupNextPropVariant = [ + 'theme-fade', + 'contrast-fade', + 'error-fade', + 'warning-fade', +] as const; + +export type TagGroupNextPropVariant = (typeof tagGroupNextPropVariant)[number]; + +type TagGroupNextDOMProps = Omit< + ComponentPropsWithRef<'div'>, + 'children' | 'defaultValue' | 'onChange' | 'onSelect' | 'ref' +>; + +type TagGroupNextCollectionProps = CollectionBase & + Omit; + +type TagGroupNextKeyboardProps = { + /** + * Whether pressing the Escape key should clear selection. + * @default 'clearSelection' + */ + escapeKeyBehavior?: 'clearSelection' | 'none'; +}; + +type TagGroupNextOwnProps = { + /** + * The variant to use. + * @default 'theme-fade' + */ + variant?: TagGroupNextPropVariant; + /** 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; + /** The props used for each slot inside. */ + slotProps?: { + root?: ComponentPropsWithRef<'div'>; + }; +}; + +type TagGroupNextInheritedProps = + TagGroupNextCollectionProps & + TagGroupNextKeyboardProps & + TagGroupNextDOMProps; + +export type TagGroupNextProps = ExtendableProps< + TagGroupNextOwnProps, + TagGroupNextInheritedProps +>; + +export type TagGroupNextComponent = ( + props: TagGroupNextProps +) => ReactElement | null; + +export type TagGroupNextRef = ComponentRef<'div'>; diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index 58af13bd2..e8a1eddff 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -33,6 +33,7 @@ export * from './Divider'; export * from './Menu'; export * from './ButtonToggleGroup'; export * from './TagGroup'; +export * from './TagGroupNext'; export * from './Table'; export * from './Calendar'; export * from './DateInput'; From 3192a08a061cbe24d5c4ddccf44ce0ad25af9458 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 20 May 2026 14:38:34 +0300 Subject: [PATCH 02/54] fix(TagList): update Tag variants and states --- .../TagGroupNext/TagGroupNext.module.css | 1 + .../TagGroupNext/TagGroupNext.stories.tsx | 2 +- .../components/TagItem/TagItem.module.css | 80 ++++++++++++++++--- 3 files changed, 72 insertions(+), 11 deletions(-) diff --git a/packages/components/src/components/TagGroupNext/TagGroupNext.module.css b/packages/components/src/components/TagGroupNext/TagGroupNext.module.css index 4e3b0717a..af2884f5b 100644 --- a/packages/components/src/components/TagGroupNext/TagGroupNext.module.css +++ b/packages/components/src/components/TagGroupNext/TagGroupNext.module.css @@ -2,6 +2,7 @@ flex-wrap: wrap; gap: var(--kbq-size-xxs); display: flex; + outline: none; } .base [role='gridcell'] { diff --git a/packages/components/src/components/TagGroupNext/TagGroupNext.stories.tsx b/packages/components/src/components/TagGroupNext/TagGroupNext.stories.tsx index 0c9fc7d50..ae71e4644 100644 --- a/packages/components/src/components/TagGroupNext/TagGroupNext.stories.tsx +++ b/packages/components/src/components/TagGroupNext/TagGroupNext.stories.tsx @@ -111,13 +111,13 @@ export const RemoveTags: Story = { export const WithInput: Story = { render: function Render() { + const [value, setValue] = useState(''); const tagGroupRef = useRef(null); const nextIdRef = useRef(1); const list = useListData({ initialItems: editableInitialItems, }); - const [value, setValue] = useState(''); const focusLastTag = () => { const tags = diff --git a/packages/components/src/components/TagGroupNext/components/TagItem/TagItem.module.css b/packages/components/src/components/TagGroupNext/components/TagItem/TagItem.module.css index 57f78688f..41d471bdf 100644 --- a/packages/components/src/components/TagGroupNext/components/TagItem/TagItem.module.css +++ b/packages/components/src/components/TagGroupNext/components/TagItem/TagItem.module.css @@ -25,6 +25,7 @@ outline: var(--tag-outline-width) solid 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); } @@ -93,39 +94,98 @@ --tag-bg-color: var(--kbq-states-background-warning-fade-hover); } -.theme-fade:where(.selected), -.contrast-fade:where(.selected) { - --tag-icon-color: var(--kbq-foreground-white); +/* 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); + } } -.theme-fade:where(.selected.hovered), -.contrast-fade:where(.selected.hovered) { - --tag-bg-color: var(--kbq-states-background-theme-hover); +.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-icon-color: var(--kbq-foreground-white); --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) { - --tag-icon-color: var(--kbq-foreground-contrast); +.warning-fade:where(.selected.hovered) { --tag-bg-color: var(--kbq-background-warning); --tag-color: var(--kbq-foreground-contrast); } /* focus-visible */ -.focusVisible { +.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: ; From b6fa1865698cd813c2d8772059ef81cabcdfb913 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 20 May 2026 15:21:12 +0300 Subject: [PATCH 03/54] feat(TagGroupNext): localize remove button label and announce shortcut to screen readers --- .../components/TagGroupNext/TagGroupNext.mdx | 10 +++- .../TagGroupNext/TagGroupNext.test.tsx | 50 +++++++++++++++++++ .../TagGroupNext/hooks/useTagItem.ts | 31 +++++++++++- .../src/components/TagGroupNext/intl.json | 10 ++++ packages/core/src/index.ts | 1 + 5 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 packages/components/src/components/TagGroupNext/intl.json diff --git a/packages/components/src/components/TagGroupNext/TagGroupNext.mdx b/packages/components/src/components/TagGroupNext/TagGroupNext.mdx index 985424891..1a0e0074b 100644 --- a/packages/components/src/components/TagGroupNext/TagGroupNext.mdx +++ b/packages/components/src/components/TagGroupNext/TagGroupNext.mdx @@ -54,8 +54,14 @@ 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 the backspace key while the tag -is focused to remove the tag from the group. +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. diff --git a/packages/components/src/components/TagGroupNext/TagGroupNext.test.tsx b/packages/components/src/components/TagGroupNext/TagGroupNext.test.tsx index 9c5cfba0b..976de1381 100644 --- a/packages/components/src/components/TagGroupNext/TagGroupNext.test.tsx +++ b/packages/components/src/components/TagGroupNext/TagGroupNext.test.tsx @@ -4,6 +4,8 @@ import { render, screen, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; +import { Provider } from '../Provider'; + import { isInteractiveTarget } from './hooks/useTagItem'; import { TagGroupNext, type TagGroupNextProps } from './index'; @@ -259,4 +261,52 @@ describe('TagGroupNext', () => { 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); + }); + }); }); diff --git a/packages/components/src/components/TagGroupNext/hooks/useTagItem.ts b/packages/components/src/components/TagGroupNext/hooks/useTagItem.ts index 0e676acd1..cc39b8af3 100644 --- a/packages/components/src/components/TagGroupNext/hooks/useTagItem.ts +++ b/packages/components/src/components/TagGroupNext/hooks/useTagItem.ts @@ -8,11 +8,15 @@ import { mergeProps, isFocusable, filterDOMProps, + useDescription, + useInteractionModality, + useLocalizedStringFormatter, } from '@koobiq/react-core'; import type { Key, Node, PressEvent, DOMAttributes } from '@koobiq/react-core'; import type { ListState } from '@koobiq/react-primitives'; import { getTagGroupNextItemProps } from '../components/TagItem/utils'; +import intlMessages from '../intl.json'; export type UseTagItemProps = { state: ListState; @@ -65,6 +69,30 @@ export function useTagItem(props: UseTagItemProps) { const allowsSelection = !isDisabled && selectionManager.canSelectItem(item.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 && (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 ( @@ -177,6 +205,7 @@ export function useTagItem(props: UseTagItemProps) { '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': item.key, onFocus: handleFocus, @@ -194,7 +223,7 @@ export function useTagItem(props: UseTagItemProps) { isDisabled, tabIndex: -1, id: removeButtonId, - 'aria-label': 'Remove', + 'aria-label': stringFormatter.format('removeButtonLabel'), 'aria-labelledby': `${removeButtonId} ${rowId}`, onPress: () => onRemove?.(new Set([item.key])), }; diff --git a/packages/components/src/components/TagGroupNext/intl.json b/packages/components/src/components/TagGroupNext/intl.json new file mode 100644 index 000000000..bf660ea45 --- /dev/null +++ b/packages/components/src/components/TagGroupNext/intl.json @@ -0,0 +1,10 @@ +{ + "en-US": { + "removeButtonLabel": "Remove", + "removeDescription": "Press Delete or Backspace to remove." + }, + "ru-RU": { + "removeButtonLabel": "Удалить", + "removeDescription": "Нажмите Delete или Backspace, чтобы удалить." + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 971d561b2..0d9ef5a8f 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 { From 951dd8fa15d57362209d4be0b66bacda29ecf08f Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 20 May 2026 15:34:27 +0300 Subject: [PATCH 04/54] feat(TagGroupNext): expose public --kbq-tag-* CSS variables --- .../src/components/TagGroupNext/TagGroupNext.mdx | 12 ++++++++++++ .../components/TagItem/TagItem.module.css | 13 ++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/components/src/components/TagGroupNext/TagGroupNext.mdx b/packages/components/src/components/TagGroupNext/TagGroupNext.mdx index 1a0e0074b..2f6e73535 100644 --- a/packages/components/src/components/TagGroupNext/TagGroupNext.mdx +++ b/packages/components/src/components/TagGroupNext/TagGroupNext.mdx @@ -79,3 +79,15 @@ Each key in this group corresponds with the `key` prop passed to the `TagGroupNe or automatically derived from the values passed to the `items` prop. + +## 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/TagGroupNext/components/TagItem/TagItem.module.css b/packages/components/src/components/TagGroupNext/components/TagItem/TagItem.module.css index 41d471bdf..c7c0fabc2 100644 --- a/packages/components/src/components/TagGroupNext/components/TagItem/TagItem.module.css +++ b/packages/components/src/components/TagGroupNext/components/TagItem/TagItem.module.css @@ -15,14 +15,17 @@ display: inline-flex; text-decoration: none; box-sizing: border-box; - color: var(--tag-color); + 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(--tag-bg-color); - outline-offset: calc(-1 * var(--tag-outline-width) / 2); - outline: var(--tag-outline-width) solid var(--tag-outline-color); + 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), @@ -42,7 +45,7 @@ flex: none; align-items: center; justify-content: center; - color: var(--tag-icon-color); + color: var(--kbq-tag-icon-color, var(--tag-icon-color)); margin-inline-start: var(--kbq-size-3xs); } From 223f40f8008e3cc7433ee9390d983eaa2563c523 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 20 May 2026 16:02:18 +0300 Subject: [PATCH 05/54] refactor(TagGroupNext): replace hand-rolled handleBlur with useFocusWithin --- .../components/TagGroupNext/TagGroupNext.tsx | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/components/src/components/TagGroupNext/TagGroupNext.tsx b/packages/components/src/components/TagGroupNext/TagGroupNext.tsx index 37bb617ee..c8d3b9c81 100644 --- a/packages/components/src/components/TagGroupNext/TagGroupNext.tsx +++ b/packages/components/src/components/TagGroupNext/TagGroupNext.tsx @@ -1,9 +1,15 @@ 'use client'; import { forwardRef, useMemo } from 'react'; -import type { FocusEvent, RefObject, Ref } from 'react'; +import type { RefObject, Ref } from 'react'; -import { clsx, mergeProps, useDOMRef, useLocale } from '@koobiq/react-core'; +import { + clsx, + mergeProps, + useDOMRef, + useFocusWithin, + useLocale, +} from '@koobiq/react-core'; import { ListKeyboardDelegate, useListState, @@ -60,18 +66,12 @@ function TagGroupNextRender( ref: domRef as RefObject, }); - const handleBlur = (event: FocusEvent) => { - const nextFocusedElement = event.relatedTarget; - - if ( - nextFocusedElement instanceof Node && - event.currentTarget.contains(nextFocusedElement) - ) { - return; - } - - state.selectionManager.clearSelection(); - }; + // Drop selection when focus leaves the entire group. `useFocusWithin` + // correctly handles Shadow DOM / portal cases that a hand-rolled + // `onBlur` + `relatedTarget` check wouldn't. + const { focusWithinProps } = useFocusWithin({ + onBlurWithin: () => state.selectionManager.clearSelection(), + }); const collectionId = (listProps as Record)[ 'data-collection' @@ -81,11 +81,11 @@ function TagGroupNextRender( { style, ref: domRef, - onBlur: handleBlur, className: clsx(groupStyles.base, className), role: state.collection.size ? 'grid' : 'group', }, listProps, + focusWithinProps, slotProps?.root ); From 07fc53174a3d0de71b2ae18d957836432f19fb13 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 20 May 2026 16:12:58 +0300 Subject: [PATCH 06/54] refactor(TagGroupNext): drop unimplemented link/section props from Tag type --- .../src/components/TagGroupNext/Tag.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/components/src/components/TagGroupNext/Tag.tsx b/packages/components/src/components/TagGroupNext/Tag.tsx index c3fb41895..948e11860 100644 --- a/packages/components/src/components/TagGroupNext/Tag.tsx +++ b/packages/components/src/components/TagGroupNext/Tag.tsx @@ -25,7 +25,22 @@ export type TagSlotProps = { removeIcon?: IconButtonProps; }; -export type ItemProps = Omit, 'children'> & { +type AriaTagItemProps = Omit< + AriaItemProps, + | 'children' + | 'href' + | 'hrefLang' + | 'target' + | 'rel' + | 'download' + | 'ping' + | 'referrerPolicy' + | 'title' + | 'childItems' + | 'hasChildItems' +>; + +export type ItemProps = AriaTagItemProps & { /** Additional CSS-classes. */ className?: string; /** Inline styles. */ @@ -38,7 +53,7 @@ export type ItemProps = Omit, 'children'> & { isDisabled?: boolean; /** The props used for each slot inside. */ slotProps?: TagSlotProps; - /** Rendered contents of the item or child items. */ + /** Rendered contents of the tag. */ children?: ReactNode; }; From 0608ec347e7fb2e1d3cdad21f26dd648c4e7604f Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 21 May 2026 20:35:01 +0300 Subject: [PATCH 07/54] feat(TagList): improve structure, tests and documentation, rename the component --- .../components/TagGroupNext/TagGroupNext.mdx | 93 ------- .../TagGroupNext/TagGroupNext.stories.tsx | 219 ---------------- .../components/TagGroupNext/hooks/index.ts | 1 - .../src/components/TagGroupNext/index.ts | 2 - .../components/TagInput/TagInput.stories.tsx | 34 +++ .../src/components/TagInput/TagInput.tsx | 169 +++++++++++++ .../src/components/TagInput/index.ts | 4 + .../{TagGroupNext => TagList}/Tag.tsx | 6 +- .../src/components/TagList/TagList.mdx | 123 +++++++++ .../TagList.module.css} | 0 .../components/TagList/TagList.stories.tsx | 235 ++++++++++++++++++ .../TagList.test.tsx} | 175 +++++++++++-- .../src/components/TagList/TagList.tsx | 59 +++++ .../TagListInner.tsx} | 88 ++++--- .../components/TagItem/TagItem.module.css | 0 .../components/TagItem/TagItem.tsx | 12 +- .../components/TagItem/index.ts | 0 .../components/TagItem/utils.ts | 10 +- .../components/index.ts | 0 .../src/components/TagList/hooks/index.ts | 1 + .../hooks/useTagListItem.ts} | 10 +- .../src/components/TagList/index.ts | 2 + .../{TagGroupNext => TagList}/intl.json | 0 .../{TagGroupNext => TagList}/types.ts | 41 +-- packages/components/src/components/index.ts | 2 +- packages/core/src/index.ts | 1 + 26 files changed, 879 insertions(+), 408 deletions(-) delete mode 100644 packages/components/src/components/TagGroupNext/TagGroupNext.mdx delete mode 100644 packages/components/src/components/TagGroupNext/TagGroupNext.stories.tsx delete mode 100644 packages/components/src/components/TagGroupNext/hooks/index.ts delete mode 100644 packages/components/src/components/TagGroupNext/index.ts create mode 100644 packages/components/src/components/TagInput/TagInput.stories.tsx create mode 100644 packages/components/src/components/TagInput/TagInput.tsx create mode 100644 packages/components/src/components/TagInput/index.ts rename packages/components/src/components/{TagGroupNext => TagList}/Tag.tsx (91%) create mode 100644 packages/components/src/components/TagList/TagList.mdx rename packages/components/src/components/{TagGroupNext/TagGroupNext.module.css => TagList/TagList.module.css} (100%) create mode 100644 packages/components/src/components/TagList/TagList.stories.tsx rename packages/components/src/components/{TagGroupNext/TagGroupNext.test.tsx => TagList/TagList.test.tsx} (60%) create mode 100644 packages/components/src/components/TagList/TagList.tsx rename packages/components/src/components/{TagGroupNext/TagGroupNext.tsx => TagList/TagListInner.tsx} (53%) rename packages/components/src/components/{TagGroupNext => TagList}/components/TagItem/TagItem.module.css (100%) rename packages/components/src/components/{TagGroupNext => TagList}/components/TagItem/TagItem.tsx (89%) rename packages/components/src/components/{TagGroupNext => TagList}/components/TagItem/index.ts (100%) rename packages/components/src/components/{TagGroupNext => TagList}/components/TagItem/utils.ts (56%) rename packages/components/src/components/{TagGroupNext => TagList}/components/index.ts (100%) create mode 100644 packages/components/src/components/TagList/hooks/index.ts rename packages/components/src/components/{TagGroupNext/hooks/useTagItem.ts => TagList/hooks/useTagListItem.ts} (95%) create mode 100644 packages/components/src/components/TagList/index.ts rename packages/components/src/components/{TagGroupNext => TagList}/intl.json (100%) rename packages/components/src/components/{TagGroupNext => TagList}/types.ts (54%) diff --git a/packages/components/src/components/TagGroupNext/TagGroupNext.mdx b/packages/components/src/components/TagGroupNext/TagGroupNext.mdx deleted file mode 100644 index 2f6e73535..000000000 --- a/packages/components/src/components/TagGroupNext/TagGroupNext.mdx +++ /dev/null @@ -1,93 +0,0 @@ -import { - Props, - Story, - Meta, - Status, -} from '../../../../../.storybook/components'; - -import * as Stories from './TagGroupNext.stories'; - - - -# TagGroupNext - - - -A tag group is a focusable list of labels, categories, keywords, filters, -or other items, with support for keyboard navigation and removal. - -## Import - -```tsx -import { TagGroupNext } from '@koobiq/react-components'; -``` - -## Usage - - - -## Props - - - -## Selection - -TagGroupNext keeps pointer selection explicit: a regular click only focuses a tag. -To select tags, use `Ctrl`/`Cmd` + click, `Ctrl+A`, or press `Space` while a tag is focused. - - - -## Variant - -To change the visual state of tags, use the `variant` prop. - - - -## 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. - - - -## Input composition - -TagGroupNext can be composed with an input placed next to the tags. -The input can add tags on `Enter`, and move focus back to the tag group with `ArrowLeft`. - - - -## Disabled tags - -TagGroupNext supports marking items as disabled using the `disabledKeys` prop. -Each key in this group corresponds with the `key` prop passed to the `TagGroupNext.Tag` component, -or automatically derived from the values passed to the `items` prop. - - - -## 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/TagGroupNext/TagGroupNext.stories.tsx b/packages/components/src/components/TagGroupNext/TagGroupNext.stories.tsx deleted file mode 100644 index ae71e4644..000000000 --- a/packages/components/src/components/TagGroupNext/TagGroupNext.stories.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { useRef, useState, type KeyboardEvent } from 'react'; - -import { IconGlobe16 } from '@koobiq/react-icons'; -import type { Meta, StoryObj } from '@storybook/react-vite'; - -import { FlexBox } from '../FlexBox'; -import { useListData } from '../index'; -import { Input } from '../Input'; - -import { TagGroupNext } from './TagGroupNext'; -import { tagGroupNextPropVariant } from './types'; - -const meta = { - title: 'Components/TagGroupNext', - component: TagGroupNext, - subcomponents: { Tag: TagGroupNext.Tag }, - parameters: { - layout: 'centered', - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -type EditableTagItem = { - id: string; - name: string; -}; - -const editableInitialItems: EditableTagItem[] = [ - { id: 'react', name: 'React' }, - { id: 'typescript', name: 'Typescript' }, - { id: 'storybook', name: 'Storybook' }, -]; - -export const Base: Story = { - render: (args) => ( - - React - Typescript - Storybook - Tailwind - - ), -}; - -export const Variant: Story = { - render: (args) => ( - - {tagGroupNextPropVariant.map((variant) => ( - undefined} - aria-label="Libraries" - {...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 ( - - aria-label="Libraries" - selectionMode="multiple" - items={list.items} - disabledKeys={[4]} - onRemove={(keys) => { - args.onRemove?.(keys); - list.remove(...keys); - }} - > - {(item) => {item.name}} - - ); - }, -}; - -export const WithInput: Story = { - render: function Render() { - const [value, setValue] = useState(''); - const tagGroupRef = useRef(null); - const nextIdRef = useRef(1); - - const list = useListData({ - initialItems: editableInitialItems, - }); - - const focusLastTag = () => { - const tags = - tagGroupRef.current?.querySelectorAll('[role="row"]'); - - tags?.[tags.length - 1]?.focus(); - }; - - const handleInputKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Enter') { - const name = value.trim(); - - if (!name) return; - - event.preventDefault(); - const id = `custom-${nextIdRef.current}`; - nextIdRef.current += 1; - - list.append({ id, name }); - setValue(''); - - return; - } - - if ( - event.key === 'ArrowLeft' && - event.currentTarget.selectionStart === 0 && - event.currentTarget.selectionEnd === 0 - ) { - event.preventDefault(); - focusLastTag(); - } - }; - - return ( - - - - ref={tagGroupRef} - items={list.items} - selectionMode="multiple" - aria-label="Selected libraries" - 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 - - - ), -}; diff --git a/packages/components/src/components/TagGroupNext/hooks/index.ts b/packages/components/src/components/TagGroupNext/hooks/index.ts deleted file mode 100644 index c50cd790d..000000000 --- a/packages/components/src/components/TagGroupNext/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './useTagItem'; diff --git a/packages/components/src/components/TagGroupNext/index.ts b/packages/components/src/components/TagGroupNext/index.ts deleted file mode 100644 index bfcb6eb56..000000000 --- a/packages/components/src/components/TagGroupNext/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './TagGroupNext'; -export * from './types'; 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 000000000..3e759f2a6 --- /dev/null +++ b/packages/components/src/components/TagInput/TagInput.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { FlexBox } from '../FlexBox'; + +import { TagInput } from './TagInput'; + +// DRAFT — TagInput is still a work-in-progress composer over +// `TagListInner`. This story exists so the in-progress UX can be +// previewed in the dev sandbox. +const meta = { + title: 'Components/TagInput (draft)', + component: TagInput, + parameters: { + layout: 'centered', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Base: Story = { + render: function Render(args) { + return ( + + + + ); + }, +}; diff --git a/packages/components/src/components/TagInput/TagInput.tsx b/packages/components/src/components/TagInput/TagInput.tsx new file mode 100644 index 000000000..da68244a5 --- /dev/null +++ b/packages/components/src/components/TagInput/TagInput.tsx @@ -0,0 +1,169 @@ +'use client'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +// +// DRAFT — work in progress. +// +// Not exported from `@koobiq/react-components`. Lives here as the seed for +// a future stable `TagInput` component. The goal of this file is to +// demonstrate how the `TagListInner` render-layer is composed with an +// `` via lifted `useListState`: +// +// - the parent owns the items collection (`useListData` here, but a +// consumer can pass plain `useState` or a controlled value) +// - the parent creates the React-Aria `state` via `useListState`, so +// `state.selectionManager.setFocusedKey(...)` is available for +// programmatic focus moves (Backspace from empty input → focus last +// tag) without DOM queries +// - tag removal flows through `onRemove`; tag creation through input +// keydown handlers (Enter / `,` / `;`) and (in a future iteration) +// paste with split +// +// API and UX details (controlled vs uncontrolled value, paste splitting, +// distinct, blur-commit, validation hooks) will be finalised when this +// graduates to a real component with stories, tests, and MDX. +// + +import { useRef, useState, type KeyboardEvent } from 'react'; + +import type { Key } from '@koobiq/react-core'; +import { useListData, useListState } from '@koobiq/react-primitives'; + +import { Input } from '../Input'; +import { Tag } from '../TagList/Tag'; +import { TagListInner } from '../TagList/TagListInner'; + +export interface TagInputProps { + /** Initial tags (uncontrolled mode). */ + defaultValue?: string[]; + /** Notified whenever the set of committed tags changes. */ + onChange?: (next: string[]) => void; + /** + * Characters (besides Enter) that commit the current input value as a + * new tag. + * @default /[,;]/ + */ + splitPattern?: RegExp; + /** Placeholder for the text input. */ + placeholder?: string; + /** Accessibility label, applied to both the input and the tag list. */ + 'aria-label'?: string; + /** Whether the whole control is disabled. */ + isDisabled?: boolean; + /** Whether the control is read-only (tags shown, can't add or remove). */ + isReadOnly?: boolean; +} + +interface TagInputItem { + id: string; + value: string; +} + +const makeItem = (value: string): TagInputItem => ({ id: value, value }); + +export function TagInput(props: TagInputProps) { + const { + defaultValue = [], + onChange, + splitPattern = /[,;]/, + placeholder, + 'aria-label': ariaLabel, + isDisabled, + isReadOnly, + } = props; + + const [inputValue, setInputValue] = useState(''); + const inputRef = useRef(null); + + const list = useListData({ + initialItems: defaultValue.map(makeItem), + getKey: (item) => item.id, + }); + + // Lifted React-Aria state. The parent (this component) gets direct + // access to `state.selectionManager`, so we can drive focus from outside + // the tag list without DOM queries. + const state = useListState({ + items: list.items, + children: (item) => {item.value}, + selectionMode: 'multiple', + }); + + const commit = () => onChange?.(list.items.map((i) => i.value)); + + const addTags = (raw: string) => { + if (isDisabled || isReadOnly) return; + const existing = new Set(list.items.map((i) => i.value)); + const candidates = raw + .split(splitPattern) + .map((s) => s.trim()) + .filter((s) => s && !existing.has(s)); + if (candidates.length === 0) { + setInputValue(''); + return; + } + candidates.forEach((value) => list.append(makeItem(value))); + setInputValue(''); + commit(); + }; + + const removeKeys = (keys: Set) => { + if (isDisabled || isReadOnly) return; + list.remove(...keys); + commit(); + }; + + const focusLastTag = () => { + const lastKey = state.collection.getLastKey(); + if (lastKey == null) return; + state.selectionManager.setFocused(true); + state.selectionManager.setFocusedKey(lastKey); + }; + + const handleInputKeyDown = (event: KeyboardEvent) => { + if (isDisabled || isReadOnly) return; + + // Enter / configured separator characters commit the current input. + if (event.key === 'Enter' || splitPattern.test(event.key)) { + if (inputValue.trim()) { + event.preventDefault(); + addTags(inputValue); + } + return; + } + + // Backspace / ArrowLeft at the very start of an empty input moves focus + // to the last tag — typed-safe via lifted state, no querySelector hack. + const { selectionStart, selectionEnd } = event.currentTarget; + if ( + (event.key === 'Backspace' || event.key === 'ArrowLeft') && + selectionStart === 0 && + selectionEnd === 0 && + !inputValue + ) { + event.preventDefault(); + focusLastTag(); + } + }; + + return ( + <> + + + state={state} + onRemove={isReadOnly ? undefined : removeKeys} + aria-label={ariaLabel ?? 'Selected tags'} + /> + + ); +} diff --git a/packages/components/src/components/TagInput/index.ts b/packages/components/src/components/TagInput/index.ts new file mode 100644 index 000000000..3dcddd096 --- /dev/null +++ b/packages/components/src/components/TagInput/index.ts @@ -0,0 +1,4 @@ +// Intentionally empty — `TagInput` is still a draft and is NOT exported from +// `@koobiq/react-components`. See `TagInput.tsx` for the work-in-progress +// implementation. +export {}; diff --git a/packages/components/src/components/TagGroupNext/Tag.tsx b/packages/components/src/components/TagList/Tag.tsx similarity index 91% rename from packages/components/src/components/TagGroupNext/Tag.tsx rename to packages/components/src/components/TagList/Tag.tsx index 948e11860..139c7e52e 100644 --- a/packages/components/src/components/TagGroupNext/Tag.tsx +++ b/packages/components/src/components/TagList/Tag.tsx @@ -18,7 +18,7 @@ type ItemComponent = FC> & { const TagInner = AriaItem as ItemComponent; -export type TagSlotProps = { +type TagSlotProps = { root?: ComponentPropsWithRef<'div'>; icon?: ComponentPropsWithRef<'span'>; content?: ComponentPropsWithRef<'span'>; @@ -40,7 +40,7 @@ type AriaTagItemProps = Omit< | 'hasChildItems' >; -export type ItemProps = AriaTagItemProps & { +export type TagProps = AriaTagItemProps & { /** Additional CSS-classes. */ className?: string; /** Inline styles. */ @@ -58,7 +58,7 @@ export type ItemProps = AriaTagItemProps & { }; // eslint-disable-next-line @typescript-eslint/no-unused-vars -export function Tag(_props: ItemProps) { +export function Tag(_props: TagProps) { return null; } diff --git a/packages/components/src/components/TagList/TagList.mdx b/packages/components/src/components/TagList/TagList.mdx new file mode 100644 index 000000000..8e485dfc2 --- /dev/null +++ b/packages/components/src/components/TagList/TagList.mdx @@ -0,0 +1,123 @@ +import { + Props, + Story, + Meta, + Status, +} from '../../../../../.storybook/components'; + +import * as Stories from './TagList.stories'; + + + +# TagList + + + +A tag group is a focusable list of labels, categories, keywords, filters, +or other items, with support for keyboard navigation and removal. + +## 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` + click, `Ctrl+A`, or press `Space` while a tag is focused. + + + +## Controlled selection + +Pass `selectedKeys` together with `onSelectionChange` to drive selection from +the parent. `selectedKeys` accepts a `Set` or the literal `'all'`; +`onSelectionChange` is invoked with the next selection (also a `Set` or +`'all'`) whenever the user toggles a tag. Use `defaultSelectedKeys` for the +uncontrolled equivalent. + + + +## Variant + +To change the visual state of tags, use the `variant` prop. + + + +## 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` + click | Toggle selection without clearing the previous one. | +| `Ctrl` + `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/TagGroupNext/TagGroupNext.module.css b/packages/components/src/components/TagList/TagList.module.css similarity index 100% rename from packages/components/src/components/TagGroupNext/TagGroupNext.module.css rename to packages/components/src/components/TagList/TagList.module.css 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 000000000..d01532cfa --- /dev/null +++ b/packages/components/src/components/TagList/TagList.stories.tsx @@ -0,0 +1,235 @@ +import { useState } from 'react'; + +import { IconGlobe16 } from '@koobiq/react-icons'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { FlexBox } from '../FlexBox'; +import { useListData } from '../index'; + +import { TagList } from './TagList'; +import { tagListPropVariant } from './types'; + +const meta = { + title: 'Components/TagList', + component: TagList, + subcomponents: { Tag: TagList.Tag }, + parameters: { + layout: 'centered', + }, +} 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} + aria-label="Libraries" + {...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 ( + + aria-label="Libraries" + selectionMode="multiple" + items={list.items} + disabledKeys={[4]} + 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 ( + + + + + + + aria-label="Dynamic" + items={list.items} + onRemove={(keys) => list.remove(...keys)} + > + {(item) => {item.name}} + + + ); + }, +}; diff --git a/packages/components/src/components/TagGroupNext/TagGroupNext.test.tsx b/packages/components/src/components/TagList/TagList.test.tsx similarity index 60% rename from packages/components/src/components/TagGroupNext/TagGroupNext.test.tsx rename to packages/components/src/components/TagList/TagList.test.tsx index 976de1381..e3a88b17a 100644 --- a/packages/components/src/components/TagGroupNext/TagGroupNext.test.tsx +++ b/packages/components/src/components/TagList/TagList.test.tsx @@ -1,27 +1,25 @@ import { useState } from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +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 { isInteractiveTarget } from './hooks/useTagItem'; -import { TagGroupNext, type TagGroupNextProps } from './index'; +import { isInteractiveTarget } from './hooks'; +import { TagList, type TagListProps } from './index'; -const TAG_GROUP_NEXT_TEST_ID = 'TAG_GROUP_NEXT_TAG'; +const TAG_LIST_TEST_ID = 'TAG_LIST_TAG'; -const renderComponent = ( - props: Omit, 'children'> -) => ( - - one - +const renderComponent = (props: Omit, 'children'>) => ( + + one + two - - three - four - + + three + four + ); const removableItems = [ @@ -31,12 +29,12 @@ const removableItems = [ { id: '4', name: 'four' }, ]; -function RemovableTagGroupNext() { +function RemovableTagList() { const [items, setItems] = useState(removableItems); return ( - - aria-label="removable-tag-group-next" + + aria-label="removable-tag-list" selectionMode="multiple" items={items} onRemove={(keys) => { @@ -46,16 +44,16 @@ function RemovableTagGroupNext() { }} > {(item) => ( - + {item.name} - + )} - + ); } -describe('TagGroupNext', () => { - const getTag = () => screen.getByTestId(TAG_GROUP_NEXT_TEST_ID); +describe('TagList', () => { + const getTag = () => screen.getByTestId(TAG_LIST_TEST_ID); it('should detect nested focusable interaction targets', () => { const root = document.createElement('div'); @@ -94,7 +92,7 @@ describe('TagGroupNext', () => { expect(getTag()).not.toHaveAttribute('data-selected'); }); - it('should toggle selection on ctrl click', async () => { + it('should toggle selection on ctrl+click', async () => { const user = userEvent.setup(); const onSelectionChange = vi.fn(); @@ -113,7 +111,7 @@ describe('TagGroupNext', () => { expect(getTag()).toHaveAttribute('data-selected', 'true'); }); - it('should toggle selection on cmd click', async () => { + it('should toggle selection on cmd+click', async () => { const user = userEvent.setup(); const onSelectionChange = vi.fn(); @@ -212,7 +210,8 @@ describe('TagGroupNext', () => { await user.click(getTag()); await user.keyboard('{Space}'); - await user.click(screen.getAllByRole('row')[0]); + const firstRow = screen.getAllByRole('row')[0]; + if (firstRow) await user.click(firstRow); expect(getTag()).toHaveAttribute('data-selected', 'true'); }); @@ -237,7 +236,7 @@ describe('TagGroupNext', () => { it('should remove focused tag after selected tags were removed', async () => { const user = userEvent.setup(); - render(); + render(); await user.keyboard('{Control>}'); await user.click(screen.getByTestId('tag-2')); @@ -309,4 +308,128 @@ describe('TagGroupNext', () => { 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 not render a remove button on a disabled tag', () => { + render( + renderComponent({ + onRemove: vi.fn(), + disabledKeys: ['2'], + }) + ); + + // With default `disabledBehavior: 'all'`, the disabled tag is taken + // out of the focus order entirely (so keyboard removal cannot reach + // it) AND the remove-button affordance is suppressed for it. We + // assert the affordance contract here — the non-disabled tags still + // get their buttons (3 of them in the fixture). + const disabledTag = getTag(); + expect(within(disabledTag).queryByRole('button')).toBeNull(); + + const allRemoveButtons = screen.queryAllByRole('button'); + expect(allRemoveButtons).toHaveLength(3); + }); }); diff --git a/packages/components/src/components/TagList/TagList.tsx b/packages/components/src/components/TagList/TagList.tsx new file mode 100644 index 000000000..e32a398db --- /dev/null +++ b/packages/components/src/components/TagList/TagList.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { forwardRef } from 'react'; +import type { Ref } from 'react'; + +import { useListState } 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 { + style, + variant, + onRemove, + autoFocus, + className, + slotProps, + escapeKeyBehavior, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, + 'data-testid': dataTestid, + } = props; + + const state = useListState(props); + + return ( + + ); +} + +const TagListComponent = forwardRef(TagListRender) as TagListComponent; + +type CompoundedComponent = typeof TagListComponent & { + Tag: typeof Tag; +}; + +export const TagList = TagListComponent as CompoundedComponent; + +TagList.Tag = Tag; diff --git a/packages/components/src/components/TagGroupNext/TagGroupNext.tsx b/packages/components/src/components/TagList/TagListInner.tsx similarity index 53% rename from packages/components/src/components/TagGroupNext/TagGroupNext.tsx rename to packages/components/src/components/TagList/TagListInner.tsx index c8d3b9c81..fb243a66c 100644 --- a/packages/components/src/components/TagGroupNext/TagGroupNext.tsx +++ b/packages/components/src/components/TagList/TagListInner.tsx @@ -1,40 +1,81 @@ 'use client'; -import { forwardRef, useMemo } from 'react'; -import type { RefObject, Ref } from 'react'; +import { useMemo } from 'react'; +import type { + Ref, + RefObject, + CSSProperties, + ComponentPropsWithRef, +} from 'react'; +import type { FocusStrategy, Key } from '@koobiq/react-core'; import { clsx, - mergeProps, + useLocale, useDOMRef, + mergeProps, useFocusWithin, - useLocale, } from '@koobiq/react-core'; +import type { ListState } from '@koobiq/react-primitives'; import { - ListKeyboardDelegate, - useListState, useSelectableList, + ListKeyboardDelegate, } from '@koobiq/react-primitives'; import { TagItem } from './components'; -import { Tag } from './Tag'; -import groupStyles from './TagGroupNext.module.css'; -import type { TagGroupNextComponent, TagGroupNextProps } from './types'; +import groupStyles from './TagList.module.css'; +import type { TagListPropVariant } from './types'; -function TagGroupNextRender( - props: TagGroupNextProps, - ref?: Ref -) { +export type TagListInnerProps = { + /** Pre-built collection state, e.g. from `useListState`. */ + state: ListState; + /** + * The variant to use. + * @default 'theme-fade' + */ + variant?: TagListPropVariant; + /** Handler that is called when a user deletes a tag. */ + onRemove?: (keys: Set) => void; + /** + * Whether pressing the Escape key should clear selection. + * @default 'clearSelection' + */ + escapeKeyBehavior?: 'clearSelection' | 'none'; + /** Auto-focus the first/last tag on mount. */ + autoFocus?: boolean | FocusStrategy; + /** Ref to the root element. */ + tagListRef?: Ref; + /** Additional CSS-classes. */ + className?: string; + /** Inline styles. */ + style?: CSSProperties; + /** Unique identifier for testing purposes. */ + 'data-testid'?: string | number; + /** An accessibility label. */ + 'aria-label'?: string; + /** ID of an element that labels this collection. */ + 'aria-labelledby'?: string; + /** ID of an element that describes this collection. */ + 'aria-describedby'?: string; + /** The props used for each slot inside. */ + slotProps?: { + root?: ComponentPropsWithRef<'div'>; + }; +}; + +export function TagListInner(props: TagListInnerProps) { const { + state, variant = 'theme-fade', style, className, slotProps, onRemove, escapeKeyBehavior, + autoFocus, + tagListRef, } = props; - const domRef = useDOMRef(ref); - const state = useListState(props); + const domRef = useDOMRef(tagListRef); const { direction } = useLocale(); const keyboardDelegate = useMemo( @@ -60,15 +101,14 @@ function TagGroupNextRender( keyboardDelegate, shouldFocusWrap: true, escapeKeyBehavior, + autoFocus, collection: state.collection, disabledKeys: state.disabledKeys, selectionManager: state.selectionManager, ref: domRef as RefObject, }); - // Drop selection when focus leaves the entire group. `useFocusWithin` - // correctly handles Shadow DOM / portal cases that a hand-rolled - // `onBlur` + `relatedTarget` check wouldn't. + // Clear selection when focus leaves the group. const { focusWithinProps } = useFocusWithin({ onBlurWithin: () => state.selectionManager.clearSelection(), }); @@ -104,15 +144,3 @@ function TagGroupNextRender( ); } - -const TagGroupNextComponent = forwardRef( - TagGroupNextRender -) as TagGroupNextComponent; - -type CompoundedComponent = typeof TagGroupNextComponent & { - Tag: typeof Tag; -}; - -export const TagGroupNext = TagGroupNextComponent as CompoundedComponent; - -TagGroupNext.Tag = Tag; diff --git a/packages/components/src/components/TagGroupNext/components/TagItem/TagItem.module.css b/packages/components/src/components/TagList/components/TagItem/TagItem.module.css similarity index 100% rename from packages/components/src/components/TagGroupNext/components/TagItem/TagItem.module.css rename to packages/components/src/components/TagList/components/TagItem/TagItem.module.css diff --git a/packages/components/src/components/TagGroupNext/components/TagItem/TagItem.tsx b/packages/components/src/components/TagList/components/TagItem/TagItem.tsx similarity index 89% rename from packages/components/src/components/TagGroupNext/components/TagItem/TagItem.tsx rename to packages/components/src/components/TagList/components/TagItem/TagItem.tsx index ec31933c7..bd956d6c8 100644 --- a/packages/components/src/components/TagGroupNext/components/TagItem/TagItem.tsx +++ b/packages/components/src/components/TagList/components/TagItem/TagItem.tsx @@ -12,17 +12,17 @@ import type { ListState } from '@koobiq/react-primitives'; import { utilClasses } from '../../../../styles/utility'; import { IconButton } from '../../../IconButton'; import type { IconButtonProps } from '../../../IconButton'; -import { useTagItem } from '../../hooks'; -import type { TagGroupNextPropVariant } from '../../types'; +import { useTagListItem } from '../../hooks'; +import type { TagListPropVariant } from '../../types'; import s from './TagItem.module.css'; -import { getTagGroupNextItemProps, matchVariantToIconButton } from './utils'; +import { getTagListItemProps, matchVariantToIconButton } from './utils'; type TagItemProps = { state: ListState; collectionId?: string; item: CollectionNode; - variant: TagGroupNextPropVariant; + variant: TagListPropVariant; onRemove?: (keys: Set) => void; }; @@ -30,7 +30,7 @@ const textNormalMedium = utilClasses.typography['text-normal-medium']; export function TagItem(props: TagItemProps) { const { collectionId, item, onRemove, state, variant: groupVariant } = props; - const itemProps = getTagGroupNextItemProps(item); + const itemProps = getTagListItemProps(item); const variant = groupVariant; const { @@ -41,7 +41,7 @@ export function TagItem(props: TagItemProps) { gridCellProps, allowsRemoving, removeButtonProps: removeButtonPropsAria, - } = useTagItem({ collectionId, item, onRemove, state }); + } = useTagListItem({ collectionId, item, onRemove, state }); const { focusProps, isFocusVisible, isFocused } = useFocusRing({ within: false, diff --git a/packages/components/src/components/TagGroupNext/components/TagItem/index.ts b/packages/components/src/components/TagList/components/TagItem/index.ts similarity index 100% rename from packages/components/src/components/TagGroupNext/components/TagItem/index.ts rename to packages/components/src/components/TagList/components/TagItem/index.ts diff --git a/packages/components/src/components/TagGroupNext/components/TagItem/utils.ts b/packages/components/src/components/TagList/components/TagItem/utils.ts similarity index 56% rename from packages/components/src/components/TagGroupNext/components/TagItem/utils.ts rename to packages/components/src/components/TagList/components/TagItem/utils.ts index 263c1319c..d618f0f78 100644 --- a/packages/components/src/components/TagGroupNext/components/TagItem/utils.ts +++ b/packages/components/src/components/TagList/components/TagItem/utils.ts @@ -1,15 +1,15 @@ import type { Node } from '@koobiq/react-core'; import type { IconButtonPropVariant } from '../../../IconButton'; -import type { ItemProps } from '../../Tag'; -import type { TagGroupNextPropVariant } from '../../types'; +import type { TagProps } from '../../Tag'; +import type { TagListPropVariant } from '../../types'; -export function getTagGroupNextItemProps(node: Node) { - return node.props as ItemProps; +export function getTagListItemProps(node: Node) { + return node.props as TagProps; } export const matchVariantToIconButton: Record< - TagGroupNextPropVariant, + TagListPropVariant, IconButtonPropVariant > = { 'theme-fade': 'theme', diff --git a/packages/components/src/components/TagGroupNext/components/index.ts b/packages/components/src/components/TagList/components/index.ts similarity index 100% rename from packages/components/src/components/TagGroupNext/components/index.ts rename to packages/components/src/components/TagList/components/index.ts diff --git a/packages/components/src/components/TagList/hooks/index.ts b/packages/components/src/components/TagList/hooks/index.ts new file mode 100644 index 000000000..362afdc0a --- /dev/null +++ b/packages/components/src/components/TagList/hooks/index.ts @@ -0,0 +1 @@ +export * from './useTagListItem'; diff --git a/packages/components/src/components/TagGroupNext/hooks/useTagItem.ts b/packages/components/src/components/TagList/hooks/useTagListItem.ts similarity index 95% rename from packages/components/src/components/TagGroupNext/hooks/useTagItem.ts rename to packages/components/src/components/TagList/hooks/useTagListItem.ts index cc39b8af3..b74fab4bb 100644 --- a/packages/components/src/components/TagGroupNext/hooks/useTagItem.ts +++ b/packages/components/src/components/TagList/hooks/useTagListItem.ts @@ -15,10 +15,10 @@ import { import type { Key, Node, PressEvent, DOMAttributes } from '@koobiq/react-core'; import type { ListState } from '@koobiq/react-primitives'; -import { getTagGroupNextItemProps } from '../components/TagItem/utils'; +import { getTagListItemProps } from '../components/TagItem/utils'; import intlMessages from '../intl.json'; -export type UseTagItemProps = { +export type UseTagListItemProps = { state: ListState; collectionId?: string; item: Node; @@ -49,14 +49,16 @@ function isSpaceKey(key: string) { return key === ' ' || key === 'Space' || key === 'Spacebar'; } -export function useTagItem(props: UseTagItemProps) { +export function useTagListItem( + props: UseTagListItemProps +) { const { collectionId, item, onRemove, state } = props; const ref = useRef(null); const rowId = useId(); const removeButtonId = useId(); - const itemProps = getTagGroupNextItemProps(item); + const itemProps = getTagListItemProps(item); const { selectionManager } = state; diff --git a/packages/components/src/components/TagList/index.ts b/packages/components/src/components/TagList/index.ts new file mode 100644 index 000000000..f22a3aada --- /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/TagGroupNext/intl.json b/packages/components/src/components/TagList/intl.json similarity index 100% rename from packages/components/src/components/TagGroupNext/intl.json rename to packages/components/src/components/TagList/intl.json diff --git a/packages/components/src/components/TagGroupNext/types.ts b/packages/components/src/components/TagList/types.ts similarity index 54% rename from packages/components/src/components/TagGroupNext/types.ts rename to packages/components/src/components/TagList/types.ts index 4cb2d8025..5295268f6 100644 --- a/packages/components/src/components/TagGroupNext/types.ts +++ b/packages/components/src/components/TagList/types.ts @@ -10,40 +10,46 @@ import type { Key, CollectionBase, ExtendableProps, + FocusStrategy, MultipleSelection, } from '@koobiq/react-core'; -export const tagGroupNextPropVariant = [ +export const tagListPropVariant = [ 'theme-fade', 'contrast-fade', 'error-fade', 'warning-fade', ] as const; -export type TagGroupNextPropVariant = (typeof tagGroupNextPropVariant)[number]; +export type TagListPropVariant = (typeof tagListPropVariant)[number]; -type TagGroupNextDOMProps = Omit< +type TagListDOMProps = Omit< ComponentPropsWithRef<'div'>, - 'children' | 'defaultValue' | 'onChange' | 'onSelect' | 'ref' + 'children' | 'defaultValue' | 'onChange' | 'onSelect' | 'ref' | 'autoFocus' >; -type TagGroupNextCollectionProps = CollectionBase & +type TagListCollectionProps = CollectionBase & Omit; -type TagGroupNextKeyboardProps = { +type TagListKeyboardProps = { /** * Whether pressing the Escape key should clear selection. * @default 'clearSelection' */ escapeKeyBehavior?: 'clearSelection' | 'none'; + /** + * Whether the collection auto-focuses on mount. `true` / `'first'` focuses + * the first tag, `'last'` the last tag. + */ + autoFocus?: boolean | FocusStrategy; }; -type TagGroupNextOwnProps = { +type TagListBaseProps = { /** * The variant to use. * @default 'theme-fade' */ - variant?: TagGroupNextPropVariant; + variant?: TagListPropVariant; /** Ref to the root element. */ ref?: Ref; /** Additional CSS-classes. */ @@ -60,18 +66,17 @@ type TagGroupNextOwnProps = { }; }; -type TagGroupNextInheritedProps = - TagGroupNextCollectionProps & - TagGroupNextKeyboardProps & - TagGroupNextDOMProps; +type TagListInheritedProps = TagListCollectionProps & + TagListKeyboardProps & + TagListDOMProps; -export type TagGroupNextProps = ExtendableProps< - TagGroupNextOwnProps, - TagGroupNextInheritedProps +export type TagListProps = ExtendableProps< + TagListBaseProps, + TagListInheritedProps >; -export type TagGroupNextComponent = ( - props: TagGroupNextProps +export type TagListComponent = ( + props: TagListProps ) => ReactElement | null; -export type TagGroupNextRef = ComponentRef<'div'>; +export type TagListRef = ComponentRef<'div'>; diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index e8a1eddff..0db63385f 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -33,7 +33,7 @@ export * from './Divider'; export * from './Menu'; export * from './ButtonToggleGroup'; export * from './TagGroup'; -export * from './TagGroupNext'; +export * from './TagList'; export * from './Table'; export * from './Calendar'; export * from './DateInput'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0d9ef5a8f..19a8f2c12 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -40,6 +40,7 @@ export type { RouterOptions, SortDescriptor, Selection, + FocusStrategy, } from '@react-types/shared'; export * from '@react-aria/i18n'; From c991586073a8d0f69cff46fc1a6f5474bd8d0225 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 21 May 2026 22:50:25 +0300 Subject: [PATCH 08/54] docs(TagList): mark keyboard keys with in MDX --- .../src/components/TagList/TagList.mdx | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/components/src/components/TagList/TagList.mdx b/packages/components/src/components/TagList/TagList.mdx index 8e485dfc2..b37ab0f37 100644 --- a/packages/components/src/components/TagList/TagList.mdx +++ b/packages/components/src/components/TagList/TagList.mdx @@ -33,7 +33,7 @@ import { TagList } from '@koobiq/react-components'; ## Selection TagList keeps pointer selection explicit: a regular click only focuses a tag. -To select tags, use `Ctrl` + click, `Ctrl+A`, or press `Space` while a tag is focused. +To select tags, use Ctrl + click, Ctrl + A, or press Space while a tag is focused. @@ -64,14 +64,16 @@ 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 +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. + +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. @@ -91,24 +93,24 @@ or automatically derived from the values passed to the `items` prop. - `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. + 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` + click | Toggle selection without clearing the previous one. | -| `Ctrl` + `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. | +| 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 + click | Toggle selection without clearing the previous one. | +| Ctrl + 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 From d62967d3d5bdda4a8e9cc226d7b7180aedbb502b Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 21 May 2026 22:54:27 +0300 Subject: [PATCH 09/54] docs(TagList): pair Ctrl with Cmd in keyboard docs --- .../src/components/TagList/TagList.mdx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/components/src/components/TagList/TagList.mdx b/packages/components/src/components/TagList/TagList.mdx index b37ab0f37..cc90cd829 100644 --- a/packages/components/src/components/TagList/TagList.mdx +++ b/packages/components/src/components/TagList/TagList.mdx @@ -33,7 +33,7 @@ import { TagList } from '@koobiq/react-components'; ## Selection TagList keeps pointer selection explicit: a regular click only focuses a tag. -To select tags, use Ctrl + click, Ctrl + A, or press Space while a tag is focused. +To select tags, use Ctrl / Cmd + click, Ctrl / Cmd + A, or press Space while a tag is focused. @@ -101,16 +101,16 @@ or automatically derived from the values passed to the `items` prop. ## 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 + click | Toggle selection without clearing the previous one. | -| Ctrl + 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. | +| 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 From 5c83b7866942b9f92f82f7c4046359efb4cfa506 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 21 May 2026 23:02:53 +0300 Subject: [PATCH 10/54] refactor(TagList): extract behavior into useTagList and useTagListState --- .../src/components/TagList/TagList.tsx | 5 +- .../src/components/TagList/TagListInner.tsx | 65 ++------------ .../src/components/TagList/hooks/index.ts | 2 + .../components/TagList/hooks/useTagList.ts | 87 +++++++++++++++++++ .../TagList/hooks/useTagListState.ts | 13 +++ 5 files changed, 113 insertions(+), 59 deletions(-) create mode 100644 packages/components/src/components/TagList/hooks/useTagList.ts create mode 100644 packages/components/src/components/TagList/hooks/useTagListState.ts diff --git a/packages/components/src/components/TagList/TagList.tsx b/packages/components/src/components/TagList/TagList.tsx index e32a398db..e09cd347f 100644 --- a/packages/components/src/components/TagList/TagList.tsx +++ b/packages/components/src/components/TagList/TagList.tsx @@ -3,8 +3,7 @@ import { forwardRef } from 'react'; import type { Ref } from 'react'; -import { useListState } from '@koobiq/react-primitives'; - +import { useTagListState } from './hooks'; import { Tag } from './Tag'; import { TagListInner } from './TagListInner'; import type { TagListComponent, TagListProps } from './types'; @@ -27,7 +26,7 @@ function TagListRender( 'data-testid': dataTestid, } = props; - const state = useListState(props); + const state = useTagListState(props); return ( (props: TagListInnerProps) { tagListRef, } = props; const domRef = useDOMRef(tagListRef); - const { direction } = useLocale(); - const keyboardDelegate = useMemo( - () => - new ListKeyboardDelegate({ - collection: state.collection, - direction, - disabledBehavior: state.selectionManager.disabledBehavior, - disabledKeys: state.disabledKeys, - orientation: 'horizontal', - ref: domRef as RefObject, - }), - [ - domRef, - direction, - state.collection, - state.disabledKeys, - state.selectionManager.disabledBehavior, - ] + const { gridProps } = useTagList( + { escapeKeyBehavior, autoFocus }, + state, + domRef ); - const { listProps } = useSelectableList({ - keyboardDelegate, - shouldFocusWrap: true, - escapeKeyBehavior, - autoFocus, - collection: state.collection, - disabledKeys: state.disabledKeys, - selectionManager: state.selectionManager, - ref: domRef as RefObject, - }); - - // Clear selection when focus leaves the group. - const { focusWithinProps } = useFocusWithin({ - onBlurWithin: () => state.selectionManager.clearSelection(), - }); - - const collectionId = (listProps as Record)[ + const collectionId = (gridProps as Record)[ 'data-collection' ] as string | undefined; @@ -122,10 +77,8 @@ export function TagListInner(props: TagListInnerProps) { style, ref: domRef, className: clsx(groupStyles.base, className), - role: state.collection.size ? 'grid' : 'group', }, - listProps, - focusWithinProps, + gridProps, slotProps?.root ); diff --git a/packages/components/src/components/TagList/hooks/index.ts b/packages/components/src/components/TagList/hooks/index.ts index 362afdc0a..bdc0c9c75 100644 --- a/packages/components/src/components/TagList/hooks/index.ts +++ b/packages/components/src/components/TagList/hooks/index.ts @@ -1 +1,3 @@ +export * from './useTagList'; export * from './useTagListItem'; +export * from './useTagListState'; diff --git a/packages/components/src/components/TagList/hooks/useTagList.ts b/packages/components/src/components/TagList/hooks/useTagList.ts new file mode 100644 index 000000000..d02ddb75d --- /dev/null +++ b/packages/components/src/components/TagList/hooks/useTagList.ts @@ -0,0 +1,87 @@ +import { useMemo } from 'react'; +import type { RefObject } from 'react'; + +import type { + DOMAttributes, + FocusStrategy, + RefObject as AriaRefObject, +} from '@koobiq/react-core'; +import { mergeProps, useFocusWithin, useLocale } from '@koobiq/react-core'; +import type { ListState } from '@koobiq/react-primitives'; +import { + ListKeyboardDelegate, + useSelectableList, +} from '@koobiq/react-primitives'; + +export type AriaTagListProps = { + /** + * Whether pressing the Escape key should clear selection. + * @default 'clearSelection' + */ + escapeKeyBehavior?: 'clearSelection' | 'none'; + /** Auto-focus the first/last tag on mount. */ + autoFocus?: boolean | FocusStrategy; +}; + +export type TagListAria = { + /** Props for the root grid/group element. */ + gridProps: DOMAttributes; +}; + +/** + * 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, + shouldFocusWrap: true, + 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(), + }); + + return { + gridProps: mergeProps( + { + role: state.collection.size ? 'grid' : 'group', + }, + listProps, + focusWithinProps + ), + }; +} diff --git a/packages/components/src/components/TagList/hooks/useTagListState.ts b/packages/components/src/components/TagList/hooks/useTagListState.ts new file mode 100644 index 000000000..1e3392919 --- /dev/null +++ b/packages/components/src/components/TagList/hooks/useTagListState.ts @@ -0,0 +1,13 @@ +import type { ListProps, ListState } from '@koobiq/react-primitives'; +import { useListState } from '@koobiq/react-primitives'; + +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); +} From db60548cf2eb7e663c431d0beb01f36e0fd7b4de Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 21 May 2026 23:09:59 +0300 Subject: [PATCH 11/54] test(TagList): add prop forwarding tests and fix data-testid/aria leak --- .../src/components/TagList/TagList.test.tsx | 55 ++++++++++++++++++- .../src/components/TagList/TagList.tsx | 4 +- .../src/components/TagList/TagListInner.tsx | 14 ++++- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/packages/components/src/components/TagList/TagList.test.tsx b/packages/components/src/components/TagList/TagList.test.tsx index e3a88b17a..2159eed5b 100644 --- a/packages/components/src/components/TagList/TagList.test.tsx +++ b/packages/components/src/components/TagList/TagList.test.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { createRef, useState } from 'react'; import { render, screen, waitFor, within } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; @@ -432,4 +432,57 @@ describe('TagList', () => { const allRemoveButtons = screen.queryAllByRole('button'); expect(allRemoveButtons).toHaveLength(3); }); + + 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 index e09cd347f..d3db239f9 100644 --- a/packages/components/src/components/TagList/TagList.tsx +++ b/packages/components/src/components/TagList/TagList.tsx @@ -21,9 +21,9 @@ function TagListRender( slotProps, escapeKeyBehavior, 'aria-label': ariaLabel, + 'data-testid': dataTestid, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, - 'data-testid': dataTestid, } = props; const state = useTagListState(props); @@ -39,10 +39,10 @@ function TagListRender( className={className} slotProps={slotProps} aria-label={ariaLabel} + data-testid={dataTestid} aria-labelledby={ariaLabelledBy} aria-describedby={ariaDescribedBy} escapeKeyBehavior={escapeKeyBehavior} - data-testid={dataTestid} /> ); } diff --git a/packages/components/src/components/TagList/TagListInner.tsx b/packages/components/src/components/TagList/TagListInner.tsx index c66850827..6e44164e3 100644 --- a/packages/components/src/components/TagList/TagListInner.tsx +++ b/packages/components/src/components/TagList/TagListInner.tsx @@ -50,15 +50,19 @@ export type TagListInnerProps = { export function TagListInner(props: TagListInnerProps) { const { - state, variant = 'theme-fade', style, + state, + onRemove, className, slotProps, - onRemove, - escapeKeyBehavior, autoFocus, tagListRef, + escapeKeyBehavior, + 'aria-label': ariaLabel, + 'data-testid': dataTestid, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, } = props; const domRef = useDOMRef(tagListRef); @@ -77,6 +81,10 @@ export function TagListInner(props: TagListInnerProps) { style, ref: domRef, className: clsx(groupStyles.base, className), + 'data-testid': dataTestid, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, }, gridProps, slotProps?.root From ea4800b61b83247057089cc70b4c7670e28a5a08 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 21 May 2026 23:25:21 +0300 Subject: [PATCH 12/54] refactor(TagList): drop prop duplication between TagList and TagListInner --- .../components/TagList/TagList.stories.tsx | 14 +++--- .../src/components/TagList/TagList.tsx | 32 +------------- .../src/components/TagList/TagListInner.tsx | 43 +------------------ .../src/components/TagList/types.ts | 20 +++++++++ 4 files changed, 30 insertions(+), 79 deletions(-) diff --git a/packages/components/src/components/TagList/TagList.stories.tsx b/packages/components/src/components/TagList/TagList.stories.tsx index d01532cfa..22c50bade 100644 --- a/packages/components/src/components/TagList/TagList.stories.tsx +++ b/packages/components/src/components/TagList/TagList.stories.tsx @@ -4,7 +4,7 @@ import { IconGlobe16 } from '@koobiq/react-icons'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { FlexBox } from '../FlexBox'; -import { useListData } from '../index'; +import { Typography, useListData } from '../index'; import { TagList } from './TagList'; import { tagListPropVariant } from './types'; @@ -12,7 +12,7 @@ import { tagListPropVariant } from './types'; const meta = { title: 'Components/TagList', component: TagList, - subcomponents: { Tag: TagList.Tag }, + subcomponents: { 'TagList.Tag': TagList.Tag }, parameters: { layout: 'centered', }, @@ -44,8 +44,8 @@ export const Variant: Story = { undefined} aria-label="Libraries" + onRemove={() => undefined} {...args} > React @@ -82,10 +82,10 @@ export const RemoveTags: Story = { return ( - aria-label="Libraries" - selectionMode="multiple" items={list.items} disabledKeys={[4]} + aria-label="Libraries" + selectionMode="multiple" onRemove={(keys) => { args.onRemove?.(keys); list.remove(...keys); @@ -156,7 +156,9 @@ export const ControlledSelection: Story = { Storybook Tailwind - Selected: {[...selected].join(', ') || '(none)'} + + Selected: {[...selected].join(', ') || '(none)'} + ); }, diff --git a/packages/components/src/components/TagList/TagList.tsx b/packages/components/src/components/TagList/TagList.tsx index d3db239f9..100c34006 100644 --- a/packages/components/src/components/TagList/TagList.tsx +++ b/packages/components/src/components/TagList/TagList.tsx @@ -12,39 +12,9 @@ function TagListRender( props: TagListProps, ref?: Ref ) { - const { - style, - variant, - onRemove, - autoFocus, - className, - slotProps, - escapeKeyBehavior, - 'aria-label': ariaLabel, - 'data-testid': dataTestid, - 'aria-labelledby': ariaLabelledBy, - 'aria-describedby': ariaDescribedBy, - } = props; - const state = useTagListState(props); - return ( - - ); + return ; } const TagListComponent = forwardRef(TagListRender) as TagListComponent; diff --git a/packages/components/src/components/TagList/TagListInner.tsx b/packages/components/src/components/TagList/TagListInner.tsx index 6e44164e3..efb359e49 100644 --- a/packages/components/src/components/TagList/TagListInner.tsx +++ b/packages/components/src/components/TagList/TagListInner.tsx @@ -1,52 +1,11 @@ 'use client'; -import type { ComponentPropsWithRef, CSSProperties, Ref } from 'react'; - -import type { FocusStrategy, Key } from '@koobiq/react-core'; import { clsx, mergeProps, useDOMRef } from '@koobiq/react-core'; -import type { ListState } from '@koobiq/react-primitives'; import { TagItem } from './components'; import { useTagList } from './hooks'; import groupStyles from './TagList.module.css'; -import type { TagListPropVariant } from './types'; - -export type TagListInnerProps = { - /** Pre-built collection state, e.g. from `useListState`. */ - state: ListState; - /** - * The variant to use. - * @default 'theme-fade' - */ - variant?: TagListPropVariant; - /** Handler that is called when a user deletes a tag. */ - onRemove?: (keys: Set) => void; - /** - * Whether pressing the Escape key should clear selection. - * @default 'clearSelection' - */ - escapeKeyBehavior?: 'clearSelection' | 'none'; - /** Auto-focus the first/last tag on mount. */ - autoFocus?: boolean | FocusStrategy; - /** Ref to the root element. */ - tagListRef?: Ref; - /** Additional CSS-classes. */ - className?: string; - /** Inline styles. */ - style?: CSSProperties; - /** Unique identifier for testing purposes. */ - 'data-testid'?: string | number; - /** An accessibility label. */ - 'aria-label'?: string; - /** ID of an element that labels this collection. */ - 'aria-labelledby'?: string; - /** ID of an element that describes this collection. */ - 'aria-describedby'?: string; - /** The props used for each slot inside. */ - slotProps?: { - root?: ComponentPropsWithRef<'div'>; - }; -}; +import type { TagListInnerProps } from './types'; export function TagListInner(props: TagListInnerProps) { const { diff --git a/packages/components/src/components/TagList/types.ts b/packages/components/src/components/TagList/types.ts index 5295268f6..4911a3221 100644 --- a/packages/components/src/components/TagList/types.ts +++ b/packages/components/src/components/TagList/types.ts @@ -13,6 +13,7 @@ import type { FocusStrategy, MultipleSelection, } from '@koobiq/react-core'; +import type { ListState } from '@koobiq/react-primitives'; export const tagListPropVariant = [ 'theme-fade', @@ -75,6 +76,25 @@ export type TagListProps = ExtendableProps< TagListInheritedProps >; +export type TagListInnerProps = { + /** Pre-built collection state, e.g. from `useTagListState`. */ + state: ListState; + /** 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; From 9a7f84181cff84501c3a875fceb808eb52b1b6b0 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 21 May 2026 23:56:32 +0300 Subject: [PATCH 13/54] refactor(primitives): move TagList hooks to behaviors --- .../src/components/TagList/TagList.test.tsx | 2 +- .../src/components/TagList/TagList.tsx | 3 +- .../src/components/TagList/TagListInner.tsx | 2 +- .../TagList/components/TagItem/TagItem.tsx | 7 ++- .../TagList/components/TagItem/utils.ts | 7 --- .../src/components/TagList/hooks/index.ts | 3 - .../src/components/TagList/intl.json | 10 --- .../src/components/TagList/types.ts | 5 +- packages/primitives/src/behaviors/index.ts | 3 + .../src/behaviors}/useTagList.ts | 11 ++-- .../src/behaviors}/useTagListItem.ts | 61 +++++++++++++------ .../src/behaviors}/useTagListState.ts | 6 +- 12 files changed, 61 insertions(+), 59 deletions(-) delete mode 100644 packages/components/src/components/TagList/hooks/index.ts delete mode 100644 packages/components/src/components/TagList/intl.json rename packages/{components/src/components/TagList/hooks => primitives/src/behaviors}/useTagList.ts (87%) rename packages/{components/src/components/TagList/hooks => primitives/src/behaviors}/useTagListItem.ts (83%) rename packages/{components/src/components/TagList/hooks => primitives/src/behaviors}/useTagListState.ts (58%) diff --git a/packages/components/src/components/TagList/TagList.test.tsx b/packages/components/src/components/TagList/TagList.test.tsx index 2159eed5b..348763610 100644 --- a/packages/components/src/components/TagList/TagList.test.tsx +++ b/packages/components/src/components/TagList/TagList.test.tsx @@ -1,12 +1,12 @@ 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 { isInteractiveTarget } from './hooks'; import { TagList, type TagListProps } from './index'; const TAG_LIST_TEST_ID = 'TAG_LIST_TAG'; diff --git a/packages/components/src/components/TagList/TagList.tsx b/packages/components/src/components/TagList/TagList.tsx index 100c34006..d6dde6dd9 100644 --- a/packages/components/src/components/TagList/TagList.tsx +++ b/packages/components/src/components/TagList/TagList.tsx @@ -3,7 +3,8 @@ import { forwardRef } from 'react'; import type { Ref } from 'react'; -import { useTagListState } from './hooks'; +import { useTagListState } from '@koobiq/react-primitives'; + import { Tag } from './Tag'; import { TagListInner } from './TagListInner'; import type { TagListComponent, TagListProps } from './types'; diff --git a/packages/components/src/components/TagList/TagListInner.tsx b/packages/components/src/components/TagList/TagListInner.tsx index efb359e49..460723045 100644 --- a/packages/components/src/components/TagList/TagListInner.tsx +++ b/packages/components/src/components/TagList/TagListInner.tsx @@ -1,9 +1,9 @@ 'use client'; import { clsx, mergeProps, useDOMRef } from '@koobiq/react-core'; +import { useTagList } from '@koobiq/react-primitives'; import { TagItem } from './components'; -import { useTagList } from './hooks'; import groupStyles from './TagList.module.css'; import type { TagListInnerProps } from './types'; diff --git a/packages/components/src/components/TagList/components/TagItem/TagItem.tsx b/packages/components/src/components/TagList/components/TagItem/TagItem.tsx index bd956d6c8..5e4538686 100644 --- a/packages/components/src/components/TagList/components/TagItem/TagItem.tsx +++ b/packages/components/src/components/TagList/components/TagItem/TagItem.tsx @@ -8,15 +8,16 @@ import { } 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 { useTagListItem } from '../../hooks'; +import type { TagProps } from '../../Tag'; import type { TagListPropVariant } from '../../types'; import s from './TagItem.module.css'; -import { getTagListItemProps, matchVariantToIconButton } from './utils'; +import { matchVariantToIconButton } from './utils'; type TagItemProps = { state: ListState; @@ -30,7 +31,7 @@ const textNormalMedium = utilClasses.typography['text-normal-medium']; export function TagItem(props: TagItemProps) { const { collectionId, item, onRemove, state, variant: groupVariant } = props; - const itemProps = getTagListItemProps(item); + const itemProps = item.props as TagProps; const variant = groupVariant; const { diff --git a/packages/components/src/components/TagList/components/TagItem/utils.ts b/packages/components/src/components/TagList/components/TagItem/utils.ts index d618f0f78..7beac3537 100644 --- a/packages/components/src/components/TagList/components/TagItem/utils.ts +++ b/packages/components/src/components/TagList/components/TagItem/utils.ts @@ -1,13 +1,6 @@ -import type { Node } from '@koobiq/react-core'; - import type { IconButtonPropVariant } from '../../../IconButton'; -import type { TagProps } from '../../Tag'; import type { TagListPropVariant } from '../../types'; -export function getTagListItemProps(node: Node) { - return node.props as TagProps; -} - export const matchVariantToIconButton: Record< TagListPropVariant, IconButtonPropVariant diff --git a/packages/components/src/components/TagList/hooks/index.ts b/packages/components/src/components/TagList/hooks/index.ts deleted file mode 100644 index bdc0c9c75..000000000 --- a/packages/components/src/components/TagList/hooks/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './useTagList'; -export * from './useTagListItem'; -export * from './useTagListState'; diff --git a/packages/components/src/components/TagList/intl.json b/packages/components/src/components/TagList/intl.json deleted file mode 100644 index bf660ea45..000000000 --- a/packages/components/src/components/TagList/intl.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "en-US": { - "removeButtonLabel": "Remove", - "removeDescription": "Press Delete or Backspace to remove." - }, - "ru-RU": { - "removeButtonLabel": "Удалить", - "removeDescription": "Нажмите Delete или Backspace, чтобы удалить." - } -} diff --git a/packages/components/src/components/TagList/types.ts b/packages/components/src/components/TagList/types.ts index 4911a3221..f30fe3e0b 100644 --- a/packages/components/src/components/TagList/types.ts +++ b/packages/components/src/components/TagList/types.ts @@ -38,10 +38,7 @@ type TagListKeyboardProps = { * @default 'clearSelection' */ escapeKeyBehavior?: 'clearSelection' | 'none'; - /** - * Whether the collection auto-focuses on mount. `true` / `'first'` focuses - * the first tag, `'last'` the last tag. - */ + /** Focus the first (`true` / `'first'`) or last (`'last'`) tag on mount. */ autoFocus?: boolean | FocusStrategy; }; diff --git a/packages/primitives/src/behaviors/index.ts b/packages/primitives/src/behaviors/index.ts index c110f5918..e627d7055 100644 --- a/packages/primitives/src/behaviors/index.ts +++ b/packages/primitives/src/behaviors/index.ts @@ -10,3 +10,6 @@ export * from './useNumberField'; export * from './useMultiSelect'; export * from './useMultiSelectState'; export * from './useMultiSelectListState'; +export * from './useTagList'; +export * from './useTagListItem'; +export * from './useTagListState'; diff --git a/packages/components/src/components/TagList/hooks/useTagList.ts b/packages/primitives/src/behaviors/useTagList.ts similarity index 87% rename from packages/components/src/components/TagList/hooks/useTagList.ts rename to packages/primitives/src/behaviors/useTagList.ts index d02ddb75d..13151dcdf 100644 --- a/packages/components/src/components/TagList/hooks/useTagList.ts +++ b/packages/primitives/src/behaviors/useTagList.ts @@ -7,11 +7,8 @@ import type { RefObject as AriaRefObject, } from '@koobiq/react-core'; import { mergeProps, useFocusWithin, useLocale } from '@koobiq/react-core'; -import type { ListState } from '@koobiq/react-primitives'; -import { - ListKeyboardDelegate, - useSelectableList, -} from '@koobiq/react-primitives'; +import { ListKeyboardDelegate, useSelectableList } from '@react-aria/selection'; +import type { ListState } from '@react-stately/list'; export type AriaTagListProps = { /** @@ -19,7 +16,7 @@ export type AriaTagListProps = { * @default 'clearSelection' */ escapeKeyBehavior?: 'clearSelection' | 'none'; - /** Auto-focus the first/last tag on mount. */ + /** Focus the first (`true` / `'first'`) or last (`'last'`) tag on mount. */ autoFocus?: boolean | FocusStrategy; }; @@ -29,7 +26,7 @@ export type TagListAria = { }; /** - * Provides behavior and accessibility wiring for a `TagList` root. + * Provides behavior and accessibility wiring for a TagList root. * Pair with `useTagListState` for state and `useTagListItem` for tags. */ export function useTagList( diff --git a/packages/components/src/components/TagList/hooks/useTagListItem.ts b/packages/primitives/src/behaviors/useTagListItem.ts similarity index 83% rename from packages/components/src/components/TagList/hooks/useTagListItem.ts rename to packages/primitives/src/behaviors/useTagListItem.ts index b74fab4bb..13339016c 100644 --- a/packages/components/src/components/TagList/hooks/useTagListItem.ts +++ b/packages/primitives/src/behaviors/useTagListItem.ts @@ -1,30 +1,32 @@ import type { FocusEvent } from 'react'; import { useEffect, useRef } from 'react'; +import type { DOMAttributes, Key, Node, PressEvent } from '@koobiq/react-core'; import { - useId, - usePress, - useKeyboard, - mergeProps, - isFocusable, filterDOMProps, + isFocusable, + mergeProps, useDescription, + useId, useInteractionModality, + useKeyboard, useLocalizedStringFormatter, + usePress, } from '@koobiq/react-core'; -import type { Key, Node, PressEvent, DOMAttributes } from '@koobiq/react-core'; -import type { ListState } from '@koobiq/react-primitives'; - -import { getTagListItemProps } from '../components/TagItem/utils'; -import intlMessages from '../intl.json'; - -export type UseTagListItemProps = { - state: ListState; - collectionId?: string; - item: Node; - onRemove?: (keys: Set) => void; +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; @@ -32,7 +34,12 @@ export function isCommandModifier(event: { return event.ctrlKey || event.metaKey; } -// Nested focusable controls handle their own interactions. +/** + * 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; @@ -45,10 +52,26 @@ export function isInteractiveTarget(target: Element, root: Element) { return false; } -function isSpaceKey(key: string) { +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 UseTagListItemProps = { + state: ListState; + collectionId?: string; + item: Node; + onRemove?: (keys: Set) => void; +}; + export function useTagListItem( props: UseTagListItemProps ) { @@ -58,7 +81,7 @@ export function useTagListItem( const rowId = useId(); const removeButtonId = useId(); - const itemProps = getTagListItemProps(item); + const itemProps = item.props as AriaTagListItemNodeProps; const { selectionManager } = state; diff --git a/packages/components/src/components/TagList/hooks/useTagListState.ts b/packages/primitives/src/behaviors/useTagListState.ts similarity index 58% rename from packages/components/src/components/TagList/hooks/useTagListState.ts rename to packages/primitives/src/behaviors/useTagListState.ts index 1e3392919..9c997d1d0 100644 --- a/packages/components/src/components/TagList/hooks/useTagListState.ts +++ b/packages/primitives/src/behaviors/useTagListState.ts @@ -1,11 +1,11 @@ -import type { ListProps, ListState } from '@koobiq/react-primitives'; -import { useListState } from '@koobiq/react-primitives'; +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`. */ +/** Builds the collection / selection state for a TagList. */ export function useTagListState( props: AriaTagListStateProps ): TagListState { From b9fb71692be8fe15661de0d64282059f4b3b01f3 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 22 May 2026 15:39:52 +0300 Subject: [PATCH 14/54] docs(TagList): polish MDX, add JSDoc, register storybook tags and roadmap entry --- .storybook/components/Roadmap/data.ts | 6 ++++ .../components/TagInput/TagInput.stories.tsx | 34 ------------------- .../src/components/TagList/TagList.mdx | 13 ++++--- .../components/TagList/TagList.stories.tsx | 1 + .../src/components/TagList/TagList.tsx | 4 +++ 5 files changed, 17 insertions(+), 41 deletions(-) delete mode 100644 packages/components/src/components/TagInput/TagInput.stories.tsx diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts index 0f4d2f420..237079e61 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/TagInput/TagInput.stories.tsx b/packages/components/src/components/TagInput/TagInput.stories.tsx deleted file mode 100644 index 3e759f2a6..000000000 --- a/packages/components/src/components/TagInput/TagInput.stories.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; - -import { FlexBox } from '../FlexBox'; - -import { TagInput } from './TagInput'; - -// DRAFT — TagInput is still a work-in-progress composer over -// `TagListInner`. This story exists so the in-progress UX can be -// previewed in the dev sandbox. -const meta = { - title: 'Components/TagInput (draft)', - component: TagInput, - parameters: { - layout: 'centered', - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Base: Story = { - render: function Render(args) { - return ( - - - - ); - }, -}; diff --git a/packages/components/src/components/TagList/TagList.mdx b/packages/components/src/components/TagList/TagList.mdx index cc90cd829..aa601239e 100644 --- a/packages/components/src/components/TagList/TagList.mdx +++ b/packages/components/src/components/TagList/TagList.mdx @@ -13,8 +13,8 @@ import * as Stories from './TagList.stories'; -A tag group is a focusable list of labels, categories, keywords, filters, -or other items, with support for keyboard navigation and removal. +A focusable tag list with explicit modifier-based selection, designed as +a foundation for `TagInput`, `TagAutocomplete` and multi-select composers. ## Import @@ -39,11 +39,10 @@ To select tags, use Ctrl / Cmd + click, Ctrl / ## Controlled selection -Pass `selectedKeys` together with `onSelectionChange` to drive selection from -the parent. `selectedKeys` accepts a `Set` or the literal `'all'`; -`onSelectionChange` is invoked with the next selection (also a `Set` or -`'all'`) whenever the user toggles a tag. Use `defaultSelectedKeys` for the -uncontrolled equivalent. +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`. diff --git a/packages/components/src/components/TagList/TagList.stories.tsx b/packages/components/src/components/TagList/TagList.stories.tsx index 22c50bade..3b3b7d38e 100644 --- a/packages/components/src/components/TagList/TagList.stories.tsx +++ b/packages/components/src/components/TagList/TagList.stories.tsx @@ -16,6 +16,7 @@ const meta = { parameters: { layout: 'centered', }, + tags: ['status:new', 'date:2026-05-22'], } satisfies Meta; export default meta; diff --git a/packages/components/src/components/TagList/TagList.tsx b/packages/components/src/components/TagList/TagList.tsx index d6dde6dd9..8b8c80a6e 100644 --- a/packages/components/src/components/TagList/TagList.tsx +++ b/packages/components/src/components/TagList/TagList.tsx @@ -24,6 +24,10 @@ 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; From 7112d354b32e286c76ca7c43dbb11549d6ffda2d Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 22 May 2026 16:54:53 +0300 Subject: [PATCH 15/54] chore(api-extractor): add TagList API guard and refresh primitives report --- tools/api-extractor/config.json | 1 + .../components/TagList.api.md | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 tools/public_api_guard/components/TagList.api.md diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index a5ba29a27..534e74b64 100644 --- a/tools/api-extractor/config.json +++ b/tools/api-extractor/config.json @@ -44,6 +44,7 @@ "Table", "Tabs", "TagGroup", + "TagList", "Textarea", "TimePicker", "ToastProvider", 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 000000000..a88f33095 --- /dev/null +++ b/tools/public_api_guard/components/TagList.api.md @@ -0,0 +1,55 @@ +## 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; + 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) + +``` From b8c723324e60969c4128f309e62fd80c74039a67 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Mon, 25 May 2026 23:08:08 +0300 Subject: [PATCH 16/54] fix(TagList): keep remove button visible on disabled tags --- .../src/components/TagList/TagList.test.tsx | 21 ++++++++++--------- .../src/behaviors/useTagListItem.ts | 8 +++++-- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/components/src/components/TagList/TagList.test.tsx b/packages/components/src/components/TagList/TagList.test.tsx index 348763610..387aaede1 100644 --- a/packages/components/src/components/TagList/TagList.test.tsx +++ b/packages/components/src/components/TagList/TagList.test.tsx @@ -413,24 +413,25 @@ describe('TagList', () => { expect(screen.getByTestId('tag-3')).toBeInTheDocument(); }); - it('should not render a remove button on a disabled tag', () => { + it('should render a disabled remove button on a disabled tag', async () => { + const onRemove = vi.fn(); render( renderComponent({ - onRemove: vi.fn(), + onRemove, disabledKeys: ['2'], }) ); - // With default `disabledBehavior: 'all'`, the disabled tag is taken - // out of the focus order entirely (so keyboard removal cannot reach - // it) AND the remove-button affordance is suppressed for it. We - // assert the affordance contract here — the non-disabled tags still - // get their buttons (3 of them in the fixture). + // 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(); - expect(within(disabledTag).queryByRole('button')).toBeNull(); + const button = within(disabledTag).getByRole('button'); + expect(button).toBeDisabled(); + expect(screen.queryAllByRole('button')).toHaveLength(4); - const allRemoveButtons = screen.queryAllByRole('button'); - expect(allRemoveButtons).toHaveLength(3); + // Clicking the disabled button must not trigger onRemove. + await userEvent.click(button); + expect(onRemove).not.toHaveBeenCalled(); }); describe('prop forwarding', () => { diff --git a/packages/primitives/src/behaviors/useTagListItem.ts b/packages/primitives/src/behaviors/useTagListItem.ts index 13339016c..866fa4e1c 100644 --- a/packages/primitives/src/behaviors/useTagListItem.ts +++ b/packages/primitives/src/behaviors/useTagListItem.ts @@ -89,7 +89,9 @@ export function useTagListItem( const isDisabled = selectionManager.isDisabled(item.key) || itemProps.isDisabled; - const allowsRemoving = !!onRemove && !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(item.key); @@ -112,7 +114,9 @@ export function useTagListItem( } const description = - allowsRemoving && (modality === 'keyboard' || modality === 'virtual') + allowsRemoving && + !isDisabled && + (modality === 'keyboard' || modality === 'virtual') ? stringFormatter.format('removeDescription') : ''; From 94a9432f870bf428cbe3d4a41aa437b7268f553f Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Tue, 26 May 2026 15:55:51 +0300 Subject: [PATCH 17/54] feat(components): draft `TagInput` implementation --- .../src/components/Form/Form.stories.tsx | 2 + .../src/components/TagInput/TagInput.mdx | 132 ++++ .../components/TagInput/TagInput.module.css | 55 ++ .../components/TagInput/TagInput.stories.tsx | 194 ++++++ .../src/components/TagInput/TagInput.test.tsx | 454 ++++++++++++++ .../src/components/TagInput/TagInput.tsx | 566 +++++++++++++----- .../src/components/TagInput/index.ts | 6 +- .../src/components/TagInput/types.ts | 129 ++++ packages/components/src/components/index.ts | 1 + tools/api-extractor/config.json | 1 + .../components/TagInput.api.md | 124 ++++ 11 files changed, 1519 insertions(+), 145 deletions(-) create mode 100644 packages/components/src/components/TagInput/TagInput.mdx create mode 100644 packages/components/src/components/TagInput/TagInput.module.css create mode 100644 packages/components/src/components/TagInput/TagInput.stories.tsx create mode 100644 packages/components/src/components/TagInput/TagInput.test.tsx create mode 100644 packages/components/src/components/TagInput/types.ts create mode 100644 tools/public_api_guard/components/TagInput.api.md diff --git a/packages/components/src/components/Form/Form.stories.tsx b/packages/components/src/components/Form/Form.stories.tsx index 5c2301095..b66e482b8 100644 --- a/packages/components/src/components/Form/Form.stories.tsx +++ b/packages/components/src/components/Form/Form.stories.tsx @@ -16,6 +16,7 @@ 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'; @@ -438,6 +439,7 @@ export const FormFields: Story = { Option 3 +