/* ============================================================================ * 서명전에 — TDS Tab 컴포넌트 * · 한 화면에서 여러 콘텐츠를 효율적으로 전환하는 가로 탭 UI 요소예요. * · 2 컴포넌트: * Tab — 컨테이너 (role="tablist") * Tab.Item — 각 탭 (role="tab" + aria-selected, redBean dot 옵션) * * · 시각: * 탭 전체가 한 줄로 늘어서고, 컨테이너 하단에 1px grey-200 가이드 라인. * 선택된 Item 의 텍스트는 grey-900 + bold, 그 아래로 2px blue-500 underline * indicator 가 슬라이드해서 따라와요. 비선택 Item 은 grey-500 + medium. * * · alignment: * fluid=false (기본) — 모든 아이템이 동일한 너비 (flex: 1 1 0). * 최대 4 개 권장. * fluid=true — 아이템 너비 = 글자 수, 컨테이너를 넘으면 가로 스크롤. * 5 개 이상이거나 텍스트 길이가 들쑥날쑥할 때. * * · size: * large (기본) — 높이 48px, 텍스트 sub-ty-5 (16px) medium / 선택 bold * small — 높이 36px, 텍스트 sub-ty-7 (14px) medium / 선택 bold * * · itemGap: * number — 아이템 사이 간격 (px). 기본은 fluid 면 24, fixed 면 0. * fixed 모드에서 0 이 아닌 itemGap 을 주면 각 탭의 너비가 줄어들면서 * 사이가 벌어져요. * * · onChange (필수): * (index, key?) => void. * key 는 React 가 children 에 부여한 key. 호출자가 setSelected(index) * 처럼 외부 state 를 갱신해야 시각이 바뀌어요 (controlled-only). * * · 접근성: * role="tablist" (컨테이너) + aria-orientation="horizontal" + aria-label * role="tab" (Item) + aria-selected (자동, selected 값으로) + tabIndex * — selected=true 면 0, 나머지는 -1 (roving tabindex 표준 패턴) * aria-label 누락 시 콘솔 경고. 라벨 문구에 "탭"/"Tab" 단어는 빼고 * 기능만 적어요 — 스크린 리더가 이미 "탭" 으로 읽어줌. * redBean=true 면 시각 + title="(업데이트 있음)" 으로 비시각 사용자에게도 안내. * * · 키보드 (W3C tablist 표준): * ←/↑ 이전 (wrap) · →/↓ 다음 (wrap) · Home 첫 · End 끝. * Space/Enter 는 자동 — 버튼 click 으로 처리. * 방향키로 이동하면 onChange 도 호출되어 패널이 함께 바뀌어요 * (자동 활성화 모드 — TDS 스펙은 수동 활성화 별도 명시 없음). * ========================================================================== */ const __TAB_SIZES = { small: 1, large: 1 }; function Tab({ children, onChange, size = "large", fluid = false, itemGap, ariaLabel, className = "", style, id, "aria-label": ariaLabelExt, "aria-labelledby": ariaLabelledBy, ...rest }) { // ----- props 검증 ----- if (!__TAB_SIZES[size]) { if (window.console && console.warn) { console.warn( `[Tab] size 는 "small" | "large" 만 가능해요. 받은 값: ${size}. "large" 사용.` ); } size = "large"; } if (typeof onChange !== "function") { if (window.console && console.warn) { console.warn( "[Tab] onChange 는 필수예요. (index, key?) => void 형태로 외부 state 를 갱신해야 해요." ); } } // 외부에서 ariaLabel / aria-label / aria-labelledby 어떤 형태로든 들어올 수 있도록 통합 const finalAriaLabel = ariaLabel != null ? ariaLabel : ariaLabelExt; if (!finalAriaLabel && !ariaLabelledBy) { if (window.console && console.warn) { console.warn( "[Tab] ariaLabel 또는 aria-labelledby 를 권장해요. 탭이 무엇을 분류하는지 " + "스크린 리더가 안내할 라벨이 필요해요. (\"탭\" 단어는 빼고 기능만 적어요)" ); } } // ----- 자식 정리 ----- const childArray = React.Children.toArray(children).filter(function (c) { return React.isValidElement(c); }); const total = childArray.length; if (total > 4 && !fluid) { if (window.console && console.warn) { console.warn( `[Tab] fluid=false 일 때는 최대 4 개까지 권장해요. 받은 자식: ${total} 개. ` + "더 많을 땐 fluid 를 켜면 가로 스크롤로 보여줘요." ); } } // ----- 현재 선택 인덱스 (selected prop 으로부터 추론) ----- let selectedIndex = -1; for (let i = 0; i < total; i += 1) { const ch = childArray[i]; if (ch.props && ch.props.selected) { selectedIndex = i; break; } } // ----- 활성화 (클릭 / 키보드 이동) ----- const handleItemActivate = function (idx, key) { if (typeof onChange === "function") { onChange(idx, key); } }; // 자식의 React key 추출 — child.key 는 cloneElement 이후에도 유지되지만 자식 // 자신은 자기 key 를 못 읽음. 부모에서 미리 캡처해 onChange 두 번째 인자로 흘려요. const keyOf = function (child) { if (child && child.key !== null && child.key !== undefined) { // React 는 key 를 항상 string 으로 직렬화하지만, 숫자 key 가 들어왔을 // 때를 위해 숫자형이 가능하면 number 로 복원해서 시그니처에 맞춰요. const k = child.key; if (typeof k === "string" && /^[-]?\d+$/.test(k)) { const n = Number(k); if (isFinite(n)) return n; } return k; } return undefined; }; // ----- gap 결정 ----- let resolvedGap = itemGap; if (typeof resolvedGap !== "number" || !isFinite(resolvedGap)) { resolvedGap = fluid ? 24 : 0; } // ----- 자식에 internal prop 주입 ----- const enhanced = childArray.map(function (child, idx) { const isSelected = idx === selectedIndex; const childKey = keyOf(child); // 자식이 클릭/엔터/스페이스로 활성화될 때 key 를 함께 보내도록 바인딩 const onActivateBound = function () { handleItemActivate(idx, childKey); }; const onKeyDownBound = function (e) { // 키보드 방향키 이동도 같은 경로로. handleItemKeyDown 안에서 // 다음 child 의 key 를 다시 풀어 onChange 에 전달. const k = e.key; let nextIdx = -1; if (k === "ArrowLeft" || k === "ArrowUp") { nextIdx = (idx - 1 + total) % total; } else if (k === "ArrowRight" || k === "ArrowDown") { nextIdx = (idx + 1) % total; } else if (k === "Home") { nextIdx = 0; } else if (k === "End") { nextIdx = total - 1; } else { return; } e.preventDefault(); if (nextIdx === idx) return; const nextKey = keyOf(childArray[nextIdx]); handleItemActivate(nextIdx, nextKey); }; return React.cloneElement(child, { __idx: idx, __size: size, __fluid: fluid, __isSelected: isSelected, __onActivate: onActivateBound, __onKeyDown: onKeyDownBound, }); }); // ----- 클래스 ----- const wrapCls = [ "tds-tab", `tds-tab--${size}`, fluid ? "tds-tab--fluid" : "tds-tab--fixed", className, ] .filter(Boolean) .join(" "); const wrapStyle = { ...(style || {}) }; // gap 은 컨테이너 flex gap 으로 적용 (fluid/fixed 양쪽에서 동작) wrapStyle.gap = `${resolvedGap}px`; return (