Design system.
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 
 

548 líneas
15 KiB

  1. import * as React from 'react';
  2. import { TagsInput } from 'react-tag-input-component';
  3. import clsx from 'clsx';
  4. import { useClientSide, useFallbackId, useProxyInput } from '@modal-sh/react-utils';
  5. import { TextControl } from '@tesseract-design/web-base';
  6. import plugin from 'tailwindcss/plugin';
  7. /**
  8. * Separator for splitting the input value into multiple tags.
  9. */
  10. export type TagInputSeparator = 'comma' | 'newline' | 'semicolon';
  11. const TAG_INPUT_SEPARATOR_MAP: Record<TagInputSeparator, string> = {
  12. 'comma': ',',
  13. 'newline': 'Enter',
  14. 'semicolon': ';',
  15. } as const;
  16. const TAG_INPUT_VALUE_SEPARATOR_MAP: Record<TagInputSeparator, string> = {
  17. 'comma': ',',
  18. 'newline': '\n',
  19. 'semicolon': ';',
  20. } as const;
  21. /**
  22. * Derived HTML element of the {@link TagInput} component.
  23. */
  24. export type TagInputDerivedElement = HTMLTextAreaElement | HTMLInputElement;
  25. /**
  26. * Proxied HTML element of the {@link TagInput} component.
  27. */
  28. export type TagInputProxiedElement = HTMLTextAreaElement & HTMLInputElement;
  29. /**
  30. * Props of the {@link TagsInput} component.
  31. */
  32. export interface TagInputProps extends Omit<React.HTMLProps<TagInputDerivedElement>, 'size' | 'type' | 'style' | 'label' | 'list'> {
  33. /**
  34. * Short textual description indicating the nature of the component's value.
  35. */
  36. label?: React.ReactNode,
  37. /**
  38. * Short textual description as guidelines for valid input values.
  39. */
  40. hint?: React.ReactNode,
  41. /**
  42. * Size of the component.
  43. */
  44. size?: TextControl.Size,
  45. /**
  46. * Additional description, usually graphical, indicating the nature of the component's value.
  47. */
  48. indicator?: React.ReactNode,
  49. /**
  50. * Should the component display a border?
  51. */
  52. border?: boolean,
  53. /**
  54. * Should the component occupy the whole width of its parent?
  55. */
  56. block?: boolean,
  57. /**
  58. * Style of the component.
  59. */
  60. variant?: TextControl.Variant,
  61. /**
  62. * Is the label hidden?
  63. */
  64. hiddenLabel?: boolean,
  65. /**
  66. * Should the component be enhanced?
  67. */
  68. enhanced?: boolean,
  69. /**
  70. * Separators for splitting the input value into multiple tags.
  71. */
  72. separator?: TagInputSeparator[],
  73. /**
  74. * Should the last tag be editable when removed?
  75. */
  76. editOnRemove?: boolean,
  77. /**
  78. * Fallback element for non-enhanced mode.
  79. */
  80. fallbackElement?: 'textarea' | 'input',
  81. /**
  82. * Separator used on the value of the input.
  83. */
  84. valueSeparator?: TagInputSeparator,
  85. }
  86. export const tagInputPlugin = plugin(({ addComponents }) => {
  87. addComponents({
  88. '.tag-input': {
  89. '& label + * + div > input': {
  90. 'flex': 'auto',
  91. },
  92. '&[data-size="small"] label + * + div > input': {
  93. 'font-size': '0.625rem',
  94. },
  95. '&[data-size="medium"] label + * + div > input': {
  96. 'font-size': '0.75rem',
  97. },
  98. '&[data-variant="default"] label + * + div': {
  99. 'padding-left': '1rem',
  100. 'padding-right': '1rem',
  101. },
  102. '&[data-variant="alternate"] label + * + div': {
  103. 'padding-left': '0.375rem',
  104. 'padding-right': '0.375rem',
  105. },
  106. '&[data-size="small"][data-variant="default"] label + * + div': {
  107. 'padding-top': '0.625rem',
  108. 'padding-bottom': '0.875rem',
  109. },
  110. '&[data-size="medium"][data-variant="default"] label + * + div': {
  111. 'padding-top': '0.75rem',
  112. 'padding-bottom': '1rem',
  113. },
  114. '&[data-size="large"][data-variant="default"] label + * + div': {
  115. 'padding-top': '1rem',
  116. 'padding-bottom': '1.25rem',
  117. },
  118. '&[data-size="small"][data-variant="alternate"] label + * + div': {
  119. 'padding-top': '1.375rem',
  120. },
  121. '&[data-size="medium"][data-variant="alternate"] label + * + div': {
  122. 'padding-top': '1.5rem',
  123. },
  124. '&[data-size="large"][data-variant="alternate"] label + * + div': {
  125. 'padding-top': '1.75rem',
  126. },
  127. '&[data-size="small"] label + * + div': {
  128. 'gap': '0.25rem',
  129. 'min-height': '2.5rem',
  130. },
  131. '&[data-size="small"].tag-input-indicator label + * + div': {
  132. 'padding-right': '2.5rem',
  133. },
  134. '&[data-size="medium"] label + * + div': {
  135. 'gap': '0.375rem',
  136. 'min-height': '3rem',
  137. },
  138. '&[data-size="medium"].tag-input-indicator label + * + div': {
  139. 'padding-right': '3rem',
  140. },
  141. '&[data-size="large"] label + * + div': {
  142. 'gap': '0.375rem',
  143. 'min-height': '4rem',
  144. },
  145. '&[data-size="large"].tag-input-indicator label + * + div': {
  146. 'padding-right': '4rem',
  147. },
  148. '& label + * + div > span': {
  149. 'padding': '0.125rem',
  150. 'border-radius': '0.25rem',
  151. 'line-height': '1',
  152. 'background-color': 'rgb(var(--color-positive) / 25%)',
  153. },
  154. '& label + * + div > span:focus-within': {
  155. 'background-color': 'rgb(var(--color-secondary) / 25%)',
  156. },
  157. '& label + * + div > span span': {
  158. 'pointer-events': 'none',
  159. },
  160. '& label + * + div > span button': {
  161. 'color': 'rgb(var(--color-primary))',
  162. 'padding': '0',
  163. 'width': '1rem',
  164. 'margin-left': '0.25rem',
  165. },
  166. '& label + * + div > span button:focus': {
  167. 'outline': 'none',
  168. 'color': 'rgb(var(--color-secondary))',
  169. },
  170. '& label + * + div > span button:focus:-moz-focusring': {
  171. 'display': 'none',
  172. },
  173. '& label + * + div > span button:hover': {
  174. 'color': 'rgb(var(--color-primary))',
  175. },
  176. },
  177. });
  178. });
  179. /**
  180. * Component for inputting textual values.
  181. *
  182. * This component supports multiline input and adjusts its layout accordingly.
  183. */
  184. export const TagInput = React.forwardRef<TagInputDerivedElement, TagInputProps>((
  185. {
  186. label,
  187. hint,
  188. indicator,
  189. size = 'medium' as const,
  190. border = false as const,
  191. block = false as const,
  192. variant = 'default' as const,
  193. hiddenLabel = false as const,
  194. className,
  195. enhanced: enhancedProp = false as const,
  196. separator = ['newline'],
  197. valueSeparator = separator?.[0] ?? 'newline',
  198. defaultValue,
  199. value,
  200. disabled,
  201. id: idProp,
  202. onFocus,
  203. onBlur,
  204. editOnRemove = false as const,
  205. placeholder,
  206. fallbackElement: FallbackElement = 'textarea' as const,
  207. ...etcProps
  208. },
  209. forwardedRef,
  210. ) => {
  211. const EffectiveFallbackElement = valueSeparator === 'newline' ? 'textarea' : FallbackElement;
  212. const { clientSide } = useClientSide({ clientSide: enhancedProp });
  213. const [tags, setTags] = React.useState<string[]>(() => {
  214. const effectiveValue = value ?? defaultValue;
  215. if (effectiveValue === undefined) {
  216. return [];
  217. }
  218. if (typeof effectiveValue === 'string') {
  219. return effectiveValue.split(TAG_INPUT_VALUE_SEPARATOR_MAP[valueSeparator]);
  220. }
  221. if (typeof effectiveValue === 'number') {
  222. return [effectiveValue.toString()];
  223. }
  224. return effectiveValue as string[];
  225. });
  226. const {
  227. defaultRef,
  228. handleChange: handleTagsInputChange,
  229. } = useProxyInput<
  230. string[],
  231. TagInputDerivedElement,
  232. TagInputProxiedElement
  233. >({
  234. forwardedRef,
  235. valueSetterFn: (v) => {
  236. setTags(v);
  237. },
  238. transformChangeHandlerArgs: (newTags) => (
  239. newTags.map((t) => t.trim()).join(TAG_INPUT_VALUE_SEPARATOR_MAP[valueSeparator])
  240. ),
  241. });
  242. const ref = forwardedRef ?? defaultRef;
  243. const labelId = React.useId();
  244. const id = useFallbackId(idProp);
  245. const handleFocus: React.FocusEventHandler<TagInputDerivedElement> = (e) => {
  246. if (!clientSide) {
  247. onFocus?.(e);
  248. }
  249. };
  250. const handleBlur: React.FocusEventHandler<TagInputDerivedElement> = (e) => {
  251. if (!clientSide) {
  252. onBlur?.(e);
  253. }
  254. };
  255. const handleRemoveTag = () => {
  256. if (!(typeof ref === 'object' && ref)) {
  257. return;
  258. }
  259. const { current: input } = ref;
  260. if (!input) {
  261. return;
  262. }
  263. setTimeout(() => {
  264. const sibling = input.nextElementSibling as HTMLDivElement;
  265. const tagsInput = sibling.children[sibling.children.length - 1] as HTMLInputElement;
  266. tagsInput.focus();
  267. });
  268. };
  269. const handleInputBlur: React.FocusEventHandler<HTMLInputElement> = (e) => {
  270. if (!(typeof ref === 'object' && ref)) {
  271. return;
  272. }
  273. const { current: input } = ref;
  274. if (!input) {
  275. return;
  276. }
  277. onBlur?.({
  278. ...e,
  279. target: input,
  280. currentTarget: input,
  281. });
  282. };
  283. const handleFocusCapture: React.FocusEventHandler<HTMLDivElement> = (e) => {
  284. const { currentTarget } = e;
  285. if (!clientSide) {
  286. return;
  287. }
  288. const { activeElement } = window.document;
  289. if (!activeElement) {
  290. return;
  291. }
  292. const tagInputWrapper = currentTarget.children[1] as HTMLDivElement;
  293. const tagInput = (
  294. tagInputWrapper.children[tagInputWrapper.children.length - 1] as HTMLInputElement
  295. );
  296. if (activeElement !== tagInput) {
  297. return;
  298. }
  299. if (!(typeof ref === 'object' && ref)) {
  300. return;
  301. }
  302. const { current: input } = ref;
  303. if (!input) {
  304. return;
  305. }
  306. if (activeElement.tagName === 'INPUT') {
  307. onFocus?.({
  308. ...e,
  309. target: input,
  310. currentTarget: input,
  311. });
  312. }
  313. };
  314. return (
  315. <div
  316. data-size={size}
  317. data-variant={variant}
  318. className={clsx(
  319. 'relative rounded ring-secondary/50 group has-[:disabled]:opacity-50',
  320. 'focus-within:ring-4',
  321. {
  322. 'block': block,
  323. 'inline-block align-middle': !block,
  324. },
  325. clientSide && {
  326. 'min-h-10': size === 'small',
  327. 'min-h-12': size === 'medium',
  328. 'min-h-16': size === 'large',
  329. },
  330. 'tag-input',
  331. indicator && 'tag-input-indicator',
  332. className,
  333. )}
  334. data-testid="base"
  335. onFocusCapture={handleFocusCapture}
  336. >
  337. {label && (
  338. <>
  339. <label
  340. data-testid="label"
  341. id={labelId}
  342. htmlFor={id}
  343. className={clsx(
  344. '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',
  345. {
  346. 'sr-only': hiddenLabel,
  347. },
  348. {
  349. 'pr-1': !indicator,
  350. },
  351. {
  352. 'pr-10': indicator && size === 'small',
  353. 'pr-12': indicator && size === 'medium',
  354. 'pr-16': indicator && size === 'large',
  355. },
  356. )}
  357. >
  358. <span className="block w-full whitespace-nowrap h-[1.1em] overflow-hidden text-ellipsis">
  359. {label}
  360. </span>
  361. </label>
  362. {' '}
  363. </>
  364. )}
  365. <EffectiveFallbackElement
  366. {...etcProps}
  367. placeholder={placeholder}
  368. disabled={disabled}
  369. ref={defaultRef}
  370. id={id}
  371. aria-labelledby={labelId}
  372. data-testid="input"
  373. defaultValue={defaultValue}
  374. value={value}
  375. onFocus={handleFocus}
  376. onBlur={handleBlur}
  377. style={{
  378. height: 0,
  379. }}
  380. tabIndex={clientSide ? -1 : undefined}
  381. className={clsx(
  382. 'bg-negative rounded-inherit peer block font-inherit',
  383. 'focus:outline-0',
  384. 'disabled:opacity-50 disabled:cursor-not-allowed',
  385. {
  386. 'resize': !block,
  387. 'resize-y': block,
  388. },
  389. {
  390. 'text-xxs': size === 'small',
  391. 'text-xs': size === 'medium',
  392. },
  393. !clientSide && {
  394. 'pl-4': variant === 'default',
  395. 'pl-1.5': variant === 'alternate',
  396. },
  397. !clientSide && {
  398. 'pt-4': variant === 'alternate' && size === 'small',
  399. 'pt-5': variant === 'alternate' && size === 'medium',
  400. 'pt-8': variant === 'alternate' && size === 'large',
  401. },
  402. !clientSide && {
  403. 'py-2.5': variant === 'default' && size === 'small',
  404. 'py-3': variant === 'default' && size === 'medium',
  405. 'py-5': variant === 'default' && size === 'large',
  406. },
  407. !clientSide && {
  408. 'pr-4': variant === 'default' && !indicator,
  409. 'pr-1.5': variant === 'alternate' && !indicator,
  410. },
  411. !clientSide && {
  412. 'pr-10': indicator && size === 'small',
  413. 'pr-12': indicator && size === 'medium',
  414. 'pr-16': indicator && size === 'large',
  415. },
  416. {
  417. 'min-h-10': size === 'small',
  418. 'min-h-12': size === 'medium',
  419. 'min-h-16': size === 'large',
  420. },
  421. !clientSide && 'peer',
  422. !clientSide && 'w-full',
  423. clientSide && 'sr-only',
  424. )}
  425. />
  426. {clientSide && (
  427. <TagsInput
  428. value={tags}
  429. classNames={{
  430. input: 'peer bg-transparent font-inherit',
  431. tag: 'text-xs p-2 select-none font-inherit',
  432. }}
  433. isEditOnRemove={editOnRemove}
  434. placeHolder={placeholder}
  435. disabled={disabled}
  436. onBlur={handleInputBlur}
  437. onChange={handleTagsInputChange}
  438. onRemoved={handleRemoveTag}
  439. separators={separator.map((s) => TAG_INPUT_SEPARATOR_MAP[s])}
  440. />
  441. )}
  442. {hint && (
  443. <div
  444. data-testid="hint"
  445. className={clsx(
  446. 'absolute left-0 px-1 pointer-events-none text-xxs leading-none w-full bg-negative select-none',
  447. {
  448. 'bottom-0 pl-4 pb-1': variant === 'default',
  449. 'top-0.5': variant === 'alternate',
  450. },
  451. {
  452. 'pt-2': variant === 'alternate' && size === 'small',
  453. 'pt-3': variant === 'alternate' && size !== 'small',
  454. },
  455. {
  456. 'pr-4': !indicator && variant === 'default',
  457. 'pr-1': !indicator && variant === 'alternate',
  458. },
  459. {
  460. 'pr-10': indicator && size === 'small',
  461. 'pr-12': indicator && size === 'medium',
  462. 'pr-16': indicator && size === 'large',
  463. },
  464. )}
  465. >
  466. <div
  467. className="opacity-50 whitespace-nowrap w-full h-[1.1em] overflow-hidden text-ellipsis"
  468. >
  469. {hint}
  470. </div>
  471. </div>
  472. )}
  473. {indicator && (
  474. <div
  475. className={clsx(
  476. 'text-center flex items-center justify-center aspect-square absolute bottom-0 right-0 pointer-events-none select-none',
  477. {
  478. 'w-10': size === 'small',
  479. 'w-12': size === 'medium',
  480. 'w-16': size === 'large',
  481. },
  482. )}
  483. >
  484. {indicator}
  485. </div>
  486. )}
  487. {border && (
  488. <span
  489. data-testid="border"
  490. className="absolute z-[1] inset-0 rounded-inherit border-2 border-primary pointer-events-none group-focus-within:border-secondary"
  491. />
  492. )}
  493. </div>
  494. );
  495. });
  496. TagInput.displayName = 'TagInput';
  497. TagInput.defaultProps = {
  498. label: undefined,
  499. hint: undefined,
  500. indicator: undefined,
  501. size: 'medium' as const,
  502. variant: 'default' as const,
  503. separator: ['newline'],
  504. border: false as const,
  505. block: false as const,
  506. hiddenLabel: false as const,
  507. enhanced: false as const,
  508. editOnRemove: false as const,
  509. fallbackElement: 'textarea' as const,
  510. valueSeparator: 'newline' as const,
  511. };