Design system.
Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.
 
 
 

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