Design system.
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 
 

328 rader
9.8 KiB

  1. import * as React from 'react';
  2. import * as TextControlBase from '@tesseract-design/web-base-textcontrol';
  3. import * as BadgeBase from '@tesseract-design/web-base-badge';
  4. export type TagInputProps = Omit<React.HTMLProps<HTMLInputElement>, 'size' | 'style'> & {
  5. /**
  6. * Short textual description indicating the nature of the component's value.
  7. */
  8. label?: React.ReactNode,
  9. /**
  10. * Short textual description as guidelines for valid input values.
  11. */
  12. hint?: React.ReactNode,
  13. /**
  14. * Size of the component.
  15. */
  16. size?: TextControlBase.TextControlSize,
  17. /**
  18. * Additional description, usually graphical, indicating the nature of the component's value.
  19. */
  20. indicator?: React.ReactNode,
  21. /**
  22. * Should the component display a border?
  23. */
  24. border?: boolean,
  25. /**
  26. * Should the component occupy the whole width of its parent?
  27. */
  28. block?: boolean,
  29. /**
  30. * Style of the component.
  31. */
  32. style?: TextControlBase.TextControlStyle,
  33. /**
  34. * Is the label hidden?
  35. */
  36. hiddenLabel?: boolean,
  37. enhanced?: boolean,
  38. separator?: string,
  39. }
  40. export const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(((
  41. {
  42. label = '',
  43. hint = '',
  44. indicator = null,
  45. size = TextControlBase.TextControlSize.MEDIUM,
  46. border = false,
  47. block = false,
  48. style = TextControlBase.TextControlStyle.DEFAULT,
  49. hiddenLabel = false,
  50. onInput,
  51. enhanced = false,
  52. defaultValue = '',
  53. separator = ',',
  54. onFocus,
  55. onBlur,
  56. onSelect,
  57. className: _className,
  58. placeholder: _placeholder,
  59. as: _as,
  60. ...etcProps
  61. }: TagInputProps,
  62. forwardedRef,
  63. ) => {
  64. const [hydrated, setHydrated] = React.useState(false);
  65. const [focused, setFocused] = React.useState(false);
  66. const [selectionStart, setSelectionStart] = React.useState(0);
  67. const [selectionEnd, setSelectionEnd] = React.useState(0);
  68. const [viewValue, setViewValue] = React.useState<string[]>(() => {
  69. const theDefaultValue = !Array.isArray(defaultValue) ? [defaultValue.toString(), ''] : [...defaultValue, ''];
  70. return theDefaultValue.filter(v => v.length > 0);
  71. });
  72. const defaultRef = React.useRef<HTMLInputElement>(null);
  73. const effectiveRef = forwardedRef ?? defaultRef;
  74. const styleArgs = React.useMemo<TextControlBase.TextControlBaseArgs>(() => ({
  75. block,
  76. border,
  77. size,
  78. indicator: Boolean(indicator),
  79. style,
  80. resizable: true,
  81. predefinedValues: false,
  82. }), [block, border, size, indicator, style]);
  83. const renderEnhanced = React.useMemo(() => enhanced && hydrated, [enhanced, hydrated]);
  84. const handleInput: React.FormEventHandler<HTMLInputElement> = (e) => {
  85. const target = e.target as HTMLInputElement;
  86. setViewValue(target.value.split(separator))
  87. if (onInput) {
  88. onInput(e)
  89. }
  90. }
  91. const handleFocus: React.FocusEventHandler<HTMLInputElement> = (e) => {
  92. setFocused(true);
  93. if (onFocus) {
  94. onFocus(e);
  95. }
  96. }
  97. const handleBlur: React.FocusEventHandler<HTMLInputElement> = (e) => {
  98. setFocused(false);
  99. if (onBlur) {
  100. onBlur(e);
  101. }
  102. }
  103. const handleSelect: React.ReactEventHandler<HTMLInputElement> = (e) => {
  104. const target = e.target as HTMLInputElement;
  105. const newSelectionStart = target.selectionStart ?? 0;
  106. const newSelectionEnd = target.selectionEnd ?? 0;
  107. const newDirection = Math.sign(newSelectionStart - selectionStart) > 0 ? 'forward' : 'backward'
  108. setSelectionStart(newSelectionStart);
  109. setSelectionEnd(newSelectionEnd);
  110. console.log(newDirection);
  111. const lastSeparatorIndex = target.value.lastIndexOf(separator);
  112. const separatorStartRaw = newSelectionStart > lastSeparatorIndex ? newSelectionStart : target.value.slice(0, newSelectionEnd).lastIndexOf(separator) + separator?.length;
  113. const separatorEndRaw = newSelectionEnd > lastSeparatorIndex ? newSelectionEnd : target.value.slice(0, newSelectionEnd).length + target.value.slice(newSelectionEnd).indexOf(separator);
  114. let separatorStart = 0;
  115. let separatorEnd;
  116. if (lastSeparatorIndex > -1) {
  117. separatorStart = separatorStartRaw > -1 ? separatorStartRaw : 0;
  118. }
  119. separatorEnd = separatorEndRaw;
  120. if (newSelectionStart <= target.value.lastIndexOf(separator)) {
  121. if (newSelectionStart === newSelectionEnd && newSelectionStart === separatorStart && newDirection === 'backward') {
  122. target.selectionStart = newSelectionStart - separator?.length;
  123. target.selectionEnd = newSelectionStart - separator?.length;
  124. target.selectionDirection = newDirection;
  125. } else if (newSelectionStart === newSelectionEnd && newSelectionEnd === separatorEnd && newDirection === 'forward') {
  126. target.selectionStart = newSelectionEnd + separator?.length;
  127. target.selectionEnd = newSelectionEnd + separator?.length;
  128. target.selectionDirection = newDirection;
  129. } else {
  130. target.selectionStart = separatorStart;
  131. target.selectionEnd = separatorEnd;
  132. target.selectionDirection = 'backward';
  133. }
  134. }
  135. if (onSelect) {
  136. onSelect(e);
  137. }
  138. }
  139. const focusOnInput: React.MouseEventHandler<HTMLDivElement> = (e) => {
  140. e.preventDefault();
  141. if (typeof effectiveRef === 'function') {
  142. return;
  143. }
  144. if (effectiveRef !== null && effectiveRef.current) {
  145. effectiveRef.current.focus();
  146. }
  147. }
  148. const tags = React.useMemo(() => viewValue.slice(0, -1), [viewValue])
  149. const inputText = React.useMemo(() => viewValue.slice(-1)[0] ?? '', [viewValue])
  150. React.useEffect(() => {
  151. setHydrated(true)
  152. }, []);
  153. return (
  154. <div
  155. className={TextControlBase.Root(styleArgs)}
  156. >
  157. <input
  158. {...etcProps}
  159. className={TextControlBase.Input(styleArgs)}
  160. ref={effectiveRef}
  161. aria-label={label}
  162. style={{
  163. height: TextControlBase.MIN_HEIGHTS[size],
  164. // position: renderEnhanced ? 'absolute' : undefined,
  165. // left: renderEnhanced ? -999999 : undefined,
  166. }}
  167. data-testid="input"
  168. onInput={handleInput}
  169. onFocus={handleFocus}
  170. onBlur={handleBlur}
  171. onSelect={handleSelect}
  172. />
  173. <div
  174. className={TextControlBase.Input(styleArgs)}
  175. onClick={focusOnInput}
  176. style={{
  177. cursor: 'text',
  178. }}
  179. >
  180. <div
  181. style={{
  182. margin: '-0.125rem',
  183. }}
  184. >
  185. {tags.map(v => (
  186. <div
  187. style={{
  188. padding: '0.125rem',
  189. display: 'inline-block',
  190. }}
  191. key={v}
  192. >
  193. <button
  194. className={BadgeBase.Root({ rounded: false })}
  195. style={{
  196. border: 0,
  197. font: 'inherit',
  198. lineHeight: 0,
  199. paddingTop: 0,
  200. paddingBottom: 0,
  201. color: 'inherit',
  202. backgroundColor: 'transparent',
  203. }}
  204. >
  205. <div
  206. className={BadgeBase.Content()}
  207. >
  208. {v}
  209. {' '}
  210. &times;
  211. </div>
  212. </button>
  213. </div>
  214. ))}
  215. {
  216. inputText.lastIndexOf(separator) < 0
  217. && (
  218. <>
  219. {
  220. inputText.slice(0, selectionStart - tags.join(separator).length - separator?.length)
  221. }
  222. <div
  223. style={{
  224. display: 'inline-block',
  225. verticalAlign: 'middle',
  226. backgroundColor: focused ? 'Highlight' : undefined,
  227. color: focused ? 'HighlightText' : undefined,
  228. height: '1.25em',
  229. minWidth: 1,
  230. }}
  231. >
  232. {
  233. inputText.slice(selectionStart - tags.join(separator).length - separator?.length, selectionEnd - tags.join(separator).length - separator?.length)
  234. }
  235. </div>
  236. {
  237. inputText.slice(selectionEnd - tags.join(separator).length - separator?.length)
  238. }
  239. </>
  240. )
  241. }
  242. {
  243. inputText.lastIndexOf(separator) >= 0
  244. && (
  245. <>
  246. {
  247. inputText.slice(0, selectionStart - tags.join(separator).length)
  248. }
  249. <div
  250. style={{
  251. display: 'inline-block',
  252. verticalAlign: 'middle',
  253. backgroundColor: focused ? 'Highlight' : undefined,
  254. color: focused ? 'HighlightText' : undefined,
  255. height: '1.25em',
  256. minWidth: 1,
  257. }}
  258. >
  259. {
  260. inputText.slice(selectionStart - tags.join(separator).length - 1, selectionEnd - tags.join(separator).length - 1)
  261. }
  262. </div>
  263. {
  264. inputText.slice(selectionEnd - tags.join(separator).length - 1)
  265. }
  266. </>
  267. )
  268. }
  269. </div>
  270. </div>
  271. {
  272. border && (
  273. <span
  274. data-testid="border"
  275. />
  276. )
  277. }
  278. {
  279. label && !hiddenLabel && (
  280. <div
  281. data-testid="label"
  282. className={TextControlBase.LabelWrapper(styleArgs)}
  283. >
  284. {label}
  285. </div>
  286. )
  287. }
  288. {hint && (
  289. <div
  290. className={TextControlBase.HintWrapper(styleArgs)}
  291. data-testid="hint"
  292. >
  293. <div
  294. className={TextControlBase.Hint()}
  295. >
  296. {hint}
  297. </div>
  298. </div>
  299. )}
  300. {indicator && (
  301. <div
  302. className={TextControlBase.IndicatorWrapper(styleArgs)}
  303. >
  304. {indicator}
  305. </div>
  306. )}
  307. </div>
  308. );
  309. }));
  310. TagInput.displayName = 'TagInput';