Design system.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

342 line
10 KiB

  1. import * as React from 'react';
  2. import { TextControl, tailwind } from '@tesseract-design/web-base';
  3. import { useClientSide, useFallbackId } from '@modal-sh/react-utils';
  4. const { tw } = tailwind;
  5. const MaskedTextInputDerivedElementComponent = 'input' as const;
  6. /**
  7. * Derived HTML element of the {@link MaskedTextInput} component.
  8. */
  9. export type MaskedTextInputDerivedElement = HTMLElementTagNameMap[
  10. typeof MaskedTextInputDerivedElementComponent
  11. ];
  12. type SelectionDirection = 'none' | 'forward' | 'backward';
  13. const AVAILABLE_AUTO_COMPLETE_VALUES = ['current-password', 'new-password'] as const;
  14. /**
  15. * Props of the {@link MaskedTextInput} component.
  16. */
  17. export interface MaskedTextInputProps extends Omit<React.HTMLProps<MaskedTextInputDerivedElement>, 'autoComplete' | 'size' | 'type' | 'label' | 'pattern'> {
  18. /**
  19. * Short textual description indicating the nature of the component's value.
  20. */
  21. label?: React.ReactNode,
  22. /**
  23. * Short textual description as guidelines for valid input values.
  24. */
  25. hint?: React.ReactNode,
  26. /**
  27. * Size of the component.
  28. */
  29. size?: TextControl.Size,
  30. /**
  31. * Should the component display a border?
  32. */
  33. border?: boolean,
  34. /**
  35. * Should the component occupy the whole width of its parent?
  36. */
  37. block?: boolean,
  38. /**
  39. * Style of the component.
  40. */
  41. variant?: TextControl.Variant,
  42. /**
  43. * Is the label hidden?
  44. */
  45. hiddenLabel?: boolean,
  46. /**
  47. * Visual length of the input.
  48. */
  49. length?: number,
  50. /**
  51. * Should the component be enhanced?
  52. */
  53. enhanced?: boolean,
  54. /**
  55. * Autocomplete value type of the component.
  56. */
  57. autoComplete?: typeof AVAILABLE_AUTO_COMPLETE_VALUES[number],
  58. }
  59. /**
  60. * Component for inputting textual values.
  61. */
  62. export const MaskedTextInput = React.forwardRef<
  63. MaskedTextInputDerivedElement,
  64. MaskedTextInputProps
  65. >((
  66. {
  67. label,
  68. hint,
  69. size = 'medium' as const,
  70. border = false as const,
  71. block = false as const,
  72. variant = 'default' as const,
  73. hiddenLabel = false as const,
  74. className,
  75. id: idProp,
  76. style,
  77. length,
  78. disabled,
  79. onKeyDown,
  80. onKeyUp,
  81. autoComplete,
  82. enhanced: enhancedProp = false as const,
  83. ...etcProps
  84. }: MaskedTextInputProps,
  85. forwardedRef,
  86. ) => {
  87. const { clientSide: enhanced } = useClientSide({ clientSide: enhancedProp });
  88. const labelId = React.useId();
  89. const id = useFallbackId(idProp);
  90. const [visible, setVisible] = React.useState(false);
  91. const [visibleViaKey, setVisibleViaKey] = React.useState(false);
  92. const defaultRef = React.useRef<MaskedTextInputDerivedElement>(null);
  93. const ref = forwardedRef ?? defaultRef;
  94. const handleKeyDown: React.KeyboardEventHandler<
  95. MaskedTextInputDerivedElement
  96. > = React.useCallback((e) => {
  97. if (e.ctrlKey && e.code === 'Space') {
  98. setVisibleViaKey(true);
  99. }
  100. onKeyDown?.(e);
  101. }, [onKeyDown]);
  102. const handleKeyUp: React.KeyboardEventHandler<
  103. MaskedTextInputDerivedElement
  104. > = React.useCallback((e) => {
  105. if (e.ctrlKey && e.code === 'Space') {
  106. setVisibleViaKey(false);
  107. setVisible((prev) => !prev);
  108. }
  109. onKeyUp?.(e);
  110. }, [onKeyUp]);
  111. const handleToggleVisible = React.useCallback(() => {
  112. const effectiveRef = typeof ref === 'object' ? ref : defaultRef;
  113. const current = effectiveRef.current as MaskedTextInputDerivedElement;
  114. const selectionStart = current.selectionStart as number;
  115. const selectionEnd = current.selectionEnd as number;
  116. const selectionDirection = current.selectionDirection as SelectionDirection;
  117. setVisible((prev) => !prev);
  118. setTimeout(() => {
  119. current.focus();
  120. current.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
  121. });
  122. }, [ref, defaultRef]);
  123. // const preserveFocus: React.MouseEventHandler<HTMLButtonElement> = React.useCallback(() => {
  124. // const { current } = typeof ref === 'object' ? ref : defaultRef;
  125. // setVisibleViaKey(true);
  126. // setTimeout(() => {
  127. // current?.focus();
  128. // });
  129. // }, [ref, defaultRef]);
  130. // const handleToggleMouseUp: React.MouseEventHandler<HTMLButtonElement> = React.useCallback(() => {
  131. // const { current } = typeof ref === 'object' ? ref : defaultRef;
  132. // setVisibleViaKey(false);
  133. // setTimeout(() => {
  134. // current?.focus();
  135. // });
  136. // }, [ref, defaultRef]);
  137. React.useEffect(() => {
  138. if (typeof ref === 'function') {
  139. const defaultElement = defaultRef.current as MaskedTextInputDerivedElement;
  140. ref(defaultElement);
  141. }
  142. }, [defaultRef, ref]);
  143. return (
  144. <div
  145. className={tw(
  146. 'relative rounded ring-secondary/50 overflow-hidden group has-[:disabled]:opacity-50',
  147. 'focus-within:ring-4',
  148. {
  149. 'block': block,
  150. 'inline-block align-middle': !block,
  151. },
  152. className,
  153. )}
  154. style={style}
  155. data-testid="base"
  156. >
  157. {label && (
  158. <>
  159. <label
  160. data-testid="label"
  161. id={labelId}
  162. htmlFor={id}
  163. className={tw(
  164. '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',
  165. {
  166. 'sr-only': hiddenLabel,
  167. },
  168. {
  169. 'pr-1': !enhanced,
  170. },
  171. {
  172. 'pr-10': enhanced && size === 'small',
  173. 'pr-12': enhanced && size === 'medium',
  174. 'pr-16': enhanced && size === 'large',
  175. },
  176. )}
  177. >
  178. <span className="block w-full whitespace-nowrap h-[1.1em] overflow-hidden text-ellipsis">
  179. {label}
  180. </span>
  181. </label>
  182. {' '}
  183. </>
  184. )}
  185. <MaskedTextInputDerivedElementComponent
  186. {...etcProps}
  187. size={length}
  188. ref={typeof ref === 'function' ? defaultRef : ref}
  189. disabled={disabled}
  190. id={id}
  191. aria-labelledby={labelId}
  192. type={!visible ? 'password' : 'text'}
  193. data-testid="input"
  194. autoComplete={autoComplete ?? 'off'}
  195. onKeyUp={enhanced ? handleKeyUp : undefined}
  196. onKeyDown={enhanced ? handleKeyDown : undefined}
  197. className={tw(
  198. 'bg-negative rounded-inherit w-full peer block font-inherit',
  199. 'focus:outline-0',
  200. 'disabled:opacity-50 disabled:cursor-not-allowed',
  201. {
  202. 'text-xxs': size === 'small',
  203. 'text-xs': size === 'medium',
  204. },
  205. {
  206. 'pl-4': variant === 'default',
  207. 'pl-1.5': variant === 'alternate',
  208. },
  209. {
  210. 'pt-4': variant === 'alternate',
  211. },
  212. {
  213. 'pr-4': variant === 'default' && !enhanced,
  214. 'pr-1.5': variant === 'alternate' && !enhanced,
  215. },
  216. {
  217. 'pr-10': enhanced && size === 'small',
  218. 'pr-12': enhanced && size === 'medium',
  219. 'pr-16': enhanced && size === 'large',
  220. },
  221. {
  222. 'h-10': size === 'small',
  223. 'h-12': size === 'medium',
  224. 'h-16': size === 'large',
  225. },
  226. )}
  227. />
  228. {hint && (
  229. <div
  230. data-testid="hint"
  231. className={tw(
  232. 'absolute left-0 px-1 pointer-events-none text-xxs leading-none w-full bg-negative select-none',
  233. {
  234. 'bottom-0 pl-4 pb-1': variant === 'default',
  235. 'top-0.5': variant === 'alternate',
  236. },
  237. {
  238. 'pt-2': variant === 'alternate' && size === 'small',
  239. 'pt-3': variant === 'alternate' && size !== 'small',
  240. },
  241. {
  242. 'pr-4': !enhanced && variant === 'default',
  243. 'pr-1': !enhanced && variant === 'alternate',
  244. },
  245. {
  246. 'pr-10': enhanced && size === 'small',
  247. 'pr-12': enhanced && size === 'medium',
  248. 'pr-16': enhanced && size === 'large',
  249. },
  250. )}
  251. >
  252. <div
  253. className="opacity-50 whitespace-nowrap w-full h-[1.1em] overflow-hidden text-ellipsis"
  254. >
  255. {hint}
  256. </div>
  257. </div>
  258. )}
  259. {enhanced && (
  260. <button
  261. disabled={disabled}
  262. type="button"
  263. data-testid="indicator"
  264. tabIndex={-1}
  265. className={tw(
  266. 'text-center z-[1] focus:outline-0 flex items-center justify-center aspect-square absolute bottom-0 right-0 select-none',
  267. {
  268. 'text-primary group-focus-within:text-secondary group-focus-within:active:text-tertiary': !visibleViaKey,
  269. 'text-tertiary': visibleViaKey,
  270. },
  271. {
  272. 'w-10': size === 'small',
  273. 'w-12': size === 'medium',
  274. 'w-16': size === 'large',
  275. },
  276. )}
  277. onClick={handleToggleVisible}
  278. title={visible ? 'Hide' : 'Show'}
  279. >
  280. <span className="sr-only">
  281. {visible ? 'Hide' : 'Show'}
  282. </span>
  283. <svg
  284. className="w-6 h-6 fill-none stroke-current stroke-2 linejoin-round linecap-round"
  285. viewBox="0 0 24 24"
  286. role="presentation"
  287. >
  288. {!visible && (
  289. <>
  290. <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
  291. <circle cx="12" cy="12" r="3" />
  292. </>
  293. )}
  294. {visible && (
  295. <>
  296. <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
  297. <line x1="1" y1="1" x2="23" y2="23" />
  298. </>
  299. )}
  300. </svg>
  301. </button>
  302. )}
  303. {border && (
  304. <span
  305. data-testid="border"
  306. className="absolute z-[1] inset-0 rounded-inherit border-2 border-primary pointer-events-none group-focus-within:border-secondary"
  307. />
  308. )}
  309. </div>
  310. );
  311. });
  312. MaskedTextInput.displayName = 'MaskedTextInput';
  313. MaskedTextInput.defaultProps = {
  314. label: undefined,
  315. hint: undefined,
  316. size: 'medium' as const,
  317. border: false as const,
  318. block: false as const,
  319. variant: 'default' as const,
  320. hiddenLabel: false as const,
  321. length: undefined,
  322. enhanced: false as const,
  323. autoComplete: undefined,
  324. };