|
|
@@ -2,7 +2,7 @@ import * as React from 'react'; |
|
|
|
import { TextControl } from '@tesseract-design/web-base'; |
|
|
|
import clsx from 'clsx'; |
|
|
|
import plugin from 'tailwindcss/plugin'; |
|
|
|
import {useFallbackId} from '@modal-sh/react-utils'; |
|
|
|
import { useBrowser, useClientSide, useFallbackId } from '@modal-sh/react-utils'; |
|
|
|
|
|
|
|
export type NumberSpinnerDerivedElement = HTMLInputElement; |
|
|
|
|
|
|
@@ -19,10 +19,6 @@ export interface NumberSpinnerProps extends Omit<React.HTMLProps<NumberSpinnerDe |
|
|
|
* Size of the component. |
|
|
|
*/ |
|
|
|
size?: TextControl.Size, |
|
|
|
/** |
|
|
|
* Additional description, usually graphical, indicating the nature of the component's value. |
|
|
|
*/ |
|
|
|
indicator?: React.ReactNode, |
|
|
|
/** |
|
|
|
* Should the component display a border? |
|
|
|
*/ |
|
|
@@ -43,12 +39,15 @@ export interface NumberSpinnerProps extends Omit<React.HTMLProps<NumberSpinnerDe |
|
|
|
* Visual length of the input. |
|
|
|
*/ |
|
|
|
length?: number, |
|
|
|
enhanced?: boolean, |
|
|
|
stepInterval?: number, |
|
|
|
initialStepDelay?: number, |
|
|
|
} |
|
|
|
|
|
|
|
export const numberSpinnerPlugin = plugin(({ addComponents, }) => { |
|
|
|
addComponents({ |
|
|
|
'.number-spinner': { |
|
|
|
'&::-webkit-inner-spin-button': { |
|
|
|
'&[data-browser="chrome"] > input::-webkit-inner-spin-button': { |
|
|
|
'position': 'absolute', |
|
|
|
'top': '0', |
|
|
|
'right': '0', |
|
|
@@ -56,6 +55,14 @@ export const numberSpinnerPlugin = plugin(({ addComponents, }) => { |
|
|
|
'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', |
|
|
|
}, |
|
|
|
}, |
|
|
|
}); |
|
|
|
}); |
|
|
@@ -67,28 +74,94 @@ export const NumberSpinner = React.forwardRef<NumberSpinnerDerivedElement, Numbe |
|
|
|
{ |
|
|
|
label, |
|
|
|
hint, |
|
|
|
indicator, |
|
|
|
size = 'medium' as const, |
|
|
|
border = false, |
|
|
|
block = false, |
|
|
|
border = false as const, |
|
|
|
block = false as const, |
|
|
|
variant = 'default' as const, |
|
|
|
hiddenLabel = false, |
|
|
|
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 |
|
|
|
>() as React.MutableRefObject<number | undefined>; |
|
|
|
|
|
|
|
const handleStepUp = React.useCallback(() => { |
|
|
|
const { current } = typeof ref === 'object' ? ref : defaultRef; |
|
|
|
if (current) { |
|
|
|
const theStep = current.step ?? 'any'; |
|
|
|
|
|
|
|
const doStepUp = () => { |
|
|
|
current.step = theStep === 'any' ? '1' : theStep; |
|
|
|
current.stepUp(); |
|
|
|
current.step = theStep; |
|
|
|
current.focus(); |
|
|
|
}; |
|
|
|
|
|
|
|
clearInterval(intervalRef.current); |
|
|
|
doStepUp(); |
|
|
|
intervalRef.current = window.setTimeout(() => { |
|
|
|
doStepUp(); |
|
|
|
intervalRef.current = window.setInterval(() => { |
|
|
|
doStepUp(); |
|
|
|
}, stepInterval); |
|
|
|
}, initialStepDelay); |
|
|
|
} |
|
|
|
}, [ref, defaultRef, intervalRef, stepInterval, initialStepDelay]); |
|
|
|
|
|
|
|
const handleStepDown = React.useCallback(() => { |
|
|
|
const { current } = typeof ref === 'object' ? ref : defaultRef; |
|
|
|
if (current) { |
|
|
|
const theStep = current.step ?? 'any'; |
|
|
|
const doStepDown = () => { |
|
|
|
current.step = theStep === 'any' ? '1' : theStep; |
|
|
|
current.stepDown(); |
|
|
|
current.step = theStep; |
|
|
|
current.focus(); |
|
|
|
}; |
|
|
|
|
|
|
|
clearInterval(intervalRef.current); |
|
|
|
doStepDown(); |
|
|
|
intervalRef.current = window.setTimeout(() => { |
|
|
|
doStepDown(); |
|
|
|
intervalRef.current = window.setInterval(() => { |
|
|
|
doStepDown(); |
|
|
|
}, stepInterval); |
|
|
|
}, initialStepDelay); |
|
|
|
} |
|
|
|
}, [ref, defaultRef, intervalRef, stepInterval, initialStepDelay]); |
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
|
const stopStep = () => { |
|
|
|
clearInterval(intervalRef.current); |
|
|
|
}; |
|
|
|
|
|
|
|
window.addEventListener('mouseup', stopStep, { capture: true }); |
|
|
|
return () => { |
|
|
|
window.removeEventListener('mouseup', stopStep, { capture: true }); |
|
|
|
}; |
|
|
|
}, []); |
|
|
|
|
|
|
|
return ( |
|
|
|
<div |
|
|
|
className={clsx( |
|
|
|
'relative rounded ring-secondary/50 group has-[:disabled]:opacity-50', |
|
|
|
'focus-within:ring-4', |
|
|
|
'number-spinner', |
|
|
|
{ |
|
|
|
'block': block, |
|
|
|
'inline-block align-middle': !block, |
|
|
@@ -97,6 +170,8 @@ export const NumberSpinner = React.forwardRef<NumberSpinnerDerivedElement, Numbe |
|
|
|
)} |
|
|
|
style={style} |
|
|
|
data-testid="base" |
|
|
|
data-browser={browser} |
|
|
|
data-enhanced={indicator} |
|
|
|
> |
|
|
|
{label && ( |
|
|
|
<> |
|
|
@@ -129,7 +204,7 @@ export const NumberSpinner = React.forwardRef<NumberSpinnerDerivedElement, Numbe |
|
|
|
<input |
|
|
|
{...etcProps} |
|
|
|
size={length} |
|
|
|
ref={forwardedRef} |
|
|
|
ref={typeof ref === 'function' ? defaultRef : ref} |
|
|
|
id={id} |
|
|
|
aria-labelledby={labelId} |
|
|
|
type="number" |
|
|
@@ -138,7 +213,6 @@ export const NumberSpinner = React.forwardRef<NumberSpinnerDerivedElement, Numbe |
|
|
|
'bg-negative rounded-inherit w-full peer block tabular-nums font-inherit relative', |
|
|
|
'focus:outline-0', |
|
|
|
'disabled:opacity-50 disabled:cursor-not-allowed', |
|
|
|
'number-spinner', |
|
|
|
{ |
|
|
|
'text-xxs': size === 'small', |
|
|
|
'text-xs': size === 'medium', |
|
|
@@ -201,7 +275,7 @@ export const NumberSpinner = React.forwardRef<NumberSpinnerDerivedElement, Numbe |
|
|
|
<div |
|
|
|
data-testid="indicator" |
|
|
|
className={clsx( |
|
|
|
'text-center flex items-center justify-center aspect-square absolute bottom-0 right-0 pointer-events-none select-none', |
|
|
|
'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', |
|
|
@@ -209,13 +283,40 @@ export const NumberSpinner = React.forwardRef<NumberSpinnerDerivedElement, Numbe |
|
|
|
}, |
|
|
|
)} |
|
|
|
> |
|
|
|
{indicator} |
|
|
|
<button |
|
|
|
aria-label="Step Up" |
|
|
|
type="button" |
|
|
|
className="h-0 flex-auto flex justify-center items-start text-inherit overflow-hidden" |
|
|
|
onMouseDown={handleStepUp} |
|
|
|
> |
|
|
|
<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> |
|
|
|
</button> |
|
|
|
<button |
|
|
|
aria-label="Step Down" |
|
|
|
type="button" |
|
|
|
className="h-0 flex-auto flex justify-center items-end text-inherit overflow-hidden" |
|
|
|
onMouseDown={handleStepDown} |
|
|
|
> |
|
|
|
<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> |
|
|
|
</button> |
|
|
|
</div> |
|
|
|
)} |
|
|
|
{border && ( |
|
|
|
<span |
|
|
|
data-testid="border" |
|
|
|
className="absolute z-[1] inset-0 rounded-inherit border-2 border-primary pointer-events-none peer-focus:border-secondary" |
|
|
|
className="absolute z-[1] inset-0 rounded-inherit border-2 border-primary pointer-events-none group-focus-within:border-secondary" |
|
|
|
/> |
|
|
|
)} |
|
|
|
</div> |
|
|
@@ -227,11 +328,13 @@ NumberSpinner.displayName = 'NumberSpinner'; |
|
|
|
NumberSpinner.defaultProps = { |
|
|
|
label: undefined, |
|
|
|
hint: undefined, |
|
|
|
indicator: undefined, |
|
|
|
border: false, |
|
|
|
block: false, |
|
|
|
hiddenLabel: false, |
|
|
|
size: 'medium', |
|
|
|
variant: 'default', |
|
|
|
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, |
|
|
|
}; |