|
- import * as React from 'react';
- import { TagsInput } from 'react-tag-input-component';
- import clsx from 'clsx';
- import { useClientSide, useFallbackId, useProxyInput } from '@modal-sh/react-utils';
- import { TextControl } from '@tesseract-design/web-base';
- import plugin from 'tailwindcss/plugin';
-
- /**
- * Separator for splitting the input value into multiple tags.
- */
- export type TagInputSeparator = 'comma' | 'newline' | 'semicolon';
-
- const TAG_INPUT_SEPARATOR_MAP: Record<TagInputSeparator, string> = {
- 'comma': ',',
- 'newline': 'Enter',
- 'semicolon': ';',
- } as const;
-
- const TAG_INPUT_VALUE_SEPARATOR_MAP: Record<TagInputSeparator, string> = {
- 'comma': ',',
- 'newline': '\n',
- 'semicolon': ';',
- } as const;
-
- /**
- * Derived HTML element of the {@link TagInput} component.
- */
- export type TagInputDerivedElement = HTMLTextAreaElement | HTMLInputElement;
-
- /**
- * Proxied HTML element of the {@link TagInput} component.
- */
- export type TagInputProxiedElement = HTMLTextAreaElement & HTMLInputElement;
-
- /**
- * Props of the {@link TagsInput} component.
- */
- export interface TagInputProps extends Omit<React.HTMLProps<TagInputDerivedElement>, 'size' | 'type' | 'style' | 'label' | 'list'> {
- /**
- * Short textual description indicating the nature of the component's value.
- */
- label?: React.ReactNode,
- /**
- * Short textual description as guidelines for valid input values.
- */
- hint?: React.ReactNode,
- /**
- * Size of the component.
- */
- size?: TextControl.Size,
- /**
- * Additional description, usually graphical, indicating the nature of the component's value.
- */
- indicator?: React.ReactNode,
- /**
- * Should the component display a border?
- */
- border?: boolean,
- /**
- * Should the component occupy the whole width of its parent?
- */
- block?: boolean,
- /**
- * Style of the component.
- */
- variant?: TextControl.Variant,
- /**
- * Is the label hidden?
- */
- hiddenLabel?: boolean,
- /**
- * Should the component be enhanced?
- */
- enhanced?: boolean,
- /**
- * Separators for splitting the input value into multiple tags.
- */
- separator?: TagInputSeparator[],
- /**
- * Should the last tag be editable when removed?
- */
- editOnRemove?: boolean,
- /**
- * Fallback element for non-enhanced mode.
- */
- fallbackElement?: 'textarea' | 'input',
- /**
- * Separator used on the value of the input.
- */
- valueSeparator?: TagInputSeparator,
- }
-
- export const tagInputPlugin = plugin(({ addComponents }) => {
- addComponents({
- '.tag-input': {
- '& label + * + div > input': {
- 'flex': 'auto',
- },
- '&[data-size="small"] label + * + div > input': {
- 'font-size': '0.625rem',
- },
- '&[data-size="medium"] label + * + div > input': {
- 'font-size': '0.75rem',
- },
- '&[data-variant="default"] label + * + div': {
- 'padding-left': '1rem',
- 'padding-right': '1rem',
- },
-
- '&[data-variant="alternate"] label + * + div': {
- 'padding-left': '0.375rem',
- 'padding-right': '0.375rem',
- },
-
- '&[data-size="small"][data-variant="default"] label + * + div': {
- 'padding-top': '0.625rem',
- 'padding-bottom': '0.875rem',
- },
-
- '&[data-size="medium"][data-variant="default"] label + * + div': {
- 'padding-top': '0.75rem',
- 'padding-bottom': '1rem',
- },
-
- '&[data-size="large"][data-variant="default"] label + * + div': {
- 'padding-top': '1rem',
- 'padding-bottom': '1.25rem',
- },
-
- '&[data-size="small"][data-variant="alternate"] label + * + div': {
- 'padding-top': '1.375rem',
- },
-
- '&[data-size="medium"][data-variant="alternate"] label + * + div': {
- 'padding-top': '1.5rem',
- },
-
- '&[data-size="large"][data-variant="alternate"] label + * + div': {
- 'padding-top': '1.75rem',
- },
-
- '&[data-size="small"] label + * + div': {
- 'gap': '0.25rem',
- 'min-height': '2.5rem',
- },
-
- '&[data-size="small"].tag-input-indicator label + * + div': {
- 'padding-right': '2.5rem',
- },
-
- '&[data-size="medium"] label + * + div': {
- 'gap': '0.375rem',
- 'min-height': '3rem',
- },
-
- '&[data-size="medium"].tag-input-indicator label + * + div': {
- 'padding-right': '3rem',
- },
-
- '&[data-size="large"] label + * + div': {
- 'gap': '0.375rem',
- 'min-height': '4rem',
- },
-
- '&[data-size="large"].tag-input-indicator label + * + div': {
- 'padding-right': '4rem',
- },
-
- '& label + * + div > span': {
- 'padding': '0.125rem',
- 'border-radius': '0.25rem',
- 'line-height': '1',
- 'background-color': 'rgb(var(--color-positive) / 25%)',
- },
-
- '& label + * + div > span:focus-within': {
- 'background-color': 'rgb(var(--color-secondary) / 25%)',
- },
-
- '& label + * + div > span span': {
- 'pointer-events': 'none',
- },
-
- '& label + * + div > span button': {
- 'color': 'rgb(var(--color-primary))',
- 'padding': '0',
- 'width': '1rem',
- 'margin-left': '0.25rem',
- },
-
- '& label + * + div > span button:focus': {
- 'outline': 'none',
- 'color': 'rgb(var(--color-secondary))',
- },
-
- '& label + * + div > span button:focus:-moz-focusring': {
- 'display': 'none',
- },
-
- '& label + * + div > span button:hover': {
- 'color': 'rgb(var(--color-primary))',
- },
- },
- });
- });
-
- /**
- * Component for inputting textual values.
- *
- * This component supports multiline input and adjusts its layout accordingly.
- */
- export const TagInput = React.forwardRef<TagInputDerivedElement, TagInputProps>((
- {
- label,
- hint,
- indicator,
- size = 'medium' as const,
- border = false as const,
- block = false as const,
- variant = 'default' as const,
- hiddenLabel = false as const,
- className,
- enhanced: enhancedProp = false as const,
- separator = ['newline'],
- valueSeparator = separator?.[0] ?? 'newline',
- defaultValue,
- value,
- disabled,
- id: idProp,
- onFocus,
- onBlur,
- editOnRemove = false as const,
- placeholder,
- fallbackElement: FallbackElement = 'textarea' as const,
- ...etcProps
- },
- forwardedRef,
- ) => {
- const EffectiveFallbackElement = valueSeparator === 'newline' ? 'textarea' : FallbackElement;
- const { clientSide } = useClientSide({ clientSide: enhancedProp });
- const [tags, setTags] = React.useState<string[]>(() => {
- const effectiveValue = value ?? defaultValue;
- if (effectiveValue === undefined) {
- return [];
- }
- if (typeof effectiveValue === 'string') {
- return effectiveValue.split(TAG_INPUT_VALUE_SEPARATOR_MAP[valueSeparator]);
- }
- if (typeof effectiveValue === 'number') {
- return [effectiveValue.toString()];
- }
- return effectiveValue as string[];
- });
- const {
- defaultRef,
- handleChange: handleTagsInputChange,
- } = useProxyInput<
- string[],
- TagInputDerivedElement,
- TagInputProxiedElement
- >({
- forwardedRef,
- valueSetterFn: (v) => {
- setTags(v);
- },
- transformChangeHandlerArgs: (newTags) => (
- newTags.map((t) => t.trim()).join(TAG_INPUT_VALUE_SEPARATOR_MAP[valueSeparator])
- ),
- });
- const ref = forwardedRef ?? defaultRef;
- const labelId = React.useId();
- const id = useFallbackId(idProp);
-
- const handleFocus: React.FocusEventHandler<TagInputDerivedElement> = (e) => {
- if (!clientSide) {
- onFocus?.(e);
- }
- };
-
- const handleBlur: React.FocusEventHandler<TagInputDerivedElement> = (e) => {
- if (!clientSide) {
- onBlur?.(e);
- }
- };
-
- const handleRemoveTag = () => {
- if (!(typeof ref === 'object' && ref)) {
- return;
- }
- const { current: input } = ref;
- if (!input) {
- return;
- }
- setTimeout(() => {
- const sibling = input.nextElementSibling as HTMLDivElement;
- const tagsInput = sibling.children[sibling.children.length - 1] as HTMLInputElement;
- tagsInput.focus();
- });
- };
-
- const handleInputBlur: React.FocusEventHandler<HTMLInputElement> = (e) => {
- if (!(typeof ref === 'object' && ref)) {
- return;
- }
- const { current: input } = ref;
- if (!input) {
- return;
- }
- onBlur?.({
- ...e,
- target: input,
- currentTarget: input,
- });
- };
-
- const handleFocusCapture: React.FocusEventHandler<HTMLDivElement> = (e) => {
- const { currentTarget } = e;
- if (!clientSide) {
- return;
- }
- const { activeElement } = window.document;
- if (!activeElement) {
- return;
- }
- const tagInputWrapper = currentTarget.children[1] as HTMLDivElement;
- const tagInput = (
- tagInputWrapper.children[tagInputWrapper.children.length - 1] as HTMLInputElement
- );
- if (activeElement !== tagInput) {
- return;
- }
- if (!(typeof ref === 'object' && ref)) {
- return;
- }
- const { current: input } = ref;
- if (!input) {
- return;
- }
- if (activeElement.tagName === 'INPUT') {
- onFocus?.({
- ...e,
- target: input,
- currentTarget: input,
- });
- }
- };
-
- return (
- <div
- data-size={size}
- data-variant={variant}
- className={clsx(
- 'relative rounded ring-secondary/50 group has-[:disabled]:opacity-50',
- 'focus-within:ring-4',
- {
- 'block': block,
- 'inline-block align-middle': !block,
- },
- clientSide && {
- 'min-h-10': size === 'small',
- 'min-h-12': size === 'medium',
- 'min-h-16': size === 'large',
- },
- 'tag-input',
- indicator && 'tag-input-indicator',
- className,
- )}
- data-testid="base"
- onFocusCapture={handleFocusCapture}
- >
- {label && (
- <>
- <label
- data-testid="label"
- id={labelId}
- htmlFor={id}
- className={clsx(
- 'absolute z-[1] w-full top-0.5 left-0 pointer-events-none pl-1 text-xxs font-bold group-focus-within:text-secondary text-primary leading-none bg-negative select-none',
- {
- 'sr-only': hiddenLabel,
- },
- {
- 'pr-1': !indicator,
- },
- {
- 'pr-10': indicator && size === 'small',
- 'pr-12': indicator && size === 'medium',
- 'pr-16': indicator && size === 'large',
- },
- )}
- >
- <span className="block w-full whitespace-nowrap h-[1.1em] overflow-hidden text-ellipsis">
- {label}
- </span>
- </label>
- {' '}
- </>
- )}
- <EffectiveFallbackElement
- {...etcProps}
- placeholder={placeholder}
- disabled={disabled}
- ref={defaultRef}
- id={id}
- aria-labelledby={labelId}
- data-testid="input"
- defaultValue={defaultValue}
- value={value}
- onFocus={handleFocus}
- onBlur={handleBlur}
- style={{
- height: 0,
- }}
- tabIndex={clientSide ? -1 : undefined}
- className={clsx(
- 'bg-negative rounded-inherit peer block font-inherit',
- 'focus:outline-0',
- 'disabled:opacity-50 disabled:cursor-not-allowed',
- {
- 'resize': !block,
- 'resize-y': block,
- },
- {
- 'text-xxs': size === 'small',
- 'text-xs': size === 'medium',
- },
- !clientSide && {
- 'pl-4': variant === 'default',
- 'pl-1.5': variant === 'alternate',
- },
- !clientSide && {
- 'pt-4': variant === 'alternate' && size === 'small',
- 'pt-5': variant === 'alternate' && size === 'medium',
- 'pt-8': variant === 'alternate' && size === 'large',
- },
- !clientSide && {
- 'py-2.5': variant === 'default' && size === 'small',
- 'py-3': variant === 'default' && size === 'medium',
- 'py-5': variant === 'default' && size === 'large',
- },
- !clientSide && {
- 'pr-4': variant === 'default' && !indicator,
- 'pr-1.5': variant === 'alternate' && !indicator,
- },
- !clientSide && {
- 'pr-10': indicator && size === 'small',
- 'pr-12': indicator && size === 'medium',
- 'pr-16': indicator && size === 'large',
- },
- {
- 'min-h-10': size === 'small',
- 'min-h-12': size === 'medium',
- 'min-h-16': size === 'large',
- },
- !clientSide && 'peer',
- !clientSide && 'w-full',
- clientSide && 'sr-only',
- )}
- />
- {clientSide && (
- <TagsInput
- value={tags}
- classNames={{
- input: 'peer bg-transparent font-inherit',
- tag: 'text-xs p-2 select-none font-inherit',
- }}
- isEditOnRemove={editOnRemove}
- placeHolder={placeholder}
- disabled={disabled}
- onBlur={handleInputBlur}
- onChange={handleTagsInputChange}
- onRemoved={handleRemoveTag}
- separators={separator.map((s) => TAG_INPUT_SEPARATOR_MAP[s])}
- />
- )}
- {hint && (
- <div
- data-testid="hint"
- className={clsx(
- 'absolute left-0 px-1 pointer-events-none text-xxs leading-none w-full bg-negative select-none',
- {
- 'bottom-0 pl-4 pb-1': variant === 'default',
- 'top-0.5': variant === 'alternate',
- },
- {
- 'pt-2': variant === 'alternate' && size === 'small',
- 'pt-3': variant === 'alternate' && size !== 'small',
- },
- {
- 'pr-4': !indicator && variant === 'default',
- 'pr-1': !indicator && variant === 'alternate',
- },
- {
- 'pr-10': indicator && size === 'small',
- 'pr-12': indicator && size === 'medium',
- 'pr-16': indicator && size === 'large',
- },
- )}
- >
- <div
- className="opacity-50 whitespace-nowrap w-full h-[1.1em] overflow-hidden text-ellipsis"
- >
- {hint}
- </div>
- </div>
- )}
- {indicator && (
- <div
- className={clsx(
- 'text-center flex items-center justify-center aspect-square absolute bottom-0 right-0 pointer-events-none select-none',
- {
- 'w-10': size === 'small',
- 'w-12': size === 'medium',
- 'w-16': size === 'large',
- },
- )}
- >
- {indicator}
- </div>
- )}
- {border && (
- <span
- data-testid="border"
- className="absolute z-[1] inset-0 rounded-inherit border-2 border-primary pointer-events-none group-focus-within:border-secondary"
- />
- )}
- </div>
- );
- });
-
- TagInput.displayName = 'TagInput';
-
- TagInput.defaultProps = {
- label: undefined,
- hint: undefined,
- indicator: undefined,
- size: 'medium' as const,
- variant: 'default' as const,
- separator: ['newline'],
- border: false as const,
- block: false as const,
- hiddenLabel: false as const,
- enhanced: false as const,
- editOnRemove: false as const,
- fallbackElement: 'textarea' as const,
- valueSeparator: 'newline' as const,
- };
|