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.
 
 
 

257 line
6.9 KiB

  1. import * as React from 'react';
  2. import { TextControl } from '@tesseract-design/web-base';
  3. import clsx from 'clsx';
  4. import plugin from 'tailwindcss/plugin';
  5. import { useFallbackId } from '@modal-sh/react-utils';
  6. /**
  7. * Derived HTML element of the {@link TimeSpinner} component.
  8. */
  9. export type TimeSpinnerDerivedElement = HTMLInputElement;
  10. type Digit = (0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9);
  11. type Segment = `${Digit}${Digit}`;
  12. type StepHhMm = `${Segment}:${Segment}`;
  13. type StepHhMmSs = `${StepHhMm}:${Segment}`;
  14. type Step = StepHhMm | StepHhMmSs;
  15. /**
  16. * Props of the {@link TimeSpinner} component.
  17. */
  18. export interface TimeSpinnerProps extends Omit<React.HTMLProps<TimeSpinnerDerivedElement>, 'size' | 'type' | 'label' | 'step' | 'pattern'> {
  19. /**
  20. * Short textual description indicating the nature of the component's value.
  21. */
  22. label?: React.ReactNode,
  23. /**
  24. * Short textual description as guidelines for valid input values.
  25. */
  26. hint?: React.ReactNode,
  27. /**
  28. * Size of the component.
  29. */
  30. size?: TextControl.Size,
  31. /**
  32. * Should the component display a border?
  33. */
  34. border?: boolean,
  35. /**
  36. * Should the component occupy the whole width of its parent?
  37. */
  38. block?: boolean,
  39. /**
  40. * Style of the component.
  41. */
  42. variant?: TextControl.Variant,
  43. /**
  44. * Is the label hidden?
  45. */
  46. hiddenLabel?: boolean,
  47. /**
  48. * Should the component display seconds?
  49. */
  50. displaySeconds?: boolean,
  51. /**
  52. * Step size for the component.
  53. */
  54. step?: Step,
  55. }
  56. export const timeSpinnerPlugin = plugin(({ addComponents }) => {
  57. addComponents({
  58. '.time-spinner': {
  59. '& > input::-webkit-calendar-picker-indicator': {
  60. 'background-image': 'none',
  61. 'position': 'absolute',
  62. 'bottom': '0',
  63. 'right': '0',
  64. 'height': '100%',
  65. 'padding': '0',
  66. 'aspect-ratio': '1 / 1',
  67. 'cursor': 'inherit',
  68. },
  69. '&[data-size="small"] > input::-webkit-calendar-picker-indicator': {
  70. 'width': '2.5rem',
  71. },
  72. '&[data-size="medium"] > input::-webkit-calendar-picker-indicator': {
  73. 'width': '3rem',
  74. },
  75. '&[data-size="large"] > input::-webkit-calendar-picker-indicator': {
  76. 'width': '4rem',
  77. },
  78. },
  79. });
  80. });
  81. /**
  82. * Component for inputting time values.
  83. */
  84. export const TimeSpinner = React.forwardRef<
  85. TimeSpinnerDerivedElement,
  86. TimeSpinnerProps
  87. >((
  88. {
  89. label,
  90. hint,
  91. size = 'medium' as const,
  92. border = false as const,
  93. block = false as const,
  94. variant = 'default' as const,
  95. hiddenLabel = false as const,
  96. className,
  97. id: idProp,
  98. style,
  99. displaySeconds = false as const,
  100. step = '00:01' as const,
  101. ...etcProps
  102. }: TimeSpinnerProps,
  103. forwardedRef,
  104. ) => {
  105. const labelId = React.useId();
  106. const id = useFallbackId(idProp);
  107. const [hh, mm, ss = 0] = step.split(':').map((s: string) => parseInt(s, 10)) as number[];
  108. const stepValue = ss + (mm * 60) + (hh * 3600);
  109. return (
  110. <div
  111. className={clsx(
  112. 'time-spinner relative rounded ring-secondary/50 overflow-hidden group has-[:disabled]:opacity-50',
  113. 'focus-within:ring-4',
  114. {
  115. 'block': block,
  116. 'inline-block align-middle': !block,
  117. },
  118. className,
  119. )}
  120. style={style}
  121. data-testid="base"
  122. data-size={size}
  123. >
  124. {label && (
  125. <>
  126. <label
  127. data-testid="label"
  128. id={labelId}
  129. htmlFor={id}
  130. className={clsx(
  131. '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',
  132. {
  133. 'sr-only': hiddenLabel,
  134. },
  135. {
  136. 'pr-10': size === 'small',
  137. 'pr-12': size === 'medium',
  138. 'pr-16': size === 'large',
  139. },
  140. )}
  141. >
  142. <span className="block w-full whitespace-nowrap h-[1.1em] overflow-hidden text-ellipsis">
  143. {label}
  144. </span>
  145. </label>
  146. {' '}
  147. </>
  148. )}
  149. <input
  150. {...etcProps}
  151. ref={forwardedRef}
  152. id={id}
  153. aria-labelledby={labelId}
  154. type="time"
  155. data-testid="input"
  156. step={displaySeconds && stepValue > 60 ? 1 : stepValue}
  157. pattern="\d{2}:\d{2}(:\d{2})?"
  158. className={clsx(
  159. 'bg-negative rounded-inherit w-full peer block font-inherit tabular-nums cursor-pointer',
  160. 'focus:outline-0',
  161. 'disabled:cursor-not-allowed',
  162. {
  163. 'pl-4': variant === 'default',
  164. 'pl-1.5 pt-4': variant === 'alternate',
  165. },
  166. {
  167. 'pr-10 h-10 text-xxs': size === 'small',
  168. 'pr-12 h-12 text-xs': size === 'medium',
  169. 'pr-16 h-16': size === 'large',
  170. },
  171. )}
  172. />
  173. {hint && (
  174. <div
  175. data-testid="hint"
  176. className={clsx(
  177. 'absolute left-0 px-1 pointer-events-none text-xxs leading-none w-full bg-negative select-none',
  178. {
  179. 'bottom-0 pl-4 pb-1': variant === 'default',
  180. 'top-0.5': variant === 'alternate',
  181. },
  182. {
  183. 'pt-2': variant === 'alternate' && size === 'small',
  184. 'pt-3': variant === 'alternate' && size !== 'small',
  185. },
  186. {
  187. 'pr-10': size === 'small',
  188. 'pr-12': size === 'medium',
  189. 'pr-16': size === 'large',
  190. },
  191. )}
  192. >
  193. <div
  194. className="opacity-50 whitespace-nowrap w-full h-[1.1em] overflow-hidden text-ellipsis"
  195. >
  196. {hint}
  197. </div>
  198. </div>
  199. )}
  200. <div
  201. className={clsx(
  202. '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',
  203. {
  204. 'w-10': size === 'small',
  205. 'w-12': size === 'medium',
  206. 'w-16': size === 'large',
  207. },
  208. )}
  209. >
  210. <svg
  211. className="w-6 h-6 fill-none stroke-current stroke-2 linejoin-round linecap-round"
  212. viewBox="0 0 24 24"
  213. role="presentation"
  214. >
  215. <circle
  216. cx="12"
  217. cy="12"
  218. r="10"
  219. />
  220. <polyline points="12 6 12 12 16 14" />
  221. </svg>
  222. </div>
  223. {border && (
  224. <span
  225. data-testid="border"
  226. className="absolute z-[1] inset-0 rounded-inherit border-2 border-primary pointer-events-none group-focus-within:border-secondary"
  227. />
  228. )}
  229. </div>
  230. );
  231. });
  232. TimeSpinner.displayName = 'TimeSpinner';
  233. TimeSpinner.defaultProps = {
  234. label: undefined,
  235. hint: undefined,
  236. size: 'medium' as const,
  237. border: false as const,
  238. block: false as const,
  239. variant: 'default' as const,
  240. hiddenLabel: false as const,
  241. displaySeconds: false as const,
  242. step: '00:01' as const,
  243. };