Design system.
Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.
 
 
 

257 righe
7.1 KiB

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