"use client"; import { useState, type Ref } from "react"; import { cx } from "../../utils"; export interface FoxToggleQuantityProps { /** 주면 제어 컴포넌트가 된다 — 호출부가 소유하고, 이 요소가 스스로 바꾸지 않는다. */ value?: number; /** 비제어로 쓸 때의 초기 값. `value`를 주면 무시된다. */ defaultValue?: number; /** * 값이 바뀔 때 호출된다. 이미 `min`·`max`로 자른 뒤의 값이 넘어온다. * * ⚠️ 이름이 `onChange`가 아니다 — 네이티브 입력을 감싸는 게 아니라 버튼 둘로 만든 커스텀 * 위젯이라 `FoxSelect.onValueChange`와 같은 쪽이다. */ onValueChange?: (value: number) => void; /** 내려갈 수 있는 하한. 값이 여기 닿으면 빼기 버튼이 비활성된다. */ min?: number; /** 올라갈 수 있는 상한. 넘기지 않으면 상한이 없어 더하기 버튼이 비활성되지 않는다. */ max?: number; /** 한 번에 오르내리는 폭. */ step?: number; /** * 폼으로 값을 보낼 이름. 주면 같은 값을 담은 ``을 함께 렌더한다. * * `FoxChip`은 이걸 열지 않는다 — 칩은 여러 개가 한 묶음으로 하나의 값을 이뤄서 이름 하나를 * 칩 하나에 묶을 수 없기 때문이다. 이쪽은 값이 정확히 하나라 그 문제가 없다. */ name?: string; /** 두 버튼을 모두 비활성한다. `min`·`max`에 의한 개별 비활성과는 별개다. */ disabled?: boolean; /** 묶음을 읽어 줄 이름. 화면에 보이는 제목이 이미 있으면 `labelledBy`를 쓴다. */ label?: string; /** 묶음 이름 역할을 하는 요소의 id. `label`보다 우선한다. */ labelledBy?: string; /** 빼기 버튼을 읽어 줄 이름. */ decreaseLabel?: string; /** 더하기 버튼을 읽어 줄 이름. */ increaseLabel?: string; /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ hidden?: boolean; /** 배치 조정용. 시안의 폭(140px)을 바꿔야 할 때도 여기로 준다. */ className?: string; ref?: Ref; } /** * @fox 수량 조절기. 빼기 버튼 · 숫자 · 더하기 버튼이 회색 판 위에 놓인다. * * 값은 제어·비제어 둘 다 된다(`FoxSelect`와 같은 방식). `min`·`max`에 닿으면 해당 버튼이 * 네이티브 `disabled`가 되므로, 누를 수 없다는 것이 커서와 보조기술에 함께 전달된다. * * 숫자는 입력칸이 아니라 읽기 전용 글자다 — 시안에 입력칸의 테두리도 배경도 없다. 그래서 * `role="spinbutton"`을 붙이지 않고 `role="group"` 안의 버튼 둘로 두고, 바뀐 값은 * `aria-live`로 알린다. 키보드로 값을 직접 치는 자리가 필요해지면 시안에 먼저 추가한다. * * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` * (또는 개별 파티셜)로 한 번 불러와야 한다. */ export function FoxToggleQuantity({ value, defaultValue = 0, onValueChange, min = 0, max, step = 1, name, disabled = false, label, labelledBy, decreaseLabel = "수량 줄이기", increaseLabel = "수량 늘리기", hidden = false, className, ref, }: FoxToggleQuantityProps) { const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue); const currentValue = value ?? uncontrolledValue; // 훅을 전부 부른 뒤에 빠져나간다 — 위쪽에서 반환하면 훅 호출 순서가 깨진다. if (hidden) { return null; } // 호출부가 범위 밖의 값을 줄 수도 있으므로 표시 직전에 한 번 자른다 — 자르지 않으면 // 버튼 비활성 여부와 보이는 숫자가 어긋난다. const clamp = (next: number) => Math.min(max ?? Infinity, Math.max(min, next)); const atMin = currentValue <= min; const atMax = max !== undefined && currentValue >= max; function commit(next: number) { const clamped = clamp(next); if (clamped === currentValue) { return; } // 제어 컴포넌트일 때는 내부 값을 건드리지 않는다 — 건드리면 호출부가 값을 거부해도 // 내부에만 반영되어 둘이 조용히 갈라진다. if (value === undefined) setUncontrolledValue(clamped); onValueChange?.(clamped); } return (
{/* 값이 바뀐 것을 소리로도 알린다 — 버튼 이름은 "수량 줄이기"라 결과를 담지 않는다. */} {clamp(currentValue)} {name ? : null}
); } // 시안 아이콘 둘. `fill`을 시안의 리터럴(#58616A) 대신 `currentColor`로 두면 색을 버튼 // 규칙이 정한다(그쪽에서 같은 값을 `primitive(neutral-60)`으로 가리킨다). // `width`·`height`를 쓰지 않는다 — 크기는 슬롯이 정하고 SVG는 100%로 따라온다. function MinusMark() { return