|
- import * as React from 'react';
- import { TextControl, tailwind } from '@tesseract-design/web-base';
- import { useBrowser, useClientSide, useFallbackId } from '@modal-sh/react-utils';
-
- const { tw } = tailwind;
-
- const NumberSpinnerDerivedElementComponent = 'input' as const;
-
- /**
- * Derived HTML element of the {@link NumberSpinner} component.
- */
- export type NumberSpinnerDerivedElement = HTMLElementTagNameMap[
- typeof NumberSpinnerDerivedElementComponent
- ];
-
- const NumberSpinnerActionElementComponent = 'button' as const;
-
- type NumberSpinnerActionElement = HTMLElementTagNameMap[
- typeof NumberSpinnerActionElementComponent
- ];
-
- /**
- * Props of the {@link NumberSpinner} component.
- */
- export interface NumberSpinnerProps extends Omit<React.HTMLProps<NumberSpinnerDerivedElement>, 'size' | 'type' | 'label'> {
- /**
- * Short textual description indicating the nature of the component's value.
- */
- label?: React.ReactNode,
- /**
- * Short textual description as guidelines for valid input values.
- */
- hint?: React.ReactNode,
- /**
- * Size of the component.
- */
- size?: TextControl.Size,
- /**
- * Should the component display a border?
- */
- border?: boolean,
- /**
- * Should the component occupy the whole width of its parent?
- */
- block?: boolean,
- /**
- * Style of the component.
- */
- variant?: TextControl.Variant,
- /**
- * Is the label hidden?
- */
- hiddenLabel?: boolean,
- /**
- * Visual length of the input.
- */
- length?: number,
- /**
- * Should the component be enhanced?
- */
- enhanced?: boolean,
- /**
- * Interval between steps in milliseconds.
- */
- stepInterval?: number,
- /**
- * Delay before the first step in milliseconds.
- */
- initialStepDelay?: number,
- }
-
- export const numberSpinnerPlugin: tailwind.PluginCreator = ({ addComponents, }) => {
- addComponents({
- '.number-spinner': {
- '&[data-browser="chrome"] > input::-webkit-inner-spin-button': {
- 'position': 'absolute',
- 'top': '0',
- 'right': '0',
- 'height': '100%',
- 'width': '1.5rem',
- 'z-index': '2',
- },
-
- '&[data-browser="chrome"][data-enhanced] > input::-webkit-inner-spin-button': {
- 'display': 'none',
- },
-
- '&[data-browser="firefox"][data-enhanced] > input[type="number"]': {
- 'appearance': 'textfield',
- },
- },
- });
- };
-
- /**
- * Component for inputting discrete numeric values.
- */
- export const NumberSpinner = React.forwardRef<NumberSpinnerDerivedElement, NumberSpinnerProps>((
- {
- label,
- hint,
- size = 'medium' as const,
- border = false as const,
- block = false as const,
- variant = 'default' as const,
- hiddenLabel = false as const,
- className,
- id: idProp,
- style,
- length,
- enhanced: enhancedProp = false as const,
- stepInterval = 100 as const,
- initialStepDelay = 400 as const,
- ...etcProps
- }: NumberSpinnerProps,
- forwardedRef,
- ) => {
- const { clientSide: indicator } = useClientSide({ clientSide: enhancedProp });
- const labelId = React.useId();
- const id = useFallbackId(idProp);
- const browser = useBrowser();
- const defaultRef = React.useRef<NumberSpinnerDerivedElement>(null);
- const ref = forwardedRef ?? defaultRef;
- const intervalRef = React.useRef<number | undefined>();
- const clickYRef = React.useRef<number>();
- const spinnerYRef = React.useRef<number>();
- const spinEventSource = React.useRef<'mouse' | 'keyboard'>();
- const [displayStep, setDisplayStep] = React.useState<boolean>();
-
- const performStep = (
- input: NumberSpinnerDerivedElement,
- theStepUpMode?: boolean,
- ) => {
- if (typeof theStepUpMode !== 'boolean') {
- return;
- }
- const current = input;
- const theStep = current.step ?? 'any';
- current.step = theStep === 'any' ? '1' : theStep;
- if (theStepUpMode) {
- current.stepUp();
- } else {
- current.stepDown();
- }
- current.dispatchEvent(new Event('change', { bubbles: true }));
- current.step = theStep;
- current.focus();
- };
-
- const windowMouseMove = (e: MouseEvent) => {
- if (spinEventSource.current !== 'mouse') {
- return;
- }
- clickYRef.current = e.pageY;
- };
-
- const checkMouseStepUpMode = () => {
- if (typeof clickYRef.current !== 'number' || typeof spinnerYRef.current !== 'number') {
- return undefined;
- }
- return clickYRef.current < spinnerYRef.current;
- };
-
- const doStepMouse: React.MouseEventHandler<NumberSpinnerActionElement> = (e) => {
- if (spinEventSource.current === 'keyboard') {
- return;
- }
- const { current } = typeof ref === 'object' ? ref : defaultRef;
- if (!current) {
- return;
- }
- spinEventSource.current = 'mouse';
- const { top, bottom } = e.currentTarget.getBoundingClientRect();
- const { pageY } = e;
- spinnerYRef.current = top + ((bottom - top) / 2);
- clickYRef.current = pageY;
- window.addEventListener('mousemove', windowMouseMove);
- setTimeout(() => {
- clearInterval(intervalRef.current);
- const stepUpMode = checkMouseStepUpMode();
- setDisplayStep(stepUpMode);
- performStep(current, stepUpMode);
- intervalRef.current = window.setTimeout(() => {
- const stepUpMode = checkMouseStepUpMode();
- setDisplayStep(stepUpMode);
- performStep(current, stepUpMode);
- intervalRef.current = window.setInterval(() => {
- const stepUpMode = checkMouseStepUpMode();
- setDisplayStep(stepUpMode);
- performStep(current, stepUpMode);
- }, stepInterval);
- }, initialStepDelay);
- });
- };
-
- const doStepKeyboard: React.KeyboardEventHandler<NumberSpinnerDerivedElement> = (e) => {
- if (spinEventSource.current === 'mouse') {
- return;
- }
- if (!(e.code === 'ArrowUp' || e.code === 'ArrowDown')) {
- return;
- }
- e.preventDefault();
- spinEventSource.current = 'keyboard';
- const current = e.currentTarget;
- const theStepUpMode = e.code === 'ArrowUp';
- setDisplayStep(theStepUpMode);
- setTimeout(() => {
- clearInterval(intervalRef.current);
- performStep(current, theStepUpMode);
- intervalRef.current = window.setTimeout(() => {
- performStep(current, theStepUpMode);
- intervalRef.current = window.setInterval(() => {
- performStep(current, theStepUpMode);
- }, stepInterval);
- }, initialStepDelay);
- });
- };
-
- React.useEffect(() => {
- const stopStepMouse = () => {
- if (spinEventSource.current === 'keyboard') {
- return;
- }
- clearInterval(intervalRef.current);
- clickYRef.current = undefined;
- spinnerYRef.current = undefined;
- window.removeEventListener('mousemove', windowMouseMove);
- spinEventSource.current = undefined;
- setDisplayStep(undefined);
- };
-
- const stopStepKeyboard = (e: KeyboardEvent) => {
- if (spinEventSource.current === 'mouse') {
- return;
- }
- if (!(e.code === 'ArrowUp' || e.code === 'ArrowDown')) {
- return;
- }
- clearInterval(intervalRef.current);
- spinEventSource.current = undefined;
- setDisplayStep(undefined);
- };
-
- window.addEventListener('mouseup', stopStepMouse, { capture: true });
- window.addEventListener('keyup', stopStepKeyboard, { capture: true });
- return () => {
- window.removeEventListener('mouseup', stopStepMouse, { capture: true });
- window.addEventListener('keyup', stopStepKeyboard, { capture: true });
- };
- }, []);
-
- return (
- <div
- className={tw(
- 'relative rounded ring-secondary/50 group has-[:disabled]:opacity-50',
- 'focus-within:ring-4',
- 'number-spinner',
- {
- 'block': block,
- 'inline-block align-middle': !block,
- },
- className,
- )}
- style={style}
- data-testid="base"
- data-browser={browser}
- data-enhanced={indicator}
- >
- {label && (
- <>
- <label
- data-testid="label"
- id={labelId}
- htmlFor={id}
- className={tw(
- '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',
- {
- 'sr-only': hiddenLabel,
- },
- {
- 'pr-1': !indicator,
- },
- {
- 'pr-10': indicator && size === 'small',
- 'pr-12': indicator && size === 'medium',
- 'pr-16': indicator && size === 'large',
- },
- )}
- >
- <span className="block w-full whitespace-nowrap h-[1.1em] overflow-hidden text-ellipsis">
- {label}
- </span>
- </label>
- {' '}
- </>
- )}
- <NumberSpinnerDerivedElementComponent
- {...etcProps}
- size={length}
- ref={typeof ref === 'function' ? defaultRef : ref}
- id={id}
- aria-labelledby={labelId}
- type="number"
- data-testid="input"
- onKeyDown={doStepKeyboard}
- className={tw(
- 'bg-negative rounded-inherit w-full peer block tabular-nums font-inherit relative',
- 'focus:outline-0',
- 'disabled:opacity-50 disabled:cursor-not-allowed',
- {
- 'text-xxs': size === 'small',
- 'text-xs': size === 'medium',
- },
- {
- 'pl-4': variant === 'default',
- 'pl-1.5': variant === 'alternate',
- },
- {
- 'pt-4': variant === 'alternate',
- },
- {
- 'pr-4': variant === 'default' && !indicator,
- 'pr-1.5': variant === 'alternate' && !indicator,
- },
- {
- 'pr-10': indicator && size === 'small',
- 'pr-12': indicator && size === 'medium',
- 'pr-16': indicator && size === 'large',
- },
- {
- 'h-10': size === 'small',
- 'h-12': size === 'medium',
- 'h-16': size === 'large',
- },
- )}
- />
- {hint && (
- <div
- data-testid="hint"
- className={tw(
- 'absolute left-0 px-1 pointer-events-none text-xxs leading-none w-full bg-negative select-none text-primary',
- {
- 'bottom-0 pl-4 pb-1': variant === 'default',
- 'top-0.5': variant === 'alternate',
- },
- {
- 'pt-2': variant === 'alternate' && size === 'small',
- 'pt-3': variant === 'alternate' && size !== 'small',
- },
- {
- 'pr-4': !indicator && variant === 'default',
- 'pr-1': !indicator && variant === 'alternate',
- },
- {
- 'pr-10': indicator && size === 'small',
- 'pr-12': indicator && size === 'medium',
- 'pr-16': indicator && size === 'large',
- },
- )}
- >
- <div
- className="opacity-50 whitespace-nowrap w-full h-[1.1em] overflow-hidden text-ellipsis"
- >
- {hint}
- </div>
- </div>
- )}
- {indicator && (
- <NumberSpinnerActionElementComponent
- data-testid="indicator"
- type="button"
- className={tw(
- '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',
- {
- 'w-10': size === 'small',
- 'w-12': size === 'medium',
- 'w-16': size === 'large',
- },
- )}
- onMouseDown={doStepMouse}
- >
- <span
- className={tw(
- 'h-0 flex-auto flex justify-center items-end overflow-hidden',
- {
- 'text-tertiary': displayStep === true,
- 'text-inherit': displayStep !== true,
- },
- )}
- >
- <svg
- className="w-6 h-6 fill-none stroke-current stroke-2 linejoin-round linecap-round"
- viewBox="0 0 24 24"
- role="presentation"
- >
- <polyline points="18 15 12 9 6 15" />
- </svg>
- <span className="sr-only">
- Step Up
- </span>
- </span>
- <span className="sr-only">
- /
- </span>
- <span
- className={tw(
- 'h-0 flex-auto flex justify-center items-end overflow-hidden',
- {
- 'text-tertiary': displayStep === false,
- 'text-inherit': displayStep !== false,
- },
- )}
- >
- <svg
- className="w-6 h-6 fill-none stroke-current stroke-2 linejoin-round linecap-round"
- viewBox="0 0 24 24"
- role="presentation"
- >
- <polyline points="6 9 12 15 18 9" />
- </svg>
- <span className="sr-only">
- Step Down
- </span>
- </span>
- </NumberSpinnerActionElementComponent>
- )}
- {border && (
- <span
- data-testid="border"
- className="absolute z-[1] inset-0 rounded-inherit border-2 border-primary pointer-events-none group-focus-within:border-secondary"
- />
- )}
- </div>
- );
- });
-
- NumberSpinner.displayName = 'NumberSpinner';
-
- NumberSpinner.defaultProps = {
- label: undefined,
- hint: undefined,
- border: false as const,
- block: false as const,
- hiddenLabel: false as const,
- size: 'medium' as const,
- variant: 'default' as const,
- length: undefined,
- enhanced: false as const,
- stepInterval: 100 as const,
- initialStepDelay: 400 as const,
- };
|