/** * @title Quantity Picker * @description 버튼으로 수치 값을 한 단계씩 늘리거나 줄일 수 있는 입력 컴포넌트예요. */import * as React from 'react';import { cva, type VariantProps } from 'class-variance-authority';import { Minus, Plus, Trash2 } from 'lucide-react';import { cn } from '@/lib/utils';// SEED Rootage spec: quantity-picker.yaml, quantity-picker-button.yamlconst quantityPickerVariants = cva( [ 'inline-flex w-fit items-center bg-bg-layer-default', 'shadow-[inset_0_0_0_1px_var(--rb-color-stroke-neutral-weak)]', 'data-[invalid]:shadow-[inset_0_0_0_2px_var(--rb-color-stroke-critical-solid)]', ], { variants: { size: { small: 'h-x9 rounded-r2', medium: 'h-x10 rounded-r2', large: 'h-x13 rounded-r3', }, layout: { hug: '', fill: 'w-auto grow', }, }, defaultVariants: { size: 'medium', layout: 'hug', }, },);const quantityPickerButtonVariants = cva( [ 'inline-flex shrink-0 cursor-pointer items-center justify-center border-none bg-bg-transparent p-0 text-fg-neutral', 'transition-[background-color,scale,outline-color] duration-(--rb-duration-color-transition) ease-easing', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-stroke-focus-ring', 'enabled:active:scale-[0.95] enabled:active:bg-bg-transparent-pressed enabled:hover:bg-bg-transparent-pressed', 'data-[loading]:bg-bg-transparent-pressed', 'disabled:cursor-not-allowed disabled:text-fg-disabled', 'aria-disabled:pointer-events-none', '[&_svg]:pointer-events-none [&_svg]:shrink-0', ], { variants: { size: { small: 'size-x9 rounded-r2 [&_svg]:size-x4', medium: 'size-x10 rounded-r2 [&_svg]:size-[18px]', large: 'size-x13 rounded-r3 [&_svg]:size-[22px]', }, }, defaultVariants: { size: 'medium', }, },);const valueDisplayVariants = cva( 'grid shrink-0 grow items-center justify-center px-x2 font-medium tabular-nums text-fg-neutral group-data-[disabled]/quantity-picker:text-fg-disabled', { variants: { size: { small: 'text-t5', medium: 'text-t5', large: 'text-t6', }, }, defaultVariants: { size: 'medium', }, },);/** SEED progressCircle: track gray-500, range fg-neutral, thickness 2px. 크기는 아이콘 크기를 따라요. */function QuantityPickerSpinner() { return ( <svg viewBox="0 0 16 16" fill="none" aria-hidden className="animate-spin"> <circle cx="8" cy="8" r="7" strokeWidth="2" className="stroke-palette-gray-500" /> <path d="M8 1a7 7 0 0 1 7 7" strokeWidth="2" strokeLinecap="round" className="stroke-fg-neutral" /> </svg> );}export type QuantityPickerLoading = boolean | { decrement?: boolean; increment?: boolean };export interface QuantityPickerProps extends Omit<React.ComponentProps<'div'>, 'children' | 'defaultValue' | 'dir'>, VariantProps<typeof quantityPickerVariants> { /** 제어 상태의 현재 수량이에요. */ value?: number; /** 비제어 상태의 초기 수량이에요. 지정하지 않으면 `min` 을 사용해요. */ defaultValue?: number; /** 수량이 바뀔 때 호출돼요. */ onValueChange?: (value: number) => void; /** 선택할 수 있는 최소 수량이에요. */ min: number; /** 선택할 수 있는 최대 수량이에요. */ max: number; /** 한 번의 조작으로 바뀌는 수량이에요. */ step?: number; /** 모든 조작을 비활성화해요. */ disabled?: boolean; /** 수량이 유효하지 않은 상태(Error)예요. */ invalid?: boolean; /** 값을 보여주되 바꿀 수 없게 해요. */ readOnly?: boolean; /** 전체 또는 특정 action 을 loading 상태로 바꾸고 조작을 막아요. */ loading?: QuantityPickerLoading; /** 값이 `min` 일 때 Decrement 버튼을 Remove 버튼으로 바꿔요. */ removable?: boolean; /** Remove 버튼을 누를 때 호출돼요. */ onRemove?: () => void; /** 표시할 수량 텍스트를 반환해요. 단위나 보조 설명을 덧붙일 때 써요. */ getValueText?: (valueText: string, value: number) => React.ReactNode; /** 버튼과 값의 배치 방향이에요. */ dir?: 'ltr' | 'rtl'; decrementAriaLabel?: string; incrementAriaLabel?: string; removeAriaLabel?: string; decrementIcon?: React.ReactNode; incrementIcon?: React.ReactNode; removeIcon?: React.ReactNode; loadingIndicator?: React.ReactNode; /** 현재 수량을 form 으로 제출할 때 hidden input 에 전달할 속성이에요. */ inputProps?: Omit<React.ComponentProps<'input'>, 'type' | 'value'>;}function getLoadingState(loading: QuantityPickerLoading | undefined) { if (loading === true) return { decrement: true, increment: true }; return { decrement: typeof loading === 'object' ? (loading.decrement ?? false) : false, increment: typeof loading === 'object' ? (loading.increment ?? false) : false, };}function QuantityPicker({ className, size, layout, value: valueProp, defaultValue, onValueChange, min, max, step = 1, disabled = false, invalid = false, readOnly = false, loading, removable = false, onRemove, getValueText = (valueText) => valueText, dir = 'ltr', decrementAriaLabel = '수량 줄이기', incrementAriaLabel = '수량 늘리기', removeAriaLabel = '상품 삭제', decrementIcon = <Minus />, incrementIcon = <Plus />, removeIcon = <Trash2 />, loadingIndicator = <QuantityPickerSpinner />, inputProps, ...props}: QuantityPickerProps) { const [internalValue, setInternalValue] = React.useState(() => Math.min(Math.max(defaultValue ?? min, min), max), ); const value = valueProp ?? internalValue; const setValue = (next: number) => { if (next === value) return; if (valueProp === undefined) setInternalValue(next); onValueChange?.(next); }; const isAtMin = value <= min; const isAtMax = value >= max; const isRemoveButton = removable && isAtMin; const loadingState = getLoadingState(loading); const decrementDisabled = disabled || (!isRemoveButton && isAtMin); const incrementDisabled = disabled || isAtMax; const decrementBlocked = decrementDisabled || readOnly || loadingState.decrement; const incrementBlocked = incrementDisabled || readOnly || loadingState.increment; const decrement = () => { if (decrementBlocked) return; if (isRemoveButton) { onRemove?.(); return; } setValue(Math.max(value - step, min)); }; const increment = () => { if (incrementBlocked) return; setValue(Math.min(value + step, max)); }; const stateAttrs = { 'data-disabled': disabled || undefined, 'data-invalid': invalid || undefined, 'data-readonly': readOnly || undefined, 'data-min': isAtMin || undefined, 'data-max': isAtMax || undefined, }; return ( <div data-slot="quantity-picker" role="group" dir={dir} aria-disabled={disabled || undefined} aria-invalid={invalid || undefined} {...stateAttrs} className={cn('group/quantity-picker', quantityPickerVariants({ size, layout }), className)} {...props} > <button type="button" data-slot="quantity-picker-decrement" data-loading={loadingState.decrement || undefined} data-remove={isRemoveButton || undefined} aria-label={isRemoveButton ? removeAriaLabel : decrementAriaLabel} aria-busy={loadingState.decrement || undefined} aria-disabled={decrementBlocked || undefined} disabled={decrementDisabled || readOnly} onClick={decrement} className={quantityPickerButtonVariants({ size })} > {loadingState.decrement ? loadingIndicator : isRemoveButton ? removeIcon : decrementIcon} </button> <span data-slot="quantity-picker-value" aria-live="polite" aria-atomic="true" className={valueDisplayVariants({ size })} > {/* 최대값 너비를 미리 확보해 값이 바뀌어도 레이아웃이 흔들리지 않아요. */} <span aria-hidden className="invisible col-start-1 row-start-1"> {getValueText(String(max), max)} </span> <span className="col-start-1 row-start-1 text-center">{getValueText(String(value), value)}</span> </span> <button type="button" data-slot="quantity-picker-increment" data-loading={loadingState.increment || undefined} aria-label={incrementAriaLabel} aria-busy={loadingState.increment || undefined} aria-disabled={incrementBlocked || undefined} disabled={incrementDisabled || readOnly} onClick={increment} className={quantityPickerButtonVariants({ size })} > {loadingState.increment ? loadingIndicator : incrementIcon} </button> <input type="hidden" value={String(value)} disabled={disabled} readOnly={readOnly} {...inputProps} /> </div> );}export { QuantityPicker, quantityPickerVariants, quantityPickerButtonVariants };