/* ============================================================================ * 서명전에 — TDS SegmentedControl 컴포넌트 * · 여러 선택지 중 하나를 선택하는 라디오 형식의 UI 요소. * · 2 서브: * SegmentedControl — 컨테이너 (radiogroup) * SegmentedControl.Item — 각 선택지 (radio) * * · controlled / uncontrolled 모두 지원: * controlled — value + onChange(v) * uncontrolled — defaultValue + 내부 useState * * · alignment: * fixed (기본) — 모든 아이템이 동일한 너비 (flex: 1 1 0) * fluid — 아이템 너비 = 글자 수, 컨테이너를 넘으면 가로 스크롤. * 스크롤로 첫 아이템이 가려지면 좌측 화살표 버튼 등장, * 클릭 시 부드럽게 처음으로 스크롤. * * · size: small (기본, 32px / sub-ty-7) / large (40px / sub-ty-5) * Item 의 size prop 으로 개별 오버라이드 가능. * * · 접근성: * · 컨테이너 role="radiogroup" + aria-orientation="horizontal" * · 각 Item role="radio" + tabIndex=0 + aria-checked (자동) * · Item 내부 라벨은 로 분리되고 * radio 가 aria-labelledby 로 자동 연결됨 * · 키보드: ← → ↑ ↓ 로 이전/다음 이동, Home/End 양 끝, Space/Enter 활성 * · 좌측 화살표 버튼 aria-label="처음으로 이동", 보이지 않을 때 tabIndex=-1 * ========================================================================== */ const __SEGMENTED_SIZES = { small: 1, large: 1 }; const __SEGMENTED_ALIGNS = { fixed: 1, fluid: 1 }; let __segmentedIdCounter = 0; function __segmentedNextId() { __segmentedIdCounter += 1; return "tds-seg-" + __segmentedIdCounter; } // 컨테이너 → Item 으로 상태/사이즈/이벤트 핸들러를 전달하기 위한 컨텍스트. const __SegmentedControlContext = React.createContext({ currentValue: undefined, onSelect: function () {}, size: "small", onItemKeyDown: function () {}, }); // ----- 좌측 화살표 SVG (14px chevron-left) ----- function __SegmentedLeftArrowSvg() { return ( ); } function SegmentedControl({ children, value, defaultValue, onChange, size = "small", alignment = "fixed", className = "", style, id, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, ...rest }) { // ----- props 검증 ----- if (!__SEGMENTED_SIZES[size]) { if (window.console && console.warn) { console.warn( `[SegmentedControl] size 는 "small" | "large" 만 가능해요. 받은 값: ${size}` ); } } if (!__SEGMENTED_ALIGNS[alignment]) { if (window.console && console.warn) { console.warn( `[SegmentedControl] alignment 는 "fixed" | "fluid" 만 가능해요. 받은 값: ${alignment}` ); } } const safeSize = __SEGMENTED_SIZES[size] ? size : "small"; const safeAlign = __SEGMENTED_ALIGNS[alignment] ? alignment : "fixed"; // ----- controlled vs uncontrolled ----- const isControlled = value !== undefined; const [internal, setInternal] = React.useState( typeof defaultValue === "string" ? defaultValue : undefined ); const currentValue = isControlled ? value : internal; // controlled 인데 onChange 가 없으면 read-only 와 다름없음 — 한 번 경고 if (isControlled && typeof onChange !== "function") { if (window.console && console.warn) { console.warn( "[SegmentedControl] value 만 주고 onChange 는 안 줬어요. " + "controlled 모드에서는 onChange 로 외부 상태를 갱신해야 해요." ); } } // ----- 자식 → values 배열 (키보드 이동 / 선택 시 인덱스 계산) ----- const childArray = React.Children.toArray(children).filter(function (c) { return React.isValidElement(c); }); const itemValues = childArray.map(function (c) { return c.props && c.props.value; }); // ----- 선택 핸들러 ----- const handleSelect = function (v) { if (!isControlled) setInternal(v); if (typeof onChange === "function") onChange(v); }; // ----- fluid 스크롤 — 첫 아이템 가림 여부 추적 → 좌측 화살표 표시 ----- const trackRef = React.useRef(null); const [showLeftArrow, setShowLeftArrow] = React.useState(false); React.useEffect(function () { // fixed 면 화살표 항상 숨김 if (safeAlign !== "fluid") { setShowLeftArrow(false); return; } const el = trackRef.current; if (!el) return; const update = function () { // 첫 아이템이 가려진 정도 — scrollLeft > 8px 이면 화살표 표시 setShowLeftArrow(el.scrollLeft > 8); }; el.addEventListener("scroll", update, { passive: true }); // 초기 상태 + 리사이즈 시 재계산 update(); let ro = null; if (typeof ResizeObserver !== "undefined") { ro = new ResizeObserver(update); ro.observe(el); } return function () { el.removeEventListener("scroll", update); if (ro) ro.disconnect(); }; }, [safeAlign]); const handleLeftArrowClick = function () { const el = trackRef.current; if (!el) return; if (typeof el.scrollTo === "function") { el.scrollTo({ left: 0, behavior: "smooth" }); } else { el.scrollLeft = 0; } }; // ----- 키보드 이동 핸들러 (Item 에서 호출) ----- const handleItemKeyDown = function (e, fromValue) { const k = e.key; if ( k !== "ArrowLeft" && k !== "ArrowRight" && k !== "ArrowUp" && k !== "ArrowDown" && k !== "Home" && k !== "End" && k !== " " && k !== "Enter" ) return; const idx = itemValues.indexOf(fromValue); if (idx < 0) return; // Space / Enter — 현재 항목 활성화 if (k === " " || k === "Enter") { e.preventDefault(); handleSelect(fromValue); return; } // 이동 — 다음 인덱스 계산 (양 끝 wrap-around) let nextIdx = idx; if (k === "ArrowLeft" || k === "ArrowUp") { nextIdx = idx === 0 ? itemValues.length - 1 : idx - 1; } else if (k === "ArrowRight" || k === "ArrowDown") { nextIdx = idx === itemValues.length - 1 ? 0 : idx + 1; } else if (k === "Home") { nextIdx = 0; } else if (k === "End") { nextIdx = itemValues.length - 1; } e.preventDefault(); const nextVal = itemValues[nextIdx]; handleSelect(nextVal); // 포커스도 새 항목으로 이동 — radiogroup 키보드 패턴 const trackEl = trackRef.current; if (trackEl) { const items = trackEl.querySelectorAll('[role="radio"]'); if (items[nextIdx] && typeof items[nextIdx].focus === "function") { items[nextIdx].focus(); // fluid 모드에서 화면 밖이면 살짝 스크롤 if (safeAlign === "fluid" && typeof items[nextIdx].scrollIntoView === "function") { items[nextIdx].scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest", }); } } } }; // ----- 컨텍스트 value ----- const ctx = { currentValue: currentValue, onSelect: handleSelect, size: safeSize, onItemKeyDown: handleItemKeyDown, }; // ----- 클래스 조립 ----- const wrapCls = [ "tds-segmented", `tds-segmented--size-${safeSize}`, `tds-segmented--align-${safeAlign}`, className, ] .filter(Boolean) .join(" "); return (
<__SegmentedControlContext.Provider value={ctx}> {children}
{safeAlign === "fluid" ? ( ) : null}
); } // ============================================================================= // SegmentedControl.Item // · 각각의 선택지 — role="radio" + aria-checked 자동. // · onClick 시 부모 컨텍스트의 onSelect(value) 호출. // · 라벨은 로 분리하고 radio 가 aria-labelledby 로 연결. // ============================================================================= function SegmentedControlItem({ children, value, size: sizeOverride, className = "", style, id, ...rest }) { const ctx = React.useContext(__SegmentedControlContext); // Item 의 size 가 small/large 가 아닌 값을 받으면 경고 if (sizeOverride !== undefined && !__SEGMENTED_SIZES[sizeOverride]) { if (window.console && console.warn) { console.warn( `[SegmentedControl.Item] size 는 "small" | "large" 만 가능해요. 받은 값: ${sizeOverride}` ); } } const itemSize = sizeOverride && __SEGMENTED_SIZES[sizeOverride] ? sizeOverride : ctx.size; // value 누락 방지 if (value === undefined || value === null || value === "") { if (window.console && console.warn) { console.warn( "[SegmentedControl.Item] value 는 필수예요. (onChange 콜백 인자로 전달돼요)" ); } } // 라벨용 stable id — 한 번만 생성 const labelIdRef = React.useRef(null); if (labelIdRef.current == null) { const baseId = id || ("tds-seg-item-" + Math.random().toString(36).slice(2, 9)); labelIdRef.current = baseId + "-label"; } const isSelected = ctx.currentValue !== undefined && ctx.currentValue === value; const cls = [ "tds-segmented__item", `tds-segmented__item--size-${itemSize}`, isSelected ? "tds-segmented__item--selected" : "", className, ] .filter(Boolean) .join(" "); const handleClick = function () { if (typeof ctx.onSelect === "function") ctx.onSelect(value); }; const handleKeyDown = function (e) { if (typeof ctx.onItemKeyDown === "function") ctx.onItemKeyDown(e, value); }; return ( ); } SegmentedControl.Item = SegmentedControlItem; window.SegmentedControl = SegmentedControl; window.SegmentedControlItem = SegmentedControlItem; Object.assign(window, { SegmentedControl, SegmentedControlItem });