Design system.
25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

265 lines
7.1 KiB

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