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.
 
 
 

453 rivejä
13 KiB

  1. import * as React from 'react';
  2. import { TextControl, tailwind } from '@tesseract-design/web-base';
  3. import { useBrowser, useClientSide, useFallbackId } from '@modal-sh/react-utils';
  4. const { tw } = tailwind;
  5. const NumberSpinnerDerivedElementComponent = 'input' as const;
  6. /**
  7. * Derived HTML element of the {@link NumberSpinner} component.
  8. */
  9. export type NumberSpinnerDerivedElement = HTMLElementTagNameMap[
  10. typeof NumberSpinnerDerivedElementComponent
  11. ];
  12. const NumberSpinnerActionElementComponent = 'button' as const;
  13. type NumberSpinnerActionElement = HTMLElementTagNameMap[
  14. typeof NumberSpinnerActionElementComponent
  15. ];
  16. /**
  17. * Props of the {@link NumberSpinner} component.
  18. */
  19. export interface NumberSpinnerProps extends Omit<React.HTMLProps<NumberSpinnerDerivedElement>, 'size' | 'type' | 'label'> {
  20. /**
  21. * Short textual description indicating the nature of the component's value.
  22. */
  23. label?: React.ReactNode,
  24. /**
  25. * Short textual description as guidelines for valid input values.
  26. */
  27. hint?: React.ReactNode,
  28. /**
  29. * Size of the component.
  30. */
  31. size?: TextControl.Size,
  32. /**
  33. * Should the component display a border?
  34. */
  35. border?: boolean,
  36. /**
  37. * Should the component occupy the whole width of its parent?
  38. */
  39. block?: boolean,
  40. /**
  41. * Style of the component.
  42. */
  43. variant?: TextControl.Variant,
  44. /**
  45. * Is the label hidden?
  46. */
  47. hiddenLabel?: boolean,
  48. /**
  49. * Visual length of the input.
  50. */
  51. length?: number,
  52. /**
  53. * Should the component be enhanced?
  54. */
  55. enhanced?: boolean,
  56. /**
  57. * Interval between steps in milliseconds.
  58. */
  59. stepInterval?: number,
  60. /**
  61. * Delay before the first step in milliseconds.
  62. */
  63. initialStepDelay?: number,
  64. }
  65. export const numberSpinnerPlugin: tailwind.PluginCreator = ({ addComponents, }) => {
  66. addComponents({
  67. '.number-spinner': {
  68. '&[data-browser="chrome"] > input::-webkit-inner-spin-button': {
  69. 'position': 'absolute',
  70. 'top': '0',
  71. 'right': '0',
  72. 'height': '100%',
  73. 'width': '1.5rem',
  74. 'z-index': '2',
  75. },
  76. '&[data-browser="chrome"][data-enhanced] > input::-webkit-inner-spin-button': {
  77. 'display': 'none',
  78. },
  79. '&[data-browser="firefox"][data-enhanced] > input[type="number"]': {
  80. 'appearance': 'textfield',
  81. },
  82. },
  83. });
  84. };
  85. /**
  86. * Component for inputting discrete numeric values.
  87. */
  88. export const NumberSpinner = React.forwardRef<NumberSpinnerDerivedElement, NumberSpinnerProps>((
  89. {
  90. label,
  91. hint,
  92. size = 'medium' as const,
  93. border = false as const,
  94. block = false as const,
  95. variant = 'default' as const,
  96. hiddenLabel = false as const,
  97. className,
  98. id: idProp,
  99. style,
  100. length,
  101. enhanced: enhancedProp = false as const,
  102. stepInterval = 100 as const,
  103. initialStepDelay = 400 as const,
  104. ...etcProps
  105. }: NumberSpinnerProps,
  106. forwardedRef,
  107. ) => {
  108. const { clientSide: indicator } = useClientSide({ clientSide: enhancedProp });
  109. const labelId = React.useId();
  110. const id = useFallbackId(idProp);
  111. const browser = useBrowser();
  112. const defaultRef = React.useRef<NumberSpinnerDerivedElement>(null);
  113. const ref = forwardedRef ?? defaultRef;
  114. const intervalRef = React.useRef<number | undefined>();
  115. const clickYRef = React.useRef<number>();
  116. const spinnerYRef = React.useRef<number>();
  117. const spinEventSource = React.useRef<'mouse' | 'keyboard'>();
  118. const [displayStep, setDisplayStep] = React.useState<boolean>();
  119. const performStep = (
  120. input: NumberSpinnerDerivedElement,
  121. theStepUpMode?: boolean,
  122. ) => {
  123. if (typeof theStepUpMode !== 'boolean') {
  124. return;
  125. }
  126. const current = input;
  127. const theStep = current.step ?? 'any';
  128. current.step = theStep === 'any' ? '1' : theStep;
  129. if (theStepUpMode) {
  130. current.stepUp();
  131. } else {
  132. current.stepDown();
  133. }
  134. current.dispatchEvent(new Event('change', { bubbles: true }));
  135. current.step = theStep;
  136. current.focus();
  137. };
  138. const windowMouseMove = (e: MouseEvent) => {
  139. if (spinEventSource.current !== 'mouse') {
  140. return;
  141. }
  142. clickYRef.current = e.pageY;
  143. };
  144. const checkMouseStepUpMode = () => {
  145. if (typeof clickYRef.current !== 'number' || typeof spinnerYRef.current !== 'number') {
  146. return undefined;
  147. }
  148. return clickYRef.current < spinnerYRef.current;
  149. };
  150. const doStepMouse: React.MouseEventHandler<NumberSpinnerActionElement> = (e) => {
  151. if (spinEventSource.current === 'keyboard') {
  152. return;
  153. }
  154. const { current } = typeof ref === 'object' ? ref : defaultRef;
  155. if (!current) {
  156. return;
  157. }
  158. spinEventSource.current = 'mouse';
  159. const { top, bottom } = e.currentTarget.getBoundingClientRect();
  160. const { pageY } = e;
  161. spinnerYRef.current = top + ((bottom - top) / 2);
  162. clickYRef.current = pageY;
  163. window.addEventListener('mousemove', windowMouseMove);
  164. setTimeout(() => {
  165. clearInterval(intervalRef.current);
  166. const stepUpMode = checkMouseStepUpMode();
  167. setDisplayStep(stepUpMode);
  168. performStep(current, stepUpMode);
  169. intervalRef.current = window.setTimeout(() => {
  170. const stepUpMode = checkMouseStepUpMode();
  171. setDisplayStep(stepUpMode);
  172. performStep(current, stepUpMode);
  173. intervalRef.current = window.setInterval(() => {
  174. const stepUpMode = checkMouseStepUpMode();
  175. setDisplayStep(stepUpMode);
  176. performStep(current, stepUpMode);
  177. }, stepInterval);
  178. }, initialStepDelay);
  179. });
  180. };
  181. const doStepKeyboard: React.KeyboardEventHandler<NumberSpinnerDerivedElement> = (e) => {
  182. if (spinEventSource.current === 'mouse') {
  183. return;
  184. }
  185. if (!(e.code === 'ArrowUp' || e.code === 'ArrowDown')) {
  186. return;
  187. }
  188. e.preventDefault();
  189. spinEventSource.current = 'keyboard';
  190. const current = e.currentTarget;
  191. const theStepUpMode = e.code === 'ArrowUp';
  192. setDisplayStep(theStepUpMode);
  193. setTimeout(() => {
  194. clearInterval(intervalRef.current);
  195. performStep(current, theStepUpMode);
  196. intervalRef.current = window.setTimeout(() => {
  197. performStep(current, theStepUpMode);
  198. intervalRef.current = window.setInterval(() => {
  199. performStep(current, theStepUpMode);
  200. }, stepInterval);
  201. }, initialStepDelay);
  202. });
  203. };
  204. React.useEffect(() => {
  205. const stopStepMouse = () => {
  206. if (spinEventSource.current === 'keyboard') {
  207. return;
  208. }
  209. clearInterval(intervalRef.current);
  210. clickYRef.current = undefined;
  211. spinnerYRef.current = undefined;
  212. window.removeEventListener('mousemove', windowMouseMove);
  213. spinEventSource.current = undefined;
  214. setDisplayStep(undefined);
  215. };
  216. const stopStepKeyboard = (e: KeyboardEvent) => {
  217. if (spinEventSource.current === 'mouse') {
  218. return;
  219. }
  220. if (!(e.code === 'ArrowUp' || e.code === 'ArrowDown')) {
  221. return;
  222. }
  223. clearInterval(intervalRef.current);
  224. spinEventSource.current = undefined;
  225. setDisplayStep(undefined);
  226. };
  227. window.addEventListener('mouseup', stopStepMouse, { capture: true });
  228. window.addEventListener('keyup', stopStepKeyboard, { capture: true });
  229. return () => {
  230. window.removeEventListener('mouseup', stopStepMouse, { capture: true });
  231. window.addEventListener('keyup', stopStepKeyboard, { capture: true });
  232. };
  233. }, []);
  234. return (
  235. <div
  236. className={tw(
  237. 'relative rounded ring-secondary/50 group has-[:disabled]:opacity-50',
  238. 'focus-within:ring-4',
  239. 'number-spinner',
  240. {
  241. 'block': block,
  242. 'inline-block align-middle': !block,
  243. },
  244. className,
  245. )}
  246. style={style}
  247. data-testid="base"
  248. data-browser={browser}
  249. data-enhanced={indicator}
  250. >
  251. {label && (
  252. <>
  253. <label
  254. data-testid="label"
  255. id={labelId}
  256. htmlFor={id}
  257. className={tw(
  258. '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',
  259. {
  260. 'sr-only': hiddenLabel,
  261. },
  262. {
  263. 'pr-1': !indicator,
  264. },
  265. {
  266. 'pr-10': indicator && size === 'small',
  267. 'pr-12': indicator && size === 'medium',
  268. 'pr-16': indicator && size === 'large',
  269. },
  270. )}
  271. >
  272. <span className="block w-full whitespace-nowrap h-[1.1em] overflow-hidden text-ellipsis">
  273. {label}
  274. </span>
  275. </label>
  276. {' '}
  277. </>
  278. )}
  279. <NumberSpinnerDerivedElementComponent
  280. {...etcProps}
  281. size={length}
  282. ref={typeof ref === 'function' ? defaultRef : ref}
  283. id={id}
  284. aria-labelledby={labelId}
  285. type="number"
  286. data-testid="input"
  287. onKeyDown={doStepKeyboard}
  288. className={tw(
  289. 'bg-negative rounded-inherit w-full peer block tabular-nums font-inherit relative',
  290. 'focus:outline-0',
  291. 'disabled:opacity-50 disabled:cursor-not-allowed',
  292. {
  293. 'text-xxs': size === 'small',
  294. 'text-xs': size === 'medium',
  295. },
  296. {
  297. 'pl-4': variant === 'default',
  298. 'pl-1.5': variant === 'alternate',
  299. },
  300. {
  301. 'pt-4': variant === 'alternate',
  302. },
  303. {
  304. 'pr-4': variant === 'default' && !indicator,
  305. 'pr-1.5': variant === 'alternate' && !indicator,
  306. },
  307. {
  308. 'pr-10': indicator && size === 'small',
  309. 'pr-12': indicator && size === 'medium',
  310. 'pr-16': indicator && size === 'large',
  311. },
  312. {
  313. 'h-10': size === 'small',
  314. 'h-12': size === 'medium',
  315. 'h-16': size === 'large',
  316. },
  317. )}
  318. />
  319. {hint && (
  320. <div
  321. data-testid="hint"
  322. className={tw(
  323. 'absolute left-0 px-1 pointer-events-none text-xxs leading-none w-full bg-negative select-none text-primary',
  324. {
  325. 'bottom-0 pl-4 pb-1': variant === 'default',
  326. 'top-0.5': variant === 'alternate',
  327. },
  328. {
  329. 'pt-2': variant === 'alternate' && size === 'small',
  330. 'pt-3': variant === 'alternate' && size !== 'small',
  331. },
  332. {
  333. 'pr-4': !indicator && variant === 'default',
  334. 'pr-1': !indicator && variant === 'alternate',
  335. },
  336. {
  337. 'pr-10': indicator && size === 'small',
  338. 'pr-12': indicator && size === 'medium',
  339. 'pr-16': indicator && size === 'large',
  340. },
  341. )}
  342. >
  343. <div
  344. className="opacity-50 whitespace-nowrap w-full h-[1.1em] overflow-hidden text-ellipsis"
  345. >
  346. {hint}
  347. </div>
  348. </div>
  349. )}
  350. {indicator && (
  351. <NumberSpinnerActionElementComponent
  352. data-testid="indicator"
  353. type="button"
  354. className={tw(
  355. 'text-center z-[1] flex flex-col items-stretch justify-center aspect-square absolute bottom-0 right-0 select-none text-primary group-focus-within:text-secondary',
  356. {
  357. 'w-10': size === 'small',
  358. 'w-12': size === 'medium',
  359. 'w-16': size === 'large',
  360. },
  361. )}
  362. onMouseDown={doStepMouse}
  363. >
  364. <span
  365. className={tw(
  366. 'h-0 flex-auto flex justify-center items-end overflow-hidden',
  367. {
  368. 'text-tertiary': displayStep === true,
  369. 'text-inherit': displayStep !== true,
  370. },
  371. )}
  372. >
  373. <svg
  374. className="w-6 h-6 fill-none stroke-current stroke-2 linejoin-round linecap-round"
  375. viewBox="0 0 24 24"
  376. role="presentation"
  377. >
  378. <polyline points="18 15 12 9 6 15" />
  379. </svg>
  380. <span className="sr-only">
  381. Step Up
  382. </span>
  383. </span>
  384. <span className="sr-only">
  385. /
  386. </span>
  387. <span
  388. className={tw(
  389. 'h-0 flex-auto flex justify-center items-end overflow-hidden',
  390. {
  391. 'text-tertiary': displayStep === false,
  392. 'text-inherit': displayStep !== false,
  393. },
  394. )}
  395. >
  396. <svg
  397. className="w-6 h-6 fill-none stroke-current stroke-2 linejoin-round linecap-round"
  398. viewBox="0 0 24 24"
  399. role="presentation"
  400. >
  401. <polyline points="6 9 12 15 18 9" />
  402. </svg>
  403. <span className="sr-only">
  404. Step Down
  405. </span>
  406. </span>
  407. </NumberSpinnerActionElementComponent>
  408. )}
  409. {border && (
  410. <span
  411. data-testid="border"
  412. className="absolute z-[1] inset-0 rounded-inherit border-2 border-primary pointer-events-none group-focus-within:border-secondary"
  413. />
  414. )}
  415. </div>
  416. );
  417. });
  418. NumberSpinner.displayName = 'NumberSpinner';
  419. NumberSpinner.defaultProps = {
  420. label: undefined,
  421. hint: undefined,
  422. border: false as const,
  423. block: false as const,
  424. hiddenLabel: false as const,
  425. size: 'medium' as const,
  426. variant: 'default' as const,
  427. length: undefined,
  428. enhanced: false as const,
  429. stepInterval: 100 as const,
  430. initialStepDelay: 400 as const,
  431. };