From 0dab012a1e5886e85b742cc3a2480892bb164b73 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 2 Jul 2026 18:57:35 +0300 Subject: [PATCH 1/6] feat(components): add renderTag + shared SelectedTag for Select/TreeSelect --- .../src/components/SelectNext/Select.mdx | 6 + .../components/SelectNext/Select.stories.tsx | 24 +++- .../src/components/SelectNext/Select.tsx | 10 +- .../__tests__/SelectMultiple.test.tsx | 74 +++++++++++- .../SelectNext/components/Tag/Tag.tsx | 105 ----------------- .../SelectNext/components/Tag/index.ts | 1 - .../SelectNext/components/Tag/utils.ts | 12 -- .../components/TagGroup/TagGroup.module.css | 46 -------- .../components/TagGroup/TagGroup.tsx | 43 ------- .../components/TagGroup/TagGroupMultiline.tsx | 48 -------- .../TagGroup/TagGroupResponsive.tsx | 71 ----------- .../SelectNext/components/TagGroup/index.ts | 1 - .../SelectNext/components/TagGroup/utils.ts | 2 - .../components/SelectNext/components/index.ts | 2 - .../src/components/SelectNext/types.ts | 15 ++- .../SelectedTags/SelectedTag/SelectedTag.tsx | 111 ++++++++++++++++++ .../SelectedTags/SelectedTag/index.ts | 1 + .../SelectedTag}/intl.json | 0 .../{Tag => SelectedTag}/utils.ts | 0 .../SelectedTags/SelectedTagsMultiline.tsx | 40 ++++--- .../SelectedTags/SelectedTagsResponsive.tsx | 44 ++++--- .../src/components/SelectedTags/Tag/Tag.tsx | 109 ----------------- .../src/components/SelectedTags/Tag/index.ts | 1 - .../src/components/SelectedTags/Tag/intl.json | 8 -- .../src/components/SelectedTags/index.ts | 1 + .../src/components/SelectedTags/types.ts | 19 +++ .../src/components/TreeSelect/TreeSelect.mdx | 7 ++ .../TreeSelect/TreeSelect.stories.tsx | 37 +++++- .../components/TreeSelect/TreeSelect.test.tsx | 43 +++++++ .../src/components/TreeSelect/TreeSelect.tsx | 6 +- .../src/components/TreeSelect/types.ts | 12 +- 31 files changed, 410 insertions(+), 489 deletions(-) delete mode 100644 packages/components/src/components/SelectNext/components/Tag/Tag.tsx delete mode 100644 packages/components/src/components/SelectNext/components/Tag/index.ts delete mode 100644 packages/components/src/components/SelectNext/components/Tag/utils.ts delete mode 100644 packages/components/src/components/SelectNext/components/TagGroup/TagGroup.module.css delete mode 100644 packages/components/src/components/SelectNext/components/TagGroup/TagGroup.tsx delete mode 100644 packages/components/src/components/SelectNext/components/TagGroup/TagGroupMultiline.tsx delete mode 100644 packages/components/src/components/SelectNext/components/TagGroup/TagGroupResponsive.tsx delete mode 100644 packages/components/src/components/SelectNext/components/TagGroup/index.ts delete mode 100644 packages/components/src/components/SelectNext/components/TagGroup/utils.ts create mode 100644 packages/components/src/components/SelectedTags/SelectedTag/SelectedTag.tsx create mode 100644 packages/components/src/components/SelectedTags/SelectedTag/index.ts rename packages/components/src/components/{SelectNext/components/Tag => SelectedTags/SelectedTag}/intl.json (100%) rename packages/components/src/components/SelectedTags/{Tag => SelectedTag}/utils.ts (100%) delete mode 100644 packages/components/src/components/SelectedTags/Tag/Tag.tsx delete mode 100644 packages/components/src/components/SelectedTags/Tag/index.ts delete mode 100644 packages/components/src/components/SelectedTags/Tag/intl.json diff --git a/packages/components/src/components/SelectNext/Select.mdx b/packages/components/src/components/SelectNext/Select.mdx index 07b8090b3..1bfcb42aa 100644 --- a/packages/components/src/components/SelectNext/Select.mdx +++ b/packages/components/src/components/SelectNext/Select.mdx @@ -151,6 +151,12 @@ To control how tags are displayed, use the `selectedTagsOverflow` prop. It suppo +### Custom Tag Render + +Allows for custom rendering of tags. + + + ### Disabled When the select component is disabled, it cannot be interacted with. diff --git a/packages/components/src/components/SelectNext/Select.stories.tsx b/packages/components/src/components/SelectNext/Select.stories.tsx index 95233f6cd..af3e02dff 100644 --- a/packages/components/src/components/SelectNext/Select.stories.tsx +++ b/packages/components/src/components/SelectNext/Select.stories.tsx @@ -32,7 +32,7 @@ const meta = { 'Select.ItemAddon': Select.ItemAddon, }, argTypes: {}, - tags: ['status:updated', 'date:2026-05-15'], + tags: ['status:updated', 'date:2026-07-02'], } satisfies Meta; export default meta; @@ -445,6 +445,28 @@ export const SelectedTagsOverflow: Story = { }, }; +export const CustomTagRender: Story = { + render: function Render() { + return ( + + ); + }, +}; + export const Disabled: Story = { render: function Render() { return ( diff --git a/packages/components/src/components/SelectNext/Select.tsx b/packages/components/src/components/SelectNext/Select.tsx index 21aff5bd3..479f51ebc 100644 --- a/packages/components/src/components/SelectNext/Select.tsx +++ b/packages/components/src/components/SelectNext/Select.tsx @@ -40,9 +40,9 @@ import { List } from '../List'; import type { ListItemAddon } from '../List/components'; import type { PopoverInnerProps, PopoverProps } from '../Popover'; import { PopoverInner } from '../Popover/PopoverInner'; +import { SelectedTags, SelectedTag } from '../SelectedTags'; import { - TagGroup, SelectList, SelectOption, SelectSection, @@ -96,6 +96,7 @@ function SelectInner({ onClear, style, label, + renderTag, } = props; const { validationBehavior: formValidationBehavior } = @@ -275,14 +276,17 @@ function SelectInner({ slotProps?.errorMessage ); + // renderTag is only consulted here; when renderValueProp is supplied, this + // function — and therefore renderTag — is never called (see `renderValue` below). const renderDefaultValue: typeof renderValueProp = (state, states) => { if (!state.selectedItems?.length) return null; if (selectionMode === 'multiple') return ( - ); @@ -392,6 +396,7 @@ type CompoundedComponent = typeof SelectComponent & { Divider: typeof Divider; ItemText: typeof ListItemText; ItemAddon: typeof ListItemAddon; + Tag: typeof SelectedTag; }; /** @@ -405,3 +410,4 @@ SelectNext.Section = SelectSection; SelectNext.Divider = Divider; SelectNext.ItemText = List.ItemText; SelectNext.ItemAddon = List.ItemAddon; +SelectNext.Tag = SelectedTag; diff --git a/packages/components/src/components/SelectNext/__tests__/SelectMultiple.test.tsx b/packages/components/src/components/SelectNext/__tests__/SelectMultiple.test.tsx index 40dd38175..40c72bf7e 100644 --- a/packages/components/src/components/SelectNext/__tests__/SelectMultiple.test.tsx +++ b/packages/components/src/components/SelectNext/__tests__/SelectMultiple.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { describe, expect, it, vi, beforeAll, afterAll } from 'vitest'; @@ -83,4 +83,76 @@ describe('Select_multiple', () => { expect(onChange).toBeCalledTimes(0); }); + + it('should render default tags with the selected item text when renderTag is not provided', () => { + render(renderComponent({ value: [1, 3] })); + + const selectedItems = screen.getByLabelText('Selected items'); + + expect(selectedItems).toHaveTextContent('1'); + expect(selectedItems).toHaveTextContent('3'); + }); + + it('should use renderTag to customize selected tags', () => { + render( + renderComponent({ + value: [1, 3], + renderTag: (item, tagProps) => ( +
+ {item.key} +
+ ), + }) + ); + + expect(screen.getByTestId('custom-tag-1')).toBeInTheDocument(); + expect(screen.getByTestId('custom-tag-3')).toBeInTheDocument(); + }); + + it('should render Select.Tag inside renderTag and support removing via its button', async () => { + const onChange = vi.fn(); + + render( + renderComponent({ + value: [1, 3], + onChange, + defaultOpen: false, + renderTag: (item, tagProps) => ( + + {item.key} + + ), + }) + ); + + expect(screen.getByTestId('tag-1')).toHaveTextContent('1'); + expect(screen.getByTestId('tag-3')).toHaveTextContent('3'); + + await userEvent.click( + within(screen.getByTestId('tag-1')).getByRole('button', { + hidden: true, + }) + ); + + expect(onChange).toHaveBeenCalled(); + + const selection = onChange.mock.calls.at(-1)?.[0]; + + expect([...selection]).toEqual([3]); + }); + + it('should ignore renderTag when renderValue is provided', () => { + render( + renderComponent({ + value: [1, 3], + renderValue: () =>
custom value
, + renderTag: (item) => ( +
{item.key}
+ ), + }) + ); + + expect(screen.getByTestId('custom-value')).toBeInTheDocument(); + expect(screen.queryByTestId('custom-tag-1')).not.toBeInTheDocument(); + }); }); diff --git a/packages/components/src/components/SelectNext/components/Tag/Tag.tsx b/packages/components/src/components/SelectNext/components/Tag/Tag.tsx deleted file mode 100644 index 68139ec32..000000000 --- a/packages/components/src/components/SelectNext/components/Tag/Tag.tsx +++ /dev/null @@ -1,105 +0,0 @@ -'use client'; - -import { - type CSSProperties, - type ReactNode, - forwardRef, - type ComponentRef, -} from 'react'; - -import type { ExtendableComponentPropsWithRef } from '@koobiq/react-core'; -import { - clsx, - mergeProps, - isNotNil, - useLocalizedStringFormatter, -} from '@koobiq/react-core'; -import { IconXmarkS16 } from '@koobiq/react-icons'; - -import { utilClasses } from '../../../../styles/utility'; -import { IconButton } from '../../../IconButton'; -import type { TagGroupPropVariant } from '../../../TagGroup'; -import s from '../../../TagGroup/components/Tag/Tag.module.css'; - -import intlMessages from './intl.json'; -import { matchVariantToCloseButton } from './utils'; - -type TagProps = ExtendableComponentPropsWithRef< - { - /** - * The variant to use. - * @default 'theme-fade' - */ - variant?: TagGroupPropVariant; - className?: string; - style?: CSSProperties; - children?: ReactNode; - icon?: ReactNode; - isDisabled?: boolean; - onRemove?: () => void; - }, - 'div' ->; - -const textNormalMedium = utilClasses.typography['text-normal-medium']; - -export const Tag = forwardRef, TagProps>((props, ref) => { - const { - variant = 'theme-fade', - icon, - className, - style, - isDisabled, - children, - onRemove, - ...other - } = props; - - const stringFormatter = useLocalizedStringFormatter(intlMessages); - - const rootProps = mergeProps({ - className: clsx( - s.base, - s[variant], - isDisabled && s.disabled, - textNormalMedium, - className - ), - ...other, - style, - }); - - const removeButtonProps = { - isCompact: true, - isDisabled, - className: s.cancelIcon, - variant: matchVariantToCloseButton[variant], - 'aria-label': stringFormatter.format('remove'), - }; - - const contentProps = mergeProps({ - className: s.content, - }); - - const iconProps = mergeProps({ - className: s.icon, - }); - - return ( -
- {isNotNil(icon) && {icon}} - {isNotNil(children) && {children}} - - - -
- ); -}); - -Tag.displayName = 'SelectTag'; diff --git a/packages/components/src/components/SelectNext/components/Tag/index.ts b/packages/components/src/components/SelectNext/components/Tag/index.ts deleted file mode 100644 index 9790fcbf1..000000000 --- a/packages/components/src/components/SelectNext/components/Tag/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Tag'; diff --git a/packages/components/src/components/SelectNext/components/Tag/utils.ts b/packages/components/src/components/SelectNext/components/Tag/utils.ts deleted file mode 100644 index ace704579..000000000 --- a/packages/components/src/components/SelectNext/components/Tag/utils.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { IconButtonPropVariant } from '../../../IconButton'; -import type { TagGroupPropVariant } from '../../../TagGroup'; - -export const matchVariantToCloseButton: Record< - TagGroupPropVariant, - IconButtonPropVariant -> = { - 'theme-fade': 'theme', - 'contrast-fade': 'fade-contrast', - 'error-fade': 'error', - 'warning-fade': 'warning', -}; diff --git a/packages/components/src/components/SelectNext/components/TagGroup/TagGroup.module.css b/packages/components/src/components/SelectNext/components/TagGroup/TagGroup.module.css deleted file mode 100644 index 3328cb514..000000000 --- a/packages/components/src/components/SelectNext/components/TagGroup/TagGroup.module.css +++ /dev/null @@ -1,46 +0,0 @@ -.container { - display: flex; - inline-size: calc(100% + var(--kbq-size-s)); - margin-inline-start: calc(-1 * var(--kbq-size-s)); -} - -.hasStartAddon { - inline-size: 100%; - margin-inline-start: unset; -} - -.base { - inline-size: 100%; - display: flex; - flex-wrap: wrap; - overflow: hidden; - gap: var(--kbq-size-xxs); - padding-block: var(--kbq-size-xxs); -} - -.base[data-limit-tags='responsive'] { - gap: unset; - flex-wrap: nowrap; - - .tag { - margin-inline-end: var(--kbq-size-xxs); - } -} - -.more { - text-align: end; - align-self: center; - white-space: nowrap; - box-sizing: border-box; - flex: 0 0 60px; - padding: var(--kbq-size-3xs) calc(var(--kbq-size-xs) + var(--kbq-size-xxs)) - var(--kbq-size-3xs) var(--kbq-size-xxs); -} - -:is(.tag, .more) { - &[aria-hidden='true'] { - inset-inline-start: -300vw; - visibility: hidden; - position: absolute; - } -} diff --git a/packages/components/src/components/SelectNext/components/TagGroup/TagGroup.tsx b/packages/components/src/components/SelectNext/components/TagGroup/TagGroup.tsx deleted file mode 100644 index 3a4a1ead4..000000000 --- a/packages/components/src/components/SelectNext/components/TagGroup/TagGroup.tsx +++ /dev/null @@ -1,43 +0,0 @@ -'use client'; - -import { logger } from '@koobiq/logger'; -import type { SelectState } from '@koobiq/react-primitives'; -import type { SelectionMode } from '@react-types/select'; - -import type { SelectNextPropSelectedTagsOverflow } from '../../types'; - -import { TagGroupMultiline } from './TagGroupMultiline'; -import { TagGroupResponsive } from './TagGroupResponsive'; - -export type TagGroupProps< - T extends object, - M extends SelectionMode = 'single', -> = { - state: SelectState; - states: { - isInvalid?: boolean; - isDisabled?: boolean; - isRequired?: boolean; - }; - selectedTagsOverflow?: SelectNextPropSelectedTagsOverflow; -}; - -function assertNever(x: never) { - logger.error(`Unhandled selectedTagsOverflow variant: ${x as string}`); - - return null; -} - -export function TagGroup({ - selectedTagsOverflow = 'responsive', - ...rest -}: TagGroupProps) { - switch (selectedTagsOverflow) { - case 'responsive': - return ; - case 'multiline': - return ; - default: - return assertNever(selectedTagsOverflow as never); - } -} diff --git a/packages/components/src/components/SelectNext/components/TagGroup/TagGroupMultiline.tsx b/packages/components/src/components/SelectNext/components/TagGroup/TagGroupMultiline.tsx deleted file mode 100644 index 267bb8a8e..000000000 --- a/packages/components/src/components/SelectNext/components/TagGroup/TagGroupMultiline.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { clsx, useLocalizedStringFormatter } from '@koobiq/react-core'; -import type { SelectionMode } from '@react-types/select'; - -import { useFormFieldControlGroup } from '../../../FormField'; -import intlMessages from '../../intl'; -import { Tag } from '../Tag'; - -import type { TagGroupProps } from './TagGroup'; -import s from './TagGroup.module.css'; - -export function TagGroupMultiline< - T extends object, - M extends SelectionMode = 'single', ->({ state, states }: TagGroupProps) { - const { isDisabled, isInvalid } = states; - const t = useLocalizedStringFormatter(intlMessages); - - const { hasStartAddon } = useFormFieldControlGroup(); - - return ( -
-
- {state.selectedItems?.map((item) => ( - { - if (state.selectionManager.isSelected(item.key)) { - state.selectionManager.toggleSelection(item.key); - } - }} - > - {item.textValue} - - ))} -
-
- ); -} diff --git a/packages/components/src/components/SelectNext/components/TagGroup/TagGroupResponsive.tsx b/packages/components/src/components/SelectNext/components/TagGroup/TagGroupResponsive.tsx deleted file mode 100644 index 391460e03..000000000 --- a/packages/components/src/components/SelectNext/components/TagGroup/TagGroupResponsive.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { - clsx, - useHideOverflowItems, - useLocalizedStringFormatter, -} from '@koobiq/react-core'; -import type { SelectionMode } from '@react-types/select'; - -import { useFormFieldControlGroup } from '../../../FormField'; -import intlMessages from '../../intl'; -import { Tag } from '../Tag'; - -import type { TagGroupProps } from './TagGroup'; -import s from './TagGroup.module.css'; -import { getHiddenCount } from './utils'; - -export function TagGroupResponsive< - T extends object, - M extends SelectionMode = 'single', ->({ state, states }: TagGroupProps) { - const { isDisabled, isInvalid } = states; - const length = state?.selectedItems?.length || 0; - - const { parentRef, visibleMap, itemsRefs } = useHideOverflowItems({ - length: length + 1, - pinnedIndex: 0, - }); - - const hiddenCount = getHiddenCount(visibleMap); - const t = useLocalizedStringFormatter(intlMessages); - - const { hasStartAddon } = useFormFieldControlGroup(); - - return ( -
-
- {state.selectedItems?.map((item, i) => ( - { - if (state.selectionManager.isSelected(item.key)) { - state.selectionManager.toggleSelection(item.key); - } - }} - > - {item.textValue} - - ))} -
-
- {t.format('more', { count: hiddenCount })} -
-
- ); -} diff --git a/packages/components/src/components/SelectNext/components/TagGroup/index.ts b/packages/components/src/components/SelectNext/components/TagGroup/index.ts deleted file mode 100644 index 4aac8bce4..000000000 --- a/packages/components/src/components/SelectNext/components/TagGroup/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './TagGroup'; diff --git a/packages/components/src/components/SelectNext/components/TagGroup/utils.ts b/packages/components/src/components/SelectNext/components/TagGroup/utils.ts deleted file mode 100644 index 088c29de4..000000000 --- a/packages/components/src/components/SelectNext/components/TagGroup/utils.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const getHiddenCount = (map: boolean[]) => - map.filter((item) => !item).length; diff --git a/packages/components/src/components/SelectNext/components/index.ts b/packages/components/src/components/SelectNext/components/index.ts index b1d9be95a..f2c1c01cc 100644 --- a/packages/components/src/components/SelectNext/components/index.ts +++ b/packages/components/src/components/SelectNext/components/index.ts @@ -1,5 +1,3 @@ export * from './SelectList'; -export * from './Tag'; export * from './SelectOption'; export * from './SelectSection'; -export * from './TagGroup'; diff --git a/packages/components/src/components/SelectNext/types.ts b/packages/components/src/components/SelectNext/types.ts index 8c306ce33..79f4c01e2 100644 --- a/packages/components/src/components/SelectNext/types.ts +++ b/packages/components/src/components/SelectNext/types.ts @@ -6,7 +6,7 @@ import type { Ref, } from 'react'; -import type { ExtendableProps } from '@koobiq/react-core'; +import type { ExtendableProps, Node } from '@koobiq/react-core'; import type { AriaSelectProps, SelectState } from '@koobiq/react-primitives'; import type { SelectionMode } from '@react-types/select'; @@ -27,6 +27,7 @@ import { import type { IconButtonProps } from '../IconButton'; import type { PopoverProps } from '../Popover'; import type { SearchInputProps } from '../SearchInput'; +import type { SelectedTagsRenderTagProps as SelectNextRenderTagProps } from '../SelectedTags'; import type { SelectListProps } from './components'; @@ -44,6 +45,9 @@ export type SelectNextPropLabelPlacement = FormFieldPropLabelPlacement; export const selectNextPropLabelAlign = formFieldPropLabelAlign; export type SelectNextPropLabelAlign = FormFieldPropLabelAlign; +export type { SelectNextRenderTagProps }; +export type { SelectedTagProps as SelectNextTagProps } from '../SelectedTags'; + export type SelectNextProps< T extends object, M extends SelectionMode = 'single', @@ -57,6 +61,15 @@ export type SelectNextProps< * @default 'responsive' */ selectedTagsOverflow?: SelectNextPropSelectedTagsOverflow; + /** + * Custom renderer for a selected tag in `selectionMode="multiple"`. + * Spread `tagProps` onto your root element to preserve overflow collapse and spacing. + * Ignored when `renderValue` is provided. Has no effect in single-selection mode. + */ + renderTag?: ( + item: Node, + tagProps: SelectNextRenderTagProps + ) => ReactNode; /** Handler that is called when the clear button is clicked. */ onClear?: () => void; /** Sets the CSS [`className`](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element. */ diff --git a/packages/components/src/components/SelectedTags/SelectedTag/SelectedTag.tsx b/packages/components/src/components/SelectedTags/SelectedTag/SelectedTag.tsx new file mode 100644 index 000000000..dd57f1c40 --- /dev/null +++ b/packages/components/src/components/SelectedTags/SelectedTag/SelectedTag.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { + type CSSProperties, + type ReactNode, + forwardRef, + type ComponentRef, +} from 'react'; + +import type { ExtendableComponentPropsWithRef } from '@koobiq/react-core'; +import { + clsx, + mergeProps, + isNotNil, + useLocalizedStringFormatter, +} from '@koobiq/react-core'; +import { IconXmarkS16 } from '@koobiq/react-icons'; + +import { utilClasses } from '../../../styles/utility'; +import { IconButton } from '../../IconButton'; +import type { TagGroupPropVariant } from '../../TagGroup'; +import s from '../../TagGroup/components/Tag/Tag.module.css'; + +import intlMessages from './intl.json'; +import { matchVariantToCloseButton } from './utils'; + +export type SelectedTagProps = ExtendableComponentPropsWithRef< + { + /** + * The variant to use. + * @default 'theme-fade' + */ + variant?: TagGroupPropVariant; + className?: string; + style?: CSSProperties; + children?: ReactNode; + icon?: ReactNode; + isDisabled?: boolean; + isReadOnly?: boolean; + onRemove?: () => void; + }, + 'div' +>; + +const textNormalMedium = utilClasses.typography['text-normal-medium']; + +export const SelectedTag = forwardRef, SelectedTagProps>( + (props, ref) => { + const { + variant = 'theme-fade', + icon, + className, + style, + isDisabled, + isReadOnly, + children, + onRemove, + ...other + } = props; + + const stringFormatter = useLocalizedStringFormatter(intlMessages); + + const rootProps = mergeProps({ + className: clsx( + s.base, + s[variant], + isDisabled && s.disabled, + textNormalMedium, + className + ), + ...other, + style, + }); + + const removeButtonProps = { + isCompact: true, + isDisabled, + className: s.cancelIcon, + variant: matchVariantToCloseButton[variant], + 'aria-label': stringFormatter.format('remove'), + }; + + const contentProps = mergeProps({ + className: s.content, + }); + + const iconProps = mergeProps({ + className: s.icon, + }); + + return ( +
+ {isNotNil(icon) && {icon}} + {isNotNil(children) && {children}} + {!isReadOnly && ( + + + + )} +
+ ); + } +); + +SelectedTag.displayName = 'SelectedTag'; diff --git a/packages/components/src/components/SelectedTags/SelectedTag/index.ts b/packages/components/src/components/SelectedTags/SelectedTag/index.ts new file mode 100644 index 000000000..44980064b --- /dev/null +++ b/packages/components/src/components/SelectedTags/SelectedTag/index.ts @@ -0,0 +1 @@ +export * from './SelectedTag'; diff --git a/packages/components/src/components/SelectNext/components/Tag/intl.json b/packages/components/src/components/SelectedTags/SelectedTag/intl.json similarity index 100% rename from packages/components/src/components/SelectNext/components/Tag/intl.json rename to packages/components/src/components/SelectedTags/SelectedTag/intl.json diff --git a/packages/components/src/components/SelectedTags/Tag/utils.ts b/packages/components/src/components/SelectedTags/SelectedTag/utils.ts similarity index 100% rename from packages/components/src/components/SelectedTags/Tag/utils.ts rename to packages/components/src/components/SelectedTags/SelectedTag/utils.ts diff --git a/packages/components/src/components/SelectedTags/SelectedTagsMultiline.tsx b/packages/components/src/components/SelectedTags/SelectedTagsMultiline.tsx index 71acc2364..7ee505c31 100644 --- a/packages/components/src/components/SelectedTags/SelectedTagsMultiline.tsx +++ b/packages/components/src/components/SelectedTags/SelectedTagsMultiline.tsx @@ -1,15 +1,18 @@ +import { Fragment } from 'react'; + import { clsx, useLocalizedStringFormatter } from '@koobiq/react-core'; import { useFormFieldControlGroup } from '../FormField'; import intlMessages from './intl'; +import { SelectedTag } from './SelectedTag'; import s from './SelectedTags.module.css'; -import { Tag } from './Tag'; -import type { SelectedTagsProps } from './types'; +import type { SelectedTagsProps, SelectedTagsRenderTagProps } from './types'; export function SelectedTagsMultiline({ state, states, + renderTag, }: SelectedTagsProps) { const { isDisabled, isInvalid, isReadOnly } = states; const t = useLocalizedStringFormatter(intlMessages); @@ -26,22 +29,29 @@ export function SelectedTagsMultiline({ data-limit-tags="multiline" aria-label={t.format('selected items')} > - {state.selectedItems?.map((item) => ( - { + {state.selectedItems?.map((item) => { + const tagProps: SelectedTagsRenderTagProps = { + className: s.tag, + isDisabled, + isReadOnly, + variant: isInvalid ? 'error-fade' : 'contrast-fade', + onRemove: () => { if (state.selectionManager.isSelected(item.key)) { state.selectionManager.toggleSelection(item.key); } - }} - > - {item.textValue} - - ))} + }, + }; + + return ( + + {renderTag ? ( + renderTag(item, tagProps) + ) : ( + {item.textValue} + )} + + ); + })} ); diff --git a/packages/components/src/components/SelectedTags/SelectedTagsResponsive.tsx b/packages/components/src/components/SelectedTags/SelectedTagsResponsive.tsx index 30c25bb33..f3c971e37 100644 --- a/packages/components/src/components/SelectedTags/SelectedTagsResponsive.tsx +++ b/packages/components/src/components/SelectedTags/SelectedTagsResponsive.tsx @@ -1,3 +1,5 @@ +import { Fragment } from 'react'; + import { clsx, useHideOverflowItems, @@ -7,14 +9,15 @@ import { import { useFormFieldControlGroup } from '../FormField'; import intlMessages from './intl'; +import { SelectedTag } from './SelectedTag'; import s from './SelectedTags.module.css'; -import { Tag } from './Tag'; -import type { SelectedTagsProps } from './types'; +import type { SelectedTagsProps, SelectedTagsRenderTagProps } from './types'; import { getHiddenCount } from './utils'; export function SelectedTagsResponsive({ state, states, + renderTag, }: SelectedTagsProps) { const { isDisabled, isInvalid, isReadOnly } = states; const length = state?.selectedItems?.length || 0; @@ -40,24 +43,31 @@ export function SelectedTagsResponsive({ data-limit-tags="responsive" aria-label={t.format('selected items')} > - {state.selectedItems?.map((item, i) => ( - { + {state.selectedItems?.map((item, i) => { + const tagProps: SelectedTagsRenderTagProps = { + ref: itemsRefs[i], + className: s.tag, + isDisabled, + isReadOnly, + 'aria-hidden': !visibleMap[i] || undefined, + variant: isInvalid ? 'error-fade' : 'contrast-fade', + onRemove: () => { if (state.selectionManager.isSelected(item.key)) { state.selectionManager.toggleSelection(item.key); } - }} - > - {item.textValue} - - ))} + }, + }; + + return ( + + {renderTag ? ( + renderTag(item, tagProps) + ) : ( + {item.textValue} + )} + + ); + })}
void; - }, - 'div' ->; - -const textNormalMedium = utilClasses.typography['text-normal-medium']; - -export const Tag = forwardRef, TagProps>((props, ref) => { - const { - variant = 'theme-fade', - icon, - className, - style, - isDisabled, - isReadOnly, - children, - onRemove, - ...other - } = props; - - const stringFormatter = useLocalizedStringFormatter(intlMessages); - - const rootProps = mergeProps({ - className: clsx( - s.base, - s[variant], - isDisabled && s.disabled, - textNormalMedium, - className - ), - ...other, - style, - }); - - const removeButtonProps = { - isCompact: true, - isDisabled, - className: s.cancelIcon, - variant: matchVariantToCloseButton[variant], - 'aria-label': stringFormatter.format('remove'), - }; - - const contentProps = mergeProps({ - className: s.content, - }); - - const iconProps = mergeProps({ - className: s.icon, - }); - - return ( -
- {isNotNil(icon) && {icon}} - {isNotNil(children) && {children}} - {!isReadOnly && ( - - - - )} -
- ); -}); - -Tag.displayName = 'SelectTag'; diff --git a/packages/components/src/components/SelectedTags/Tag/index.ts b/packages/components/src/components/SelectedTags/Tag/index.ts deleted file mode 100644 index 9790fcbf1..000000000 --- a/packages/components/src/components/SelectedTags/Tag/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Tag'; diff --git a/packages/components/src/components/SelectedTags/Tag/intl.json b/packages/components/src/components/SelectedTags/Tag/intl.json deleted file mode 100644 index 1678b8c85..000000000 --- a/packages/components/src/components/SelectedTags/Tag/intl.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "ru-RU": { - "remove": "Удалить" - }, - "en-US": { - "remove": "Remove" - } -} diff --git a/packages/components/src/components/SelectedTags/index.ts b/packages/components/src/components/SelectedTags/index.ts index b2c9c9d02..3ee9db8bc 100644 --- a/packages/components/src/components/SelectedTags/index.ts +++ b/packages/components/src/components/SelectedTags/index.ts @@ -1,2 +1,3 @@ export * from './SelectedTags'; +export * from './SelectedTag'; export * from './types'; diff --git a/packages/components/src/components/SelectedTags/types.ts b/packages/components/src/components/SelectedTags/types.ts index bdccb036e..f2658dc95 100644 --- a/packages/components/src/components/SelectedTags/types.ts +++ b/packages/components/src/components/SelectedTags/types.ts @@ -1,5 +1,9 @@ +import type { ComponentRef, ReactNode, Ref } from 'react'; + import type { Key, Node } from '@koobiq/react-core'; +import type { TagGroupPropVariant } from '../TagGroup'; + export const selectedTagsPropOverflow = ['multiline', 'responsive'] as const; export type SelectedTagsPropOverflow = @@ -14,6 +18,17 @@ export interface SelectedTagsSelectionState { }; } +/** Props to spread onto a custom tag's root element; returned by the `renderTag` callback. */ +export type SelectedTagsRenderTagProps = { + ref?: Ref>; + className?: string; + isDisabled?: boolean; + isReadOnly?: boolean; + 'aria-hidden'?: true; + variant: TagGroupPropVariant; + onRemove: () => void; +}; + export type SelectedTagsProps = { state: SelectedTagsSelectionState; states: { @@ -23,4 +38,8 @@ export type SelectedTagsProps = { isRequired?: boolean; }; selectedTagsOverflow?: SelectedTagsPropOverflow; + renderTag?: ( + item: Node, + tagProps: SelectedTagsRenderTagProps + ) => ReactNode; }; diff --git a/packages/components/src/components/TreeSelect/TreeSelect.mdx b/packages/components/src/components/TreeSelect/TreeSelect.mdx index 22d86520b..f41d3c6b2 100644 --- a/packages/components/src/components/TreeSelect/TreeSelect.mdx +++ b/packages/components/src/components/TreeSelect/TreeSelect.mdx @@ -1,4 +1,5 @@ import { + Alert, Meta, Story, Props, @@ -115,6 +116,12 @@ or `selectedTagsOverflow="multiline"` to wrap tags onto multiple lines. +### Custom Tag Render + +Allows for custom rendering of tags. + + + ### Disabled When TreeSelect is disabled, it cannot be interacted with. diff --git a/packages/components/src/components/TreeSelect/TreeSelect.stories.tsx b/packages/components/src/components/TreeSelect/TreeSelect.stories.tsx index 0025288c2..0f7bfdbfa 100644 --- a/packages/components/src/components/TreeSelect/TreeSelect.stories.tsx +++ b/packages/components/src/components/TreeSelect/TreeSelect.stories.tsx @@ -25,7 +25,7 @@ const meta = { 'TreeSelect.LoadMoreItem': TreeSelect.LoadMoreItem, }, argTypes: {}, - tags: ['status:new', 'date:2026-06-26'], + tags: ['status:new', 'date:2026-07-02'], } satisfies Meta; export default meta; @@ -451,6 +451,41 @@ export const SelectedTagsOverflow: Story = { }, }; +export const CustomTagRender: Story = { + render: function Render() { + const defaultValue = [1, 6]; + + return ( + ( + } + > + {item.textValue} + + )} + > + {function renderItem(item) { + return ( + + {item.title} + {renderItem} + + ); + }} + + ); + }, +}; + export const Disabled: Story = { render: function Render() { return ( diff --git a/packages/components/src/components/TreeSelect/TreeSelect.test.tsx b/packages/components/src/components/TreeSelect/TreeSelect.test.tsx index 58569af8b..1e390a75f 100644 --- a/packages/components/src/components/TreeSelect/TreeSelect.test.tsx +++ b/packages/components/src/components/TreeSelect/TreeSelect.test.tsx @@ -341,6 +341,49 @@ describe('TreeSelect', () => { ).toBeInTheDocument(); }); + it('should use renderTag to customize selected tags in multiple mode', () => { + renderTreeSelect({ + selectionMode: 'multiple', + defaultValue: [1, 7], + renderTag: (item, tagProps) => ( +
+ {item.textValue} +
+ ), + }); + + const control = getControl(); + + expect(within(control).getByTestId('custom-tag-1')).toBeInTheDocument(); + expect(within(control).getByTestId('custom-tag-7')).toBeInTheDocument(); + }); + + it('should render TreeSelect.Tag inside renderTag and support removing via its button', async () => { + const onChange = vi.fn(); + + renderTreeSelect({ + selectionMode: 'multiple', + defaultValue: [1, 7], + onChange, + renderTag: (item, tagProps) => ( + + {item.textValue} + + ), + }); + + expect(screen.getByTestId('tag-1')).toHaveTextContent('app'); + expect(screen.getByTestId('tag-7')).toHaveTextContent('README.md'); + + await userEvent.click( + within(screen.getByTestId('tag-7')).getByRole('button', { + hidden: true, + }) + ); + + expect(onChange).toHaveBeenCalledWith([1]); + }); + it('should call onChange with keys in multiple mode', async () => { const onChange = vi.fn(); diff --git a/packages/components/src/components/TreeSelect/TreeSelect.tsx b/packages/components/src/components/TreeSelect/TreeSelect.tsx index e116288e5..6e0c4531d 100644 --- a/packages/components/src/components/TreeSelect/TreeSelect.tsx +++ b/packages/components/src/components/TreeSelect/TreeSelect.tsx @@ -50,7 +50,7 @@ import { import type { PopoverInnerProps, PopoverProps } from '../Popover'; import { PopoverInner } from '../Popover/PopoverInner'; import { SearchInput, type SearchInputProps } from '../SearchInput'; -import { SelectedTags } from '../SelectedTags'; +import { SelectedTags, SelectedTag } from '../SelectedTags'; import { Tree } from '../Tree'; import intlMessages from './intl'; @@ -77,6 +77,7 @@ export function TreeSelectInner< >({ props, collection, controlRef }: TreeSelectInnerProps) { const { selectedTagsOverflow = 'responsive', + renderTag, defaultInputValue = '', labelAlign, placeholder, @@ -401,6 +402,7 @@ export function TreeSelectInner< isReadOnly, isRequired, }} + renderTag={renderTag} selectedTagsOverflow={selectedTagsOverflow} /> ) : ( @@ -471,6 +473,7 @@ type CompoundedComponent = typeof TreeSelectComponent & { Item: typeof Tree.Item; ItemContent: typeof Tree.ItemContent; LoadMoreItem: typeof Tree.LoadMoreItem; + Tag: typeof SelectedTag; }; /** Select with hierarchical tree data. */ @@ -479,3 +482,4 @@ export const TreeSelect = TreeSelectComponent as CompoundedComponent; TreeSelect.Item = Tree.Item; TreeSelect.ItemContent = Tree.ItemContent; TreeSelect.LoadMoreItem = Tree.LoadMoreItem; +TreeSelect.Tag = SelectedTag; diff --git a/packages/components/src/components/TreeSelect/types.ts b/packages/components/src/components/TreeSelect/types.ts index 4c55e1110..00d69dfdb 100644 --- a/packages/components/src/components/TreeSelect/types.ts +++ b/packages/components/src/components/TreeSelect/types.ts @@ -6,7 +6,7 @@ import type { Ref, } from 'react'; -import type { DataAttributeProps, RefObject } from '@koobiq/react-core'; +import type { DataAttributeProps, Node, RefObject } from '@koobiq/react-core'; import type { SelectionMode, TreeProps as AriaTreeProps, @@ -32,6 +32,7 @@ import type { IconButtonProps } from '../IconButton'; import type { PopoverProps } from '../Popover'; import type { SearchInputProps } from '../SearchInput'; import { selectedTagsPropOverflow } from '../SelectedTags'; +import type { SelectedTagsRenderTagProps as TreeSelectRenderTagProps } from '../SelectedTags'; import type { TreeCollection } from './TreeInner'; @@ -48,6 +49,9 @@ export const treeSelectPropSelectedTagsOverflow = selectedTagsPropOverflow; export type TreeSelectPropSelectedTagsOverflow = (typeof treeSelectPropSelectedTagsOverflow)[number]; +export type { TreeSelectRenderTagProps }; +export type { SelectedTagProps as TreeSelectTagProps } from '../SelectedTags'; + type AriaTreeSelectProps< T extends object, M extends SelectionMode = 'single', @@ -59,6 +63,12 @@ export type TreeSelectProps< > = { /** Defines how selected tags are displayed when they exceed the available space. */ selectedTagsOverflow?: TreeSelectPropSelectedTagsOverflow; + /** + * Custom renderer for a selected tag in `selectionMode="multiple"`. + * Spread `tagProps` onto your root element to preserve overflow collapse and spacing. + * Has no effect in single-selection mode. + */ + renderTag?: (item: Node, tagProps: TreeSelectRenderTagProps) => ReactNode; /** Whether the field can be emptied. */ isClearable?: boolean; /** Handler called when the clear button is clicked. */ From 083cee2eaa2815da4c0bf7b0cf2c885cfcdd82a7 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 3 Jul 2026 15:31:05 +0300 Subject: [PATCH 2/6] refactor(SelectedTags): reuse base Tag in selected values --- .../src/components/SelectNext/Select.tsx | 7 +- .../src/components/SelectNext/types.ts | 3 +- .../SelectedTags/SelectedTag/SelectedTag.tsx | 111 ---------- .../SelectedTags/SelectedTag/index.ts | 1 - .../SelectedTags/SelectedTag/utils.ts | 12 -- .../SelectedTags/SelectedTagsMultiline.tsx | 26 ++- .../SelectedTags/SelectedTagsResponsive.tsx | 26 ++- .../src/components/SelectedTags/index.ts | 1 - .../src/components/SelectedTags/types.ts | 13 +- .../src/components/Tag/Tag.module.css | 204 ++++++++++++++++++ .../components/src/components/Tag/Tag.tsx | 78 +++++++ .../components/src/components/Tag/index.ts | 3 + .../SelectedTag => Tag}/intl.json | 0 .../components/src/components/Tag/types.ts | 45 ++++ .../components/src/components/Tag/utils.ts | 13 ++ .../src/components/TreeSelect/TreeSelect.tsx | 7 +- .../src/components/TreeSelect/types.ts | 3 +- 17 files changed, 395 insertions(+), 158 deletions(-) delete mode 100644 packages/components/src/components/SelectedTags/SelectedTag/SelectedTag.tsx delete mode 100644 packages/components/src/components/SelectedTags/SelectedTag/index.ts delete mode 100644 packages/components/src/components/SelectedTags/SelectedTag/utils.ts create mode 100644 packages/components/src/components/Tag/Tag.module.css create mode 100644 packages/components/src/components/Tag/Tag.tsx create mode 100644 packages/components/src/components/Tag/index.ts rename packages/components/src/components/{SelectedTags/SelectedTag => Tag}/intl.json (100%) create mode 100644 packages/components/src/components/Tag/types.ts create mode 100644 packages/components/src/components/Tag/utils.ts diff --git a/packages/components/src/components/SelectNext/Select.tsx b/packages/components/src/components/SelectNext/Select.tsx index 479f51ebc..aac32fe9d 100644 --- a/packages/components/src/components/SelectNext/Select.tsx +++ b/packages/components/src/components/SelectNext/Select.tsx @@ -40,7 +40,8 @@ import { List } from '../List'; import type { ListItemAddon } from '../List/components'; import type { PopoverInnerProps, PopoverProps } from '../Popover'; import { PopoverInner } from '../Popover/PopoverInner'; -import { SelectedTags, SelectedTag } from '../SelectedTags'; +import { SelectedTags } from '../SelectedTags'; +import { Tag } from '../Tag'; import { SelectList, @@ -396,7 +397,7 @@ type CompoundedComponent = typeof SelectComponent & { Divider: typeof Divider; ItemText: typeof ListItemText; ItemAddon: typeof ListItemAddon; - Tag: typeof SelectedTag; + Tag: typeof Tag; }; /** @@ -410,4 +411,4 @@ SelectNext.Section = SelectSection; SelectNext.Divider = Divider; SelectNext.ItemText = List.ItemText; SelectNext.ItemAddon = List.ItemAddon; -SelectNext.Tag = SelectedTag; +SelectNext.Tag = Tag; diff --git a/packages/components/src/components/SelectNext/types.ts b/packages/components/src/components/SelectNext/types.ts index 79f4c01e2..795f06335 100644 --- a/packages/components/src/components/SelectNext/types.ts +++ b/packages/components/src/components/SelectNext/types.ts @@ -28,6 +28,7 @@ import type { IconButtonProps } from '../IconButton'; import type { PopoverProps } from '../Popover'; import type { SearchInputProps } from '../SearchInput'; import type { SelectedTagsRenderTagProps as SelectNextRenderTagProps } from '../SelectedTags'; +import type { TagProps } from '../Tag'; import type { SelectListProps } from './components'; @@ -46,7 +47,7 @@ export const selectNextPropLabelAlign = formFieldPropLabelAlign; export type SelectNextPropLabelAlign = FormFieldPropLabelAlign; export type { SelectNextRenderTagProps }; -export type { SelectedTagProps as SelectNextTagProps } from '../SelectedTags'; +export type SelectNextTagProps = TagProps; export type SelectNextProps< T extends object, diff --git a/packages/components/src/components/SelectedTags/SelectedTag/SelectedTag.tsx b/packages/components/src/components/SelectedTags/SelectedTag/SelectedTag.tsx deleted file mode 100644 index dd57f1c40..000000000 --- a/packages/components/src/components/SelectedTags/SelectedTag/SelectedTag.tsx +++ /dev/null @@ -1,111 +0,0 @@ -'use client'; - -import { - type CSSProperties, - type ReactNode, - forwardRef, - type ComponentRef, -} from 'react'; - -import type { ExtendableComponentPropsWithRef } from '@koobiq/react-core'; -import { - clsx, - mergeProps, - isNotNil, - useLocalizedStringFormatter, -} from '@koobiq/react-core'; -import { IconXmarkS16 } from '@koobiq/react-icons'; - -import { utilClasses } from '../../../styles/utility'; -import { IconButton } from '../../IconButton'; -import type { TagGroupPropVariant } from '../../TagGroup'; -import s from '../../TagGroup/components/Tag/Tag.module.css'; - -import intlMessages from './intl.json'; -import { matchVariantToCloseButton } from './utils'; - -export type SelectedTagProps = ExtendableComponentPropsWithRef< - { - /** - * The variant to use. - * @default 'theme-fade' - */ - variant?: TagGroupPropVariant; - className?: string; - style?: CSSProperties; - children?: ReactNode; - icon?: ReactNode; - isDisabled?: boolean; - isReadOnly?: boolean; - onRemove?: () => void; - }, - 'div' ->; - -const textNormalMedium = utilClasses.typography['text-normal-medium']; - -export const SelectedTag = forwardRef, SelectedTagProps>( - (props, ref) => { - const { - variant = 'theme-fade', - icon, - className, - style, - isDisabled, - isReadOnly, - children, - onRemove, - ...other - } = props; - - const stringFormatter = useLocalizedStringFormatter(intlMessages); - - const rootProps = mergeProps({ - className: clsx( - s.base, - s[variant], - isDisabled && s.disabled, - textNormalMedium, - className - ), - ...other, - style, - }); - - const removeButtonProps = { - isCompact: true, - isDisabled, - className: s.cancelIcon, - variant: matchVariantToCloseButton[variant], - 'aria-label': stringFormatter.format('remove'), - }; - - const contentProps = mergeProps({ - className: s.content, - }); - - const iconProps = mergeProps({ - className: s.icon, - }); - - return ( -
- {isNotNil(icon) && {icon}} - {isNotNil(children) && {children}} - {!isReadOnly && ( - - - - )} -
- ); - } -); - -SelectedTag.displayName = 'SelectedTag'; diff --git a/packages/components/src/components/SelectedTags/SelectedTag/index.ts b/packages/components/src/components/SelectedTags/SelectedTag/index.ts deleted file mode 100644 index 44980064b..000000000 --- a/packages/components/src/components/SelectedTags/SelectedTag/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './SelectedTag'; diff --git a/packages/components/src/components/SelectedTags/SelectedTag/utils.ts b/packages/components/src/components/SelectedTags/SelectedTag/utils.ts deleted file mode 100644 index 605a0eca3..000000000 --- a/packages/components/src/components/SelectedTags/SelectedTag/utils.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { IconButtonPropVariant } from '../../IconButton'; -import type { TagGroupPropVariant } from '../../TagGroup'; - -export const matchVariantToCloseButton: Record< - TagGroupPropVariant, - IconButtonPropVariant -> = { - 'theme-fade': 'theme', - 'contrast-fade': 'fade-contrast', - 'error-fade': 'error', - 'warning-fade': 'warning', -}; diff --git a/packages/components/src/components/SelectedTags/SelectedTagsMultiline.tsx b/packages/components/src/components/SelectedTags/SelectedTagsMultiline.tsx index 7ee505c31..7b46c25f7 100644 --- a/packages/components/src/components/SelectedTags/SelectedTagsMultiline.tsx +++ b/packages/components/src/components/SelectedTags/SelectedTagsMultiline.tsx @@ -3,9 +3,9 @@ import { Fragment } from 'react'; import { clsx, useLocalizedStringFormatter } from '@koobiq/react-core'; import { useFormFieldControlGroup } from '../FormField'; +import { Tag } from '../Tag'; import intlMessages from './intl'; -import { SelectedTag } from './SelectedTag'; import s from './SelectedTags.module.css'; import type { SelectedTagsProps, SelectedTagsRenderTagProps } from './types'; @@ -30,16 +30,26 @@ export function SelectedTagsMultiline({ aria-label={t.format('selected items')} > {state.selectedItems?.map((item) => { + const onRemove = () => { + if (state.selectionManager.isSelected(item.key)) { + state.selectionManager.toggleSelection(item.key); + } + }; + const tagProps: SelectedTagsRenderTagProps = { className: s.tag, isDisabled, - isReadOnly, variant: isInvalid ? 'error-fade' : 'contrast-fade', - onRemove: () => { - if (state.selectionManager.isSelected(item.key)) { - state.selectionManager.toggleSelection(item.key); - } - }, + slotProps: !isReadOnly + ? { + removeIcon: { + as: 'div', + isDisabled, + tabIndex: undefined, + onPress: onRemove, + }, + } + : undefined, }; return ( @@ -47,7 +57,7 @@ export function SelectedTagsMultiline({ {renderTag ? ( renderTag(item, tagProps) ) : ( - {item.textValue} + {item.textValue} )} ); diff --git a/packages/components/src/components/SelectedTags/SelectedTagsResponsive.tsx b/packages/components/src/components/SelectedTags/SelectedTagsResponsive.tsx index f3c971e37..320318598 100644 --- a/packages/components/src/components/SelectedTags/SelectedTagsResponsive.tsx +++ b/packages/components/src/components/SelectedTags/SelectedTagsResponsive.tsx @@ -7,9 +7,9 @@ import { } from '@koobiq/react-core'; import { useFormFieldControlGroup } from '../FormField'; +import { Tag } from '../Tag'; import intlMessages from './intl'; -import { SelectedTag } from './SelectedTag'; import s from './SelectedTags.module.css'; import type { SelectedTagsProps, SelectedTagsRenderTagProps } from './types'; import { getHiddenCount } from './utils'; @@ -44,18 +44,28 @@ export function SelectedTagsResponsive({ aria-label={t.format('selected items')} > {state.selectedItems?.map((item, i) => { + const onRemove = () => { + if (state.selectionManager.isSelected(item.key)) { + state.selectionManager.toggleSelection(item.key); + } + }; + const tagProps: SelectedTagsRenderTagProps = { ref: itemsRefs[i], className: s.tag, isDisabled, - isReadOnly, 'aria-hidden': !visibleMap[i] || undefined, variant: isInvalid ? 'error-fade' : 'contrast-fade', - onRemove: () => { - if (state.selectionManager.isSelected(item.key)) { - state.selectionManager.toggleSelection(item.key); - } - }, + slotProps: !isReadOnly + ? { + removeIcon: { + as: 'div', + isDisabled, + tabIndex: undefined, + onPress: onRemove, + }, + } + : undefined, }; return ( @@ -63,7 +73,7 @@ export function SelectedTagsResponsive({ {renderTag ? ( renderTag(item, tagProps) ) : ( - {item.textValue} + {item.textValue} )} ); diff --git a/packages/components/src/components/SelectedTags/index.ts b/packages/components/src/components/SelectedTags/index.ts index 3ee9db8bc..b2c9c9d02 100644 --- a/packages/components/src/components/SelectedTags/index.ts +++ b/packages/components/src/components/SelectedTags/index.ts @@ -1,3 +1,2 @@ export * from './SelectedTags'; -export * from './SelectedTag'; export * from './types'; diff --git a/packages/components/src/components/SelectedTags/types.ts b/packages/components/src/components/SelectedTags/types.ts index f2658dc95..c8b9c846b 100644 --- a/packages/components/src/components/SelectedTags/types.ts +++ b/packages/components/src/components/SelectedTags/types.ts @@ -1,8 +1,8 @@ -import type { ComponentRef, ReactNode, Ref } from 'react'; +import type { ReactNode } from 'react'; import type { Key, Node } from '@koobiq/react-core'; -import type { TagGroupPropVariant } from '../TagGroup'; +import type { TagProps } from '../Tag'; export const selectedTagsPropOverflow = ['multiline', 'responsive'] as const; @@ -19,14 +19,9 @@ export interface SelectedTagsSelectionState { } /** Props to spread onto a custom tag's root element; returned by the `renderTag` callback. */ -export type SelectedTagsRenderTagProps = { - ref?: Ref>; - className?: string; - isDisabled?: boolean; - isReadOnly?: boolean; +export type SelectedTagsRenderTagProps = Omit & { 'aria-hidden'?: true; - variant: TagGroupPropVariant; - onRemove: () => void; + variant: NonNullable; }; export type SelectedTagsProps = { diff --git a/packages/components/src/components/Tag/Tag.module.css b/packages/components/src/components/Tag/Tag.module.css new file mode 100644 index 000000000..8a3cda359 --- /dev/null +++ b/packages/components/src/components/Tag/Tag.module.css @@ -0,0 +1,204 @@ +@import url('../../styles/mixins.css'); + +.base { + --tag-color: ; + --tag-bg-color: ; + --tag-icon-color: ; + --tag-outline-color: transparent; + --tag-outline-width: var(--kbq-size-3xs); + + border: none; + max-inline-size: 100%; + cursor: default; + align-items: center; + vertical-align: top; + display: inline-flex; + text-decoration: none; + box-sizing: border-box; + color: var(--kbq-tag-color, var(--tag-color)); + gap: var(--kbq-size-3xs); + block-size: var(--kbq-size-xxl); + border-radius: var(--kbq-size-xxs); + padding-inline: var(--kbq-size-xxs); + background-color: var(--kbq-tag-bg-color, var(--tag-bg-color)); + outline-offset: calc( + -1 * var(--kbq-tag-outline-width, var(--tag-outline-width)) / 2 + ); + outline: var(--kbq-tag-outline-width, var(--tag-outline-width)) solid + var(--kbq-tag-outline-color, var(--tag-outline-color)); + transition: + outline-color var(--kbq-transition-default), + box-shadow var(--kbq-transition-default), + background-color var(--kbq-transition-default), + color var(--kbq-transition-default); +} + +.body { + display: contents; +} + +.content { + @mixin ellipsis; + + max-inline-size: 100%; + margin-inline: var(--kbq-size-3xs); +} + +.icon { + display: flex; + flex: none; + align-items: center; + justify-content: center; + color: var(--kbq-tag-icon-color, var(--tag-icon-color)); + margin-inline-start: var(--kbq-size-3xs); +} + +.cancelIcon { + display: flex; + align-items: center; + justify-content: center; + margin-inline-end: var(--kbq-size-3xs); +} + +.theme-fade { + --tag-icon-color: var(--kbq-icon-theme); + --tag-bg-color: var(--kbq-background-theme-fade); + --tag-color: var(--kbq-foreground-theme); +} + +.contrast-fade { + --tag-icon-color: var(--kbq-icon-contrast-fade); + --tag-bg-color: var(--kbq-background-contrast-fade); + --tag-color: var(--kbq-foreground-contrast); +} + +.error-fade { + --tag-icon-color: var(--kbq-icon-error); + --tag-bg-color: var(--kbq-background-error-fade); + --tag-color: var(--kbq-foreground-error); +} + +.warning-fade { + --tag-icon-color: var(--kbq-icon-warning); + --tag-bg-color: var(--kbq-background-warning-fade); + --tag-color: var(--kbq-foreground-warning); +} + +/* hovered */ +.theme-fade:where([data-hovered]) { + --tag-bg-color: var(--kbq-states-background-theme-fade-hover); +} + +.contrast-fade:where([data-hovered]) { + --tag-bg-color: var(--kbq-states-background-contrast-fade-hover); +} + +.error-fade:where([data-hovered]) { + --tag-bg-color: var(--kbq-states-background-error-fade-hover); +} + +.warning-fade:where([data-hovered]) { + --tag-bg-color: var(--kbq-states-background-warning-fade-hover); +} + +/* selected */ +.theme-fade:where([data-selected]) { + --tag-bg-color: var(--kbq-background-theme); + --tag-color: var(--kbq-foreground-white); + --tag-icon-color: var(--kbq-icon-white); + + .cancelIcon { + --icon-button-color: var(--kbq-icon-white); + --icon-button-color-hover: var(--kbq-icon-white); + --icon-button-color-active: var(--kbq-icon-white); + } +} + +.contrast-fade:where([data-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([data-selected]) { + --tag-bg-color: var(--kbq-background-error); + --tag-color: var(--kbq-foreground-white); + --tag-icon-color: var(--kbq-icon-white); + + .cancelIcon { + --icon-button-color: var(--kbq-icon-white); + --icon-button-color-hover: var(--kbq-icon-white); + --icon-button-color-active: var(--kbq-icon-white); + } +} + +.warning-fade:where([data-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([data-selected][data-hovered]) { + --tag-bg-color: var(--kbq-states-background-theme-hover); + --tag-color: var(--kbq-foreground-white); +} + +.contrast-fade:where([data-selected][data-hovered]) { + --tag-bg-color: var(--kbq-states-background-theme-hover); + --tag-color: var(--kbq-foreground-white); +} + +.error-fade:where([data-selected][data-hovered]) { + --tag-bg-color: var(--kbq-states-background-error-hover); + --tag-color: var(--kbq-foreground-white); +} + +.warning-fade:where([data-selected][data-hovered]) { + --tag-bg-color: var(--kbq-background-warning); + --tag-color: var(--kbq-foreground-contrast); +} + +/* focus-visible */ +.theme-fade:where([data-focus-visible]) { + --tag-outline-color: var(--kbq-states-line-focus-theme); +} + +.contrast-fade:where([data-focus-visible]) { + --tag-outline-color: var(--kbq-states-line-focus-theme); +} + +.error-fade:where([data-focus-visible]) { + --tag-outline-color: var(--kbq-states-line-focus-error); +} + +.warning-fade:where([data-focus-visible]) { + --tag-outline-color: var(--kbq-states-line-focus-theme); +} + +/* focus-visible + selected */ +.base:where([data-focus-visible][data-selected]) { + box-shadow: inset 0 0 0 2px var(--kbq-background-bg); +} + +/* disabled */ +.base:where([data-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/Tag/Tag.tsx b/packages/components/src/components/Tag/Tag.tsx new file mode 100644 index 000000000..048264520 --- /dev/null +++ b/packages/components/src/components/Tag/Tag.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { forwardRef, type ComponentRef } from 'react'; + +import { + clsx, + isNotNil, + mergeProps, + useLocalizedStringFormatter, +} from '@koobiq/react-core'; +import { IconXmarkS16 } from '@koobiq/react-icons'; + +import { utilClasses } from '../../styles/utility'; +import { IconButton } from '../IconButton'; + +import intlMessages from './intl.json'; +import s from './Tag.module.css'; +import type { TagProps, TagRemoveButtonProps } from './types'; +import { matchTagVariantToIconButton } from './utils'; + +const textNormalMedium = utilClasses.typography['text-normal-medium']; + +export const Tag = forwardRef, TagProps>((props, ref) => { + const { + variant = 'theme-fade', + icon, + className, + style, + children, + slotProps, + isDisabled, + ...other + } = props; + + const t = useLocalizedStringFormatter(intlMessages); + + const rootProps = mergeProps(other, slotProps?.root, { + ref, + style, + className: clsx(s.base, s[variant], textNormalMedium, className), + 'data-variant': variant, + 'data-disabled': isDisabled || undefined, + }); + + const bodyProps = mergeProps({ className: s.body }, slotProps?.body); + + const contentProps = mergeProps({ className: s.content }, slotProps?.content); + + const iconProps = mergeProps({ className: s.icon }, slotProps?.icon); + + const removeButtonProps = isNotNil(slotProps?.removeIcon) + ? mergeProps<[TagRemoveButtonProps, TagRemoveButtonProps]>( + { + isCompact: true, + className: s.cancelIcon, + variant: matchTagVariantToIconButton[variant], + 'aria-label': t.format('remove'), + }, + slotProps.removeIcon + ) + : undefined; + + return ( +
+
+ {isNotNil(icon) && {icon}} + {isNotNil(children) && {children}} + {removeButtonProps && ( + + + + )} +
+
+ ); +}); + +Tag.displayName = 'Tag'; diff --git a/packages/components/src/components/Tag/index.ts b/packages/components/src/components/Tag/index.ts new file mode 100644 index 000000000..f9e40386c --- /dev/null +++ b/packages/components/src/components/Tag/index.ts @@ -0,0 +1,3 @@ +export * from './Tag'; +export * from './types'; +export * from './utils'; diff --git a/packages/components/src/components/SelectedTags/SelectedTag/intl.json b/packages/components/src/components/Tag/intl.json similarity index 100% rename from packages/components/src/components/SelectedTags/SelectedTag/intl.json rename to packages/components/src/components/Tag/intl.json diff --git a/packages/components/src/components/Tag/types.ts b/packages/components/src/components/Tag/types.ts new file mode 100644 index 000000000..8785a2e0f --- /dev/null +++ b/packages/components/src/components/Tag/types.ts @@ -0,0 +1,45 @@ +import type { + ComponentPropsWithRef, + CSSProperties, + ElementType, + ReactNode, +} from 'react'; + +import type { IconButtonProps } from '../IconButton'; + +export const tagPropVariant = [ + 'theme-fade', + 'contrast-fade', + 'error-fade', + 'warning-fade', +] as const; + +export type TagPropVariant = (typeof tagPropVariant)[number]; + +export type TagRemoveButtonProps = IconButtonProps; + +export type TagSlotProps = { + root?: ComponentPropsWithRef<'div'>; + body?: ComponentPropsWithRef<'div'>; + icon?: ComponentPropsWithRef<'span'>; + content?: ComponentPropsWithRef<'span'>; + removeIcon?: TagRemoveButtonProps; +}; + +export type TagProps = Omit, 'children'> & { + /** + * The variant to use. + * @default 'theme-fade' + */ + variant?: TagPropVariant; + /** Icon placed before the children. */ + icon?: ReactNode; + /** The props used for each slot inside. */ + slotProps?: TagSlotProps; + /** Whether the tag is disabled. */ + isDisabled?: boolean; + /** Inline styles. */ + style?: CSSProperties; + /** Rendered contents of the tag. */ + children?: ReactNode; +}; diff --git a/packages/components/src/components/Tag/utils.ts b/packages/components/src/components/Tag/utils.ts new file mode 100644 index 000000000..bde21c011 --- /dev/null +++ b/packages/components/src/components/Tag/utils.ts @@ -0,0 +1,13 @@ +import type { IconButtonPropVariant } from '../IconButton'; + +import type { TagPropVariant } from './types'; + +export const matchTagVariantToIconButton: Record< + TagPropVariant, + IconButtonPropVariant +> = { + 'theme-fade': 'theme', + 'contrast-fade': 'fade-contrast', + 'error-fade': 'error', + 'warning-fade': 'warning', +}; diff --git a/packages/components/src/components/TreeSelect/TreeSelect.tsx b/packages/components/src/components/TreeSelect/TreeSelect.tsx index 6e0c4531d..6a156e8fb 100644 --- a/packages/components/src/components/TreeSelect/TreeSelect.tsx +++ b/packages/components/src/components/TreeSelect/TreeSelect.tsx @@ -50,7 +50,8 @@ import { import type { PopoverInnerProps, PopoverProps } from '../Popover'; import { PopoverInner } from '../Popover/PopoverInner'; import { SearchInput, type SearchInputProps } from '../SearchInput'; -import { SelectedTags, SelectedTag } from '../SelectedTags'; +import { SelectedTags } from '../SelectedTags'; +import { Tag } from '../Tag'; import { Tree } from '../Tree'; import intlMessages from './intl'; @@ -473,7 +474,7 @@ type CompoundedComponent = typeof TreeSelectComponent & { Item: typeof Tree.Item; ItemContent: typeof Tree.ItemContent; LoadMoreItem: typeof Tree.LoadMoreItem; - Tag: typeof SelectedTag; + Tag: typeof Tag; }; /** Select with hierarchical tree data. */ @@ -482,4 +483,4 @@ export const TreeSelect = TreeSelectComponent as CompoundedComponent; TreeSelect.Item = Tree.Item; TreeSelect.ItemContent = Tree.ItemContent; TreeSelect.LoadMoreItem = Tree.LoadMoreItem; -TreeSelect.Tag = SelectedTag; +TreeSelect.Tag = Tag; diff --git a/packages/components/src/components/TreeSelect/types.ts b/packages/components/src/components/TreeSelect/types.ts index 00d69dfdb..194f7ee3f 100644 --- a/packages/components/src/components/TreeSelect/types.ts +++ b/packages/components/src/components/TreeSelect/types.ts @@ -33,6 +33,7 @@ import type { PopoverProps } from '../Popover'; import type { SearchInputProps } from '../SearchInput'; import { selectedTagsPropOverflow } from '../SelectedTags'; import type { SelectedTagsRenderTagProps as TreeSelectRenderTagProps } from '../SelectedTags'; +import type { TagProps } from '../Tag'; import type { TreeCollection } from './TreeInner'; @@ -50,7 +51,7 @@ export type TreeSelectPropSelectedTagsOverflow = (typeof treeSelectPropSelectedTagsOverflow)[number]; export type { TreeSelectRenderTagProps }; -export type { SelectedTagProps as TreeSelectTagProps } from '../SelectedTags'; +export type TreeSelectTagProps = TagProps; type AriaTreeSelectProps< T extends object, From 3dea30c843218595b0f5db05afd196e52b270d69 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 3 Jul 2026 15:35:57 +0300 Subject: [PATCH 3/6] refactor(TreeSelect): remove selected tag customization --- .../TreeSelect/TreeSelect.stories.tsx | 35 --------------- .../components/TreeSelect/TreeSelect.test.tsx | 43 ------------------- .../src/components/TreeSelect/TreeSelect.tsx | 5 --- .../src/components/TreeSelect/types.ts | 13 +----- 4 files changed, 1 insertion(+), 95 deletions(-) diff --git a/packages/components/src/components/TreeSelect/TreeSelect.stories.tsx b/packages/components/src/components/TreeSelect/TreeSelect.stories.tsx index 0f7bfdbfa..ae368a493 100644 --- a/packages/components/src/components/TreeSelect/TreeSelect.stories.tsx +++ b/packages/components/src/components/TreeSelect/TreeSelect.stories.tsx @@ -451,41 +451,6 @@ export const SelectedTagsOverflow: Story = { }, }; -export const CustomTagRender: Story = { - render: function Render() { - const defaultValue = [1, 6]; - - return ( - ( - } - > - {item.textValue} - - )} - > - {function renderItem(item) { - return ( - - {item.title} - {renderItem} - - ); - }} - - ); - }, -}; - export const Disabled: Story = { render: function Render() { return ( diff --git a/packages/components/src/components/TreeSelect/TreeSelect.test.tsx b/packages/components/src/components/TreeSelect/TreeSelect.test.tsx index 1e390a75f..58569af8b 100644 --- a/packages/components/src/components/TreeSelect/TreeSelect.test.tsx +++ b/packages/components/src/components/TreeSelect/TreeSelect.test.tsx @@ -341,49 +341,6 @@ describe('TreeSelect', () => { ).toBeInTheDocument(); }); - it('should use renderTag to customize selected tags in multiple mode', () => { - renderTreeSelect({ - selectionMode: 'multiple', - defaultValue: [1, 7], - renderTag: (item, tagProps) => ( -
- {item.textValue} -
- ), - }); - - const control = getControl(); - - expect(within(control).getByTestId('custom-tag-1')).toBeInTheDocument(); - expect(within(control).getByTestId('custom-tag-7')).toBeInTheDocument(); - }); - - it('should render TreeSelect.Tag inside renderTag and support removing via its button', async () => { - const onChange = vi.fn(); - - renderTreeSelect({ - selectionMode: 'multiple', - defaultValue: [1, 7], - onChange, - renderTag: (item, tagProps) => ( - - {item.textValue} - - ), - }); - - expect(screen.getByTestId('tag-1')).toHaveTextContent('app'); - expect(screen.getByTestId('tag-7')).toHaveTextContent('README.md'); - - await userEvent.click( - within(screen.getByTestId('tag-7')).getByRole('button', { - hidden: true, - }) - ); - - expect(onChange).toHaveBeenCalledWith([1]); - }); - it('should call onChange with keys in multiple mode', async () => { const onChange = vi.fn(); diff --git a/packages/components/src/components/TreeSelect/TreeSelect.tsx b/packages/components/src/components/TreeSelect/TreeSelect.tsx index 6a156e8fb..e116288e5 100644 --- a/packages/components/src/components/TreeSelect/TreeSelect.tsx +++ b/packages/components/src/components/TreeSelect/TreeSelect.tsx @@ -51,7 +51,6 @@ import type { PopoverInnerProps, PopoverProps } from '../Popover'; import { PopoverInner } from '../Popover/PopoverInner'; import { SearchInput, type SearchInputProps } from '../SearchInput'; import { SelectedTags } from '../SelectedTags'; -import { Tag } from '../Tag'; import { Tree } from '../Tree'; import intlMessages from './intl'; @@ -78,7 +77,6 @@ export function TreeSelectInner< >({ props, collection, controlRef }: TreeSelectInnerProps) { const { selectedTagsOverflow = 'responsive', - renderTag, defaultInputValue = '', labelAlign, placeholder, @@ -403,7 +401,6 @@ export function TreeSelectInner< isReadOnly, isRequired, }} - renderTag={renderTag} selectedTagsOverflow={selectedTagsOverflow} /> ) : ( @@ -474,7 +471,6 @@ type CompoundedComponent = typeof TreeSelectComponent & { Item: typeof Tree.Item; ItemContent: typeof Tree.ItemContent; LoadMoreItem: typeof Tree.LoadMoreItem; - Tag: typeof Tag; }; /** Select with hierarchical tree data. */ @@ -483,4 +479,3 @@ export const TreeSelect = TreeSelectComponent as CompoundedComponent; TreeSelect.Item = Tree.Item; TreeSelect.ItemContent = Tree.ItemContent; TreeSelect.LoadMoreItem = Tree.LoadMoreItem; -TreeSelect.Tag = Tag; diff --git a/packages/components/src/components/TreeSelect/types.ts b/packages/components/src/components/TreeSelect/types.ts index 194f7ee3f..4c55e1110 100644 --- a/packages/components/src/components/TreeSelect/types.ts +++ b/packages/components/src/components/TreeSelect/types.ts @@ -6,7 +6,7 @@ import type { Ref, } from 'react'; -import type { DataAttributeProps, Node, RefObject } from '@koobiq/react-core'; +import type { DataAttributeProps, RefObject } from '@koobiq/react-core'; import type { SelectionMode, TreeProps as AriaTreeProps, @@ -32,8 +32,6 @@ import type { IconButtonProps } from '../IconButton'; import type { PopoverProps } from '../Popover'; import type { SearchInputProps } from '../SearchInput'; import { selectedTagsPropOverflow } from '../SelectedTags'; -import type { SelectedTagsRenderTagProps as TreeSelectRenderTagProps } from '../SelectedTags'; -import type { TagProps } from '../Tag'; import type { TreeCollection } from './TreeInner'; @@ -50,9 +48,6 @@ export const treeSelectPropSelectedTagsOverflow = selectedTagsPropOverflow; export type TreeSelectPropSelectedTagsOverflow = (typeof treeSelectPropSelectedTagsOverflow)[number]; -export type { TreeSelectRenderTagProps }; -export type TreeSelectTagProps = TagProps; - type AriaTreeSelectProps< T extends object, M extends SelectionMode = 'single', @@ -64,12 +59,6 @@ export type TreeSelectProps< > = { /** Defines how selected tags are displayed when they exceed the available space. */ selectedTagsOverflow?: TreeSelectPropSelectedTagsOverflow; - /** - * Custom renderer for a selected tag in `selectionMode="multiple"`. - * Spread `tagProps` onto your root element to preserve overflow collapse and spacing. - * Has no effect in single-selection mode. - */ - renderTag?: (item: Node, tagProps: TreeSelectRenderTagProps) => ReactNode; /** Whether the field can be emptied. */ isClearable?: boolean; /** Handler called when the clear button is clicked. */ From 2f28caca760c6cabe4dd5111987820969b91f9fd Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 3 Jul 2026 15:48:28 +0300 Subject: [PATCH 4/6] refactor(SelectNext): use TagProps for custom tag renderer --- .../components/src/components/SelectNext/types.ts | 13 ++----------- .../SelectedTags/SelectedTagsMultiline.tsx | 6 +++--- .../SelectedTags/SelectedTagsResponsive.tsx | 6 +++--- .../components/src/components/SelectedTags/types.ts | 11 +---------- .../src/components/TreeSelect/TreeSelect.mdx | 6 ------ 5 files changed, 9 insertions(+), 33 deletions(-) diff --git a/packages/components/src/components/SelectNext/types.ts b/packages/components/src/components/SelectNext/types.ts index 795f06335..116c3a068 100644 --- a/packages/components/src/components/SelectNext/types.ts +++ b/packages/components/src/components/SelectNext/types.ts @@ -27,7 +27,6 @@ import { import type { IconButtonProps } from '../IconButton'; import type { PopoverProps } from '../Popover'; import type { SearchInputProps } from '../SearchInput'; -import type { SelectedTagsRenderTagProps as SelectNextRenderTagProps } from '../SelectedTags'; import type { TagProps } from '../Tag'; import type { SelectListProps } from './components'; @@ -46,7 +45,6 @@ export type SelectNextPropLabelPlacement = FormFieldPropLabelPlacement; export const selectNextPropLabelAlign = formFieldPropLabelAlign; export type SelectNextPropLabelAlign = FormFieldPropLabelAlign; -export type { SelectNextRenderTagProps }; export type SelectNextTagProps = TagProps; export type SelectNextProps< @@ -62,15 +60,8 @@ export type SelectNextProps< * @default 'responsive' */ selectedTagsOverflow?: SelectNextPropSelectedTagsOverflow; - /** - * Custom renderer for a selected tag in `selectionMode="multiple"`. - * Spread `tagProps` onto your root element to preserve overflow collapse and spacing. - * Ignored when `renderValue` is provided. Has no effect in single-selection mode. - */ - renderTag?: ( - item: Node, - tagProps: SelectNextRenderTagProps - ) => ReactNode; + /** Custom renderer for selected tags in multiple selection mode. */ + renderTag?: (item: Node, tagProps: TagProps) => ReactNode; /** Handler that is called when the clear button is clicked. */ onClear?: () => void; /** Sets the CSS [`className`](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element. */ diff --git a/packages/components/src/components/SelectedTags/SelectedTagsMultiline.tsx b/packages/components/src/components/SelectedTags/SelectedTagsMultiline.tsx index 7b46c25f7..7eebcc58e 100644 --- a/packages/components/src/components/SelectedTags/SelectedTagsMultiline.tsx +++ b/packages/components/src/components/SelectedTags/SelectedTagsMultiline.tsx @@ -3,11 +3,11 @@ import { Fragment } from 'react'; import { clsx, useLocalizedStringFormatter } from '@koobiq/react-core'; import { useFormFieldControlGroup } from '../FormField'; -import { Tag } from '../Tag'; +import { Tag, type TagProps } from '../Tag'; import intlMessages from './intl'; import s from './SelectedTags.module.css'; -import type { SelectedTagsProps, SelectedTagsRenderTagProps } from './types'; +import type { SelectedTagsProps } from './types'; export function SelectedTagsMultiline({ state, @@ -36,7 +36,7 @@ export function SelectedTagsMultiline({ } }; - const tagProps: SelectedTagsRenderTagProps = { + const tagProps: TagProps = { className: s.tag, isDisabled, variant: isInvalid ? 'error-fade' : 'contrast-fade', diff --git a/packages/components/src/components/SelectedTags/SelectedTagsResponsive.tsx b/packages/components/src/components/SelectedTags/SelectedTagsResponsive.tsx index 320318598..1631487fb 100644 --- a/packages/components/src/components/SelectedTags/SelectedTagsResponsive.tsx +++ b/packages/components/src/components/SelectedTags/SelectedTagsResponsive.tsx @@ -7,11 +7,11 @@ import { } from '@koobiq/react-core'; import { useFormFieldControlGroup } from '../FormField'; -import { Tag } from '../Tag'; +import { Tag, type TagProps } from '../Tag'; import intlMessages from './intl'; import s from './SelectedTags.module.css'; -import type { SelectedTagsProps, SelectedTagsRenderTagProps } from './types'; +import type { SelectedTagsProps } from './types'; import { getHiddenCount } from './utils'; export function SelectedTagsResponsive({ @@ -50,7 +50,7 @@ export function SelectedTagsResponsive({ } }; - const tagProps: SelectedTagsRenderTagProps = { + const tagProps: TagProps = { ref: itemsRefs[i], className: s.tag, isDisabled, diff --git a/packages/components/src/components/SelectedTags/types.ts b/packages/components/src/components/SelectedTags/types.ts index c8b9c846b..4ff361eac 100644 --- a/packages/components/src/components/SelectedTags/types.ts +++ b/packages/components/src/components/SelectedTags/types.ts @@ -18,12 +18,6 @@ export interface SelectedTagsSelectionState { }; } -/** Props to spread onto a custom tag's root element; returned by the `renderTag` callback. */ -export type SelectedTagsRenderTagProps = Omit & { - 'aria-hidden'?: true; - variant: NonNullable; -}; - export type SelectedTagsProps = { state: SelectedTagsSelectionState; states: { @@ -33,8 +27,5 @@ export type SelectedTagsProps = { isRequired?: boolean; }; selectedTagsOverflow?: SelectedTagsPropOverflow; - renderTag?: ( - item: Node, - tagProps: SelectedTagsRenderTagProps - ) => ReactNode; + renderTag?: (item: Node, tagProps: TagProps) => ReactNode; }; diff --git a/packages/components/src/components/TreeSelect/TreeSelect.mdx b/packages/components/src/components/TreeSelect/TreeSelect.mdx index f41d3c6b2..68af55c50 100644 --- a/packages/components/src/components/TreeSelect/TreeSelect.mdx +++ b/packages/components/src/components/TreeSelect/TreeSelect.mdx @@ -116,12 +116,6 @@ or `selectedTagsOverflow="multiline"` to wrap tags onto multiple lines. -### Custom Tag Render - -Allows for custom rendering of tags. - - - ### Disabled When TreeSelect is disabled, it cannot be interacted with. From 6c9ffc01e5388478b19cfb5175d55bf19e42cd6b Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 3 Jul 2026 15:51:35 +0300 Subject: [PATCH 5/6] chore: remove unused import --- packages/components/src/components/TreeSelect/TreeSelect.mdx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/components/src/components/TreeSelect/TreeSelect.mdx b/packages/components/src/components/TreeSelect/TreeSelect.mdx index 68af55c50..22d86520b 100644 --- a/packages/components/src/components/TreeSelect/TreeSelect.mdx +++ b/packages/components/src/components/TreeSelect/TreeSelect.mdx @@ -1,5 +1,4 @@ import { - Alert, Meta, Story, Props, From 1d919cfc74d938b522aa979ebe99047b52b9c1c3 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 3 Jul 2026 15:54:03 +0300 Subject: [PATCH 6/6] docs(TreeSelect): fix stories --- .../components/src/components/TreeSelect/TreeSelect.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/components/TreeSelect/TreeSelect.stories.tsx b/packages/components/src/components/TreeSelect/TreeSelect.stories.tsx index ae368a493..0025288c2 100644 --- a/packages/components/src/components/TreeSelect/TreeSelect.stories.tsx +++ b/packages/components/src/components/TreeSelect/TreeSelect.stories.tsx @@ -25,7 +25,7 @@ const meta = { 'TreeSelect.LoadMoreItem': TreeSelect.LoadMoreItem, }, argTypes: {}, - tags: ['status:new', 'date:2026-07-02'], + tags: ['status:new', 'date:2026-06-26'], } satisfies Meta; export default meta;