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.
 
 
 

253 lines
7.0 KiB

  1. import * as React from 'react';
  2. import { TextControl } from '@tesseract-design/web-base';
  3. import { useClientSide, useFallbackId, useProxyInput } from '@modal-sh/react-utils';
  4. import PhoneInput, { Country, Value } from 'react-phone-number-input/input';
  5. import clsx from 'clsx';
  6. /**
  7. * Derived HTML element of the {@link PhoneNumberInput} component.
  8. */
  9. export type PhoneNumberInputDerivedElement = HTMLInputElement;
  10. /**
  11. * Props of the {@link PhoneNumberInput} component.
  12. */
  13. export interface PhoneNumberInputProps extends Omit<React.HTMLProps<PhoneNumberInputDerivedElement>, 'autoComplete' | 'size' | 'type' | 'label' | 'inputMode'> {
  14. /**
  15. * Short textual description indicating the nature of the component's value.
  16. */
  17. label?: React.ReactNode,
  18. /**
  19. * Short textual description as guidelines for valid input values.
  20. */
  21. hint?: React.ReactNode,
  22. /**
  23. * Size of the component.
  24. */
  25. size?: TextControl.Size,
  26. /**
  27. * Should the component display a border?
  28. */
  29. border?: boolean,
  30. /**
  31. * Should the component occupy the whole width of its parent?
  32. */
  33. block?: boolean,
  34. /**
  35. * Style of the component.
  36. */
  37. variant?: TextControl.Variant,
  38. /**
  39. * Is the label hidden?
  40. */
  41. hiddenLabel?: boolean,
  42. /**
  43. * Should the component be enhanced?
  44. */
  45. enhanced?: boolean,
  46. /**
  47. * Country where the phone number should be formatted for.
  48. */
  49. country?: Country,
  50. /**
  51. * Visual length of the input.
  52. */
  53. length?: number,
  54. }
  55. /**
  56. * Component for inputting national and international phone numbers.
  57. */
  58. export const PhoneNumberInput = React.forwardRef<
  59. PhoneNumberInputDerivedElement,
  60. PhoneNumberInputProps
  61. >((
  62. {
  63. label,
  64. hint,
  65. size = 'medium' as const,
  66. border = false as const,
  67. block = false as const,
  68. variant = 'default' as const,
  69. hiddenLabel = false as const,
  70. className,
  71. id: idProp,
  72. style,
  73. enhanced = false as const,
  74. country = 'PH' as const,
  75. value,
  76. onChange,
  77. name,
  78. length,
  79. defaultValue,
  80. ...etcProps
  81. }: PhoneNumberInputProps,
  82. forwardedRef,
  83. ) => {
  84. const { clientSide } = useClientSide({ clientSide: enhanced });
  85. const [phoneNumber, setPhoneNumber] = React.useState<Value>(
  86. value?.toString() ?? defaultValue?.toString() ?? '',
  87. );
  88. const labelId = React.useId();
  89. const id = useFallbackId(idProp);
  90. const {
  91. defaultRef,
  92. handleChange: handlePhoneInputChange,
  93. } = useProxyInput<Value, PhoneNumberInputDerivedElement>({
  94. forwardedRef,
  95. valueSetterFn: (v) => {
  96. setPhoneNumber(v);
  97. },
  98. transformChangeHandlerArgs: (v) => (v ?? '') as unknown as Value,
  99. });
  100. const commonInputStyles = clsx(
  101. 'bg-negative rounded-inherit w-full peer block font-inherit tabular-nums',
  102. 'focus:outline-0',
  103. 'disabled:cursor-not-allowed',
  104. {
  105. 'pl-4': variant === 'default',
  106. 'pl-1.5 pt-4': variant === 'alternate',
  107. },
  108. {
  109. 'pr-10 h-10 text-xxs': size === 'small',
  110. 'pr-12 h-12 text-xs': size === 'medium',
  111. 'pr-16 h-16': size === 'large',
  112. },
  113. );
  114. return (
  115. <div
  116. className={clsx(
  117. 'relative rounded ring-secondary/50 group has-[:disabled]:opacity-50',
  118. 'focus-within:ring-4',
  119. {
  120. 'block': block,
  121. 'inline-block align-middle': !block,
  122. },
  123. className,
  124. )}
  125. style={style}
  126. data-testid="base"
  127. >
  128. {label && (
  129. <>
  130. <label
  131. data-testid="label"
  132. id={labelId}
  133. htmlFor={id}
  134. className={clsx(
  135. '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',
  136. {
  137. 'sr-only': hiddenLabel,
  138. },
  139. {
  140. 'pr-10': size === 'small',
  141. 'pr-12': size === 'medium',
  142. 'pr-16': size === 'large',
  143. },
  144. )}
  145. >
  146. <span className="block w-full whitespace-nowrap h-[1.1em] overflow-hidden text-ellipsis">
  147. {label}
  148. </span>
  149. </label>
  150. {' '}
  151. </>
  152. )}
  153. <input
  154. {...etcProps}
  155. size={length}
  156. value={value}
  157. onChange={onChange}
  158. ref={defaultRef}
  159. defaultValue={defaultValue}
  160. aria-labelledby={labelId}
  161. type="tel"
  162. id={id}
  163. name={name}
  164. data-testid="input"
  165. tabIndex={clientSide ? -1 : undefined}
  166. className={clsx(commonInputStyles, clientSide && 'sr-only')}
  167. />
  168. {clientSide && (
  169. <PhoneInput
  170. {...etcProps}
  171. ref={undefined}
  172. value={phoneNumber}
  173. onChange={handlePhoneInputChange}
  174. defaultCountry={country}
  175. className={commonInputStyles}
  176. />
  177. )}
  178. {hint && (
  179. <div
  180. data-testid="hint"
  181. className={clsx(
  182. 'absolute left-0 px-1 pointer-events-none text-xxs leading-none w-full bg-negative select-none',
  183. {
  184. 'bottom-0 pl-4 pb-1': variant === 'default',
  185. 'top-0.5': variant === 'alternate',
  186. },
  187. {
  188. 'pt-2': variant === 'alternate' && size === 'small',
  189. 'pt-3': variant === 'alternate' && size !== 'small',
  190. },
  191. {
  192. 'pr-10': size === 'small',
  193. 'pr-12': size === 'medium',
  194. 'pr-16': size === 'large',
  195. },
  196. )}
  197. >
  198. <div
  199. className="opacity-50 whitespace-nowrap w-full h-[1.1em] overflow-hidden text-ellipsis"
  200. >
  201. {hint}
  202. </div>
  203. </div>
  204. )}
  205. <div
  206. data-testid="indicator"
  207. className={clsx(
  208. 'text-center flex items-center justify-center aspect-square absolute bottom-0 right-0 pointer-events-none select-none text-primary group-focus-within:text-secondary',
  209. {
  210. 'w-10': size === 'small',
  211. 'w-12': size === 'medium',
  212. 'w-16': size === 'large',
  213. },
  214. )}
  215. >
  216. <svg
  217. className="w-6 h-6 fill-none stroke-current stroke-2 linejoin-round linecap-round"
  218. viewBox="0 0 24 24"
  219. role="presentation"
  220. >
  221. <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
  222. </svg>
  223. </div>
  224. {border && (
  225. <span
  226. data-testid="border"
  227. className="absolute z-[1] inset-0 rounded-inherit border-2 border-primary pointer-events-none peer-focus:border-secondary"
  228. />
  229. )}
  230. </div>
  231. );
  232. });
  233. PhoneNumberInput.displayName = 'PhoneNumberInput';
  234. PhoneNumberInput.defaultProps = {
  235. label: undefined,
  236. hint: undefined,
  237. size: 'medium' as const,
  238. border: false as const,
  239. block: false as const,
  240. variant: 'default' as const,
  241. hiddenLabel: false as const,
  242. enhanced: false as const,
  243. country: 'PH' as const,
  244. length: undefined,
  245. };