brunner56's picture
implement app
0bfe2e3
'use client';
import type { IntlTranslations } from '@zag-js/number-input';
import * as numberInput from '@zag-js/number-input';
import { normalizeProps, useMachine } from '@zag-js/react';
import { cva, VariantProps } from 'class-variance-authority';
import * as React from 'react';
import {
BasicField,
BasicFieldOptions,
extractBasicFieldProps,
} from '../basic-field';
import { IconButton } from '../button';
import { cn, ComponentAnatomy, defineStyleAnatomy } from '../core/styling';
import {
extractInputPartProps,
InputAddon,
InputAnatomy,
InputContainer,
InputIcon,
InputStyling,
} from '../input';
/* -------------------------------------------------------------------------------------------------
* Anatomy
* -----------------------------------------------------------------------------------------------*/
export const NumberInputAnatomy = defineStyleAnatomy({
root: cva(['UI-NumberInput__root', 'z-[2]'], {
variants: {
hideControls: {
true: false,
false: 'border-r border-r-transparent hover:border-r-[--border]',
},
size: {
sm: null,
md: null,
lg: null,
},
intent: {
basic: null,
filled: null,
unstyled: 'border-r-0 hover:border-r-transparent',
},
},
defaultVariants: {
hideControls: false,
},
}),
control: cva(['UI-NumberInput__control', 'rounded-none h-[50%] ring-inset']),
controlsContainer: cva(
[
'UI-NumberInput__controlsContainer',
'form-input w-auto p-0 flex flex-col items-stretch justify-center overflow-hidden max-h-full',
'border-l-0 relative z-[1]',
'shadow-xs',
],
{
variants: {
size: {
sm: 'h-8',
md: 'h-10',
lg: 'h-12',
},
intent: {
basic: null,
filled: 'hover:bg-gray-100',
unstyled: null,
},
hasRightAddon: {
true: 'border-r-0',
false: null,
},
},
}
),
chevronIcon: cva(['UI-Combobox__chevronIcon', 'h-4 w-4 shrink-0']),
});
/* -------------------------------------------------------------------------------------------------
* NumberInput
* -----------------------------------------------------------------------------------------------*/
export type NumberInputProps = Omit<
React.ComponentPropsWithoutRef<'input'>,
'value' | 'size' | 'defaultValue'
> &
ComponentAnatomy<typeof NumberInputAnatomy> &
Omit<VariantProps<typeof NumberInputAnatomy.root>, 'size' | 'intent'> &
BasicFieldOptions &
InputStyling & {
/**
* The value of the input
*/
value?: number | string;
/**
* The callback to handle value changes
*/
onValueChange?: (value: number, valueAsString: string) => void;
/**
* Default value when uncontrolled
*/
defaultValue?: number | string;
/**
* The minimum value of the input
*/
min?: number;
/**
* The maximum value of the input
*/
max?: number;
/**
* The amount to increment or decrement the value by
*/
step?: number;
/**
* Whether to allow mouse wheel to change the value
*/
allowMouseWheel?: boolean;
/**
* Whether to allow the value overflow the min/max range
*/
allowOverflow?: boolean;
/**
* Whether to hide the controls
*/
hideControls?: boolean;
/**
* The format options for the value
*/
formatOptions?: Intl.NumberFormatOptions;
/**
* Whether to clamp the value when the input loses focus (blur)
*/
clampValueOnBlur?: boolean;
/**
* Accessibility
*
* Specifies the localized strings that identifies the accessibility elements and their states
*/
translations?: IntlTranslations;
/**
* The current locale. Based on the BCP 47 definition.
*/
locale?: string;
/**
* The document's text/writing direction.
*/
dir?: 'ltr' | 'rtl';
};
export const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
(props, ref) => {
const [props1, basicFieldProps] = extractBasicFieldProps<NumberInputProps>(
props,
React.useId()
);
const [
{
controlClass,
controlsContainerClass,
chevronIconClass,
className,
children,
/**/
size,
intent,
leftAddon,
leftIcon,
rightAddon,
rightIcon,
placeholder,
onValueChange,
hideControls,
value: controlledValue,
min = 0,
max,
step,
allowMouseWheel = true,
formatOptions = { maximumFractionDigits: 2 },
clampValueOnBlur = true,
translations,
locale,
dir,
defaultValue,
...rest
},
{
inputContainerProps,
leftAddonProps,
leftIconProps,
rightAddonProps,
rightIconProps,
},
] = extractInputPartProps<NumberInputProps>({
...props1,
size: props1.size ?? 'md',
intent: props1.intent ?? 'basic',
leftAddon: props1.leftAddon,
leftIcon: props1.leftIcon,
rightAddon: props1.rightAddon,
rightIcon: props1.rightIcon,
});
const service = useMachine(numberInput.machine, {
id: basicFieldProps.id,
name: basicFieldProps.name,
disabled: basicFieldProps.disabled,
readOnly: basicFieldProps.readonly,
value: controlledValue
? String(controlledValue)
: defaultValue
? String(defaultValue)
: undefined,
min,
max,
step,
allowMouseWheel,
formatOptions,
clampValueOnBlur,
translations,
locale,
dir,
onValueChange: (details: { valueAsNumber: number; value: string }) => {
onValueChange?.(details.valueAsNumber, details.value);
},
});
const api = numberInput.connect(service, normalizeProps);
const isFirst = React.useRef(true);
React.useEffect(() => {
if (!isFirst.current) {
if (
typeof controlledValue === 'string' &&
!isNaN(Number(controlledValue))
) {
api.setValue(Number(controlledValue));
} else if (typeof controlledValue === 'number') {
api.setValue(controlledValue);
} else if (controlledValue === undefined) {
api.setValue(min);
}
}
isFirst.current = false;
}, [controlledValue]);
return (
<BasicField {...basicFieldProps} id={api.getInputProps().id}>
<InputContainer {...inputContainerProps}>
<InputAddon {...leftAddonProps} />
<InputIcon {...leftIconProps} />
<input
ref={ref}
type="number"
name={basicFieldProps.name}
className={cn(
'form-input',
InputAnatomy.root({
size,
intent,
hasError: !!basicFieldProps.error,
isDisabled: !!basicFieldProps.disabled,
hasRightAddon: !!rightAddon || !hideControls,
hasRightIcon: !!rightIcon,
hasLeftAddon: !!leftAddon,
hasLeftIcon: !!leftIcon,
}),
NumberInputAnatomy.root({ hideControls, intent, size }),
className
)}
disabled={basicFieldProps.disabled || basicFieldProps.readonly}
data-disabled={basicFieldProps.disabled}
data-readonly={basicFieldProps.readonly}
aria-readonly={basicFieldProps.readonly}
required={basicFieldProps.required}
{...api.getInputProps()}
{...rest}
/>
{!hideControls && (
<div
className={cn(
InputAnatomy.root({
size,
intent,
hasError: !!basicFieldProps.error,
isDisabled: !!basicFieldProps.disabled,
hasRightAddon: !!rightAddon,
hasRightIcon: !!rightIcon,
hasLeftAddon: true,
}),
NumberInputAnatomy.controlsContainer({
size,
intent,
hasRightAddon: !!rightAddon,
}),
controlsContainerClass
)}
>
<IconButton
intent="gray-basic"
size="sm"
className={cn(NumberInputAnatomy.control(), controlClass)}
{...api.getIncrementTriggerProps()}
data-readonly={basicFieldProps.readonly}
data-disabled={
basicFieldProps.disabled ||
api.getIncrementTriggerProps().disabled
}
disabled={
basicFieldProps.disabled ||
basicFieldProps.readonly ||
api.getIncrementTriggerProps().disabled
}
tabIndex={0}
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(
NumberInputAnatomy.chevronIcon(),
'rotate-180',
chevronIconClass
)}
>
<path d="m6 9 6 6 6-6" />
</svg>
}
/>
<IconButton
intent="gray-basic"
size="sm"
className={cn(NumberInputAnatomy.control(), controlClass)}
{...api.getDecrementTriggerProps()}
data-readonly={basicFieldProps.readonly}
data-disabled={
basicFieldProps.disabled ||
api.getDecrementTriggerProps().disabled
}
disabled={
basicFieldProps.disabled ||
basicFieldProps.readonly ||
api.getDecrementTriggerProps().disabled
}
tabIndex={0}
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(
NumberInputAnatomy.chevronIcon(),
chevronIconClass
)}
>
<path d="m6 9 6 6 6-6" />
</svg>
}
/>
</div>
)}
<InputAddon {...rightAddonProps} />
<InputIcon
{...rightIconProps}
className={cn(
'z-3',
rightIconProps.className,
!rightAddon ? 'mr-6' : null
)}
/>
</InputContainer>
</BasicField>
);
}
);
NumberInput.displayName = 'NumberInput';