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..aac32fe9d 100644 --- a/packages/components/src/components/SelectNext/Select.tsx +++ b/packages/components/src/components/SelectNext/Select.tsx @@ -40,9 +40,10 @@ import { List } from '../List'; import type { ListItemAddon } from '../List/components'; import type { PopoverInnerProps, PopoverProps } from '../Popover'; import { PopoverInner } from '../Popover/PopoverInner'; +import { SelectedTags } from '../SelectedTags'; +import { Tag } from '../Tag'; import { - TagGroup, SelectList, SelectOption, SelectSection, @@ -96,6 +97,7 @@ function SelectInner({ onClear, style, label, + renderTag, } = props; const { validationBehavior: formValidationBehavior } = @@ -275,14 +277,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 +397,7 @@ type CompoundedComponent = typeof SelectComponent & { Divider: typeof Divider; ItemText: typeof ListItemText; ItemAddon: typeof ListItemAddon; + Tag: typeof Tag; }; /** @@ -405,3 +411,4 @@ SelectNext.Section = SelectSection; SelectNext.Divider = Divider; SelectNext.ItemText = List.ItemText; SelectNext.ItemAddon = List.ItemAddon; +SelectNext.Tag = Tag; 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..116c3a068 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 { TagProps } from '../Tag'; import type { SelectListProps } from './components'; @@ -44,6 +45,8 @@ export type SelectNextPropLabelPlacement = FormFieldPropLabelPlacement; export const selectNextPropLabelAlign = formFieldPropLabelAlign; export type SelectNextPropLabelAlign = FormFieldPropLabelAlign; +export type SelectNextTagProps = TagProps; + export type SelectNextProps< T extends object, M extends SelectionMode = 'single', @@ -57,6 +60,8 @@ export type SelectNextProps< * @default 'responsive' */ selectedTagsOverflow?: SelectNextPropSelectedTagsOverflow; + /** 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 71acc2364..7eebcc58e 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 { Tag, type TagProps } from '../Tag'; import intlMessages from './intl'; import s from './SelectedTags.module.css'; -import { Tag } from './Tag'; import type { SelectedTagsProps } from './types'; export function SelectedTagsMultiline({ state, states, + renderTag, }: SelectedTagsProps) { const { isDisabled, isInvalid, isReadOnly } = states; const t = useLocalizedStringFormatter(intlMessages); @@ -26,22 +29,39 @@ export function SelectedTagsMultiline({ data-limit-tags="multiline" aria-label={t.format('selected items')} > - {state.selectedItems?.map((item) => ( - { - if (state.selectionManager.isSelected(item.key)) { - state.selectionManager.toggleSelection(item.key); - } - }} - > - {item.textValue} - - ))} + {state.selectedItems?.map((item) => { + const onRemove = () => { + if (state.selectionManager.isSelected(item.key)) { + state.selectionManager.toggleSelection(item.key); + } + }; + + const tagProps: TagProps = { + className: s.tag, + isDisabled, + variant: isInvalid ? 'error-fade' : 'contrast-fade', + slotProps: !isReadOnly + ? { + removeIcon: { + as: 'div', + isDisabled, + tabIndex: undefined, + onPress: onRemove, + }, + } + : undefined, + }; + + 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..1631487fb 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, @@ -5,16 +7,17 @@ import { } from '@koobiq/react-core'; import { useFormFieldControlGroup } from '../FormField'; +import { Tag, type TagProps } from '../Tag'; import intlMessages from './intl'; import s from './SelectedTags.module.css'; -import { Tag } from './Tag'; import type { SelectedTagsProps } 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,41 @@ export function SelectedTagsResponsive({ data-limit-tags="responsive" aria-label={t.format('selected items')} > - {state.selectedItems?.map((item, i) => ( - { - if (state.selectionManager.isSelected(item.key)) { - state.selectionManager.toggleSelection(item.key); - } - }} - > - {item.textValue} - - ))} + {state.selectedItems?.map((item, i) => { + const onRemove = () => { + if (state.selectionManager.isSelected(item.key)) { + state.selectionManager.toggleSelection(item.key); + } + }; + + const tagProps: TagProps = { + ref: itemsRefs[i], + className: s.tag, + isDisabled, + 'aria-hidden': !visibleMap[i] || undefined, + variant: isInvalid ? 'error-fade' : 'contrast-fade', + slotProps: !isReadOnly + ? { + removeIcon: { + as: 'div', + isDisabled, + tabIndex: undefined, + onPress: onRemove, + }, + } + : undefined, + }; + + 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/Tag/utils.ts b/packages/components/src/components/SelectedTags/Tag/utils.ts deleted file mode 100644 index 605a0eca3..000000000 --- a/packages/components/src/components/SelectedTags/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/SelectedTags/types.ts b/packages/components/src/components/SelectedTags/types.ts index bdccb036e..4ff361eac 100644 --- a/packages/components/src/components/SelectedTags/types.ts +++ b/packages/components/src/components/SelectedTags/types.ts @@ -1,5 +1,9 @@ +import type { ReactNode } from 'react'; + import type { Key, Node } from '@koobiq/react-core'; +import type { TagProps } from '../Tag'; + export const selectedTagsPropOverflow = ['multiline', 'responsive'] as const; export type SelectedTagsPropOverflow = @@ -23,4 +27,5 @@ export type SelectedTagsProps = { isRequired?: boolean; }; selectedTagsOverflow?: SelectedTagsPropOverflow; + renderTag?: (item: Node, tagProps: TagProps) => ReactNode; }; 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/SelectNext/components/Tag/intl.json b/packages/components/src/components/Tag/intl.json similarity index 100% rename from packages/components/src/components/SelectNext/components/Tag/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', +};