"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 ;
}
function PlusMark() {
return ;
}