Design system.
Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 

555 linhas
15 KiB

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