/* ============================================================================ * 서명전에 — TDS Switch 컴포넌트 * · 두 가지 상태(켜짐/꺼짐) 사이를 전환하는 단순 토글이에요. * 설정·옵션 ON/OFF 처럼 즉시 반영되는 boolean 상태를 다룰 때 써요. * * · Checkbox(#129) 와의 차이 * Switch — 켜짐/꺼짐 한 가지 동작이 즉시 반영 (예: 푸시 알림 ON/OFF) * Checkbox — 다중 선택 / 약관 동의 / 라디오 그룹 등 "선택" 상태 표시 * * · controlled / uncontrolled 모두 지원: * controlled — checked + onChange(event, checked) * uncontrolled — defaultChecked + 내부 useState (편의 추가) * * · props (TDS 스펙): * checked boolean — 현재 켜짐 여부 (controlled) * defaultChecked boolean — 초기 켜짐 여부 (uncontrolled, TDS 외 편의) * disabled boolean — 비활성 (기본 false) * name string — 폼 전송용 input name * hasTouchEffect boolean — 누름 시 리플 + thumb 늘어남 (기본 true) * onChange (event: ChangeEvent, checked: boolean) => void * — 두 번째 인자가 다음 상태값. TDS 만의 시그니처. * onClick (event: MouseEvent) => void — 그대로 패스스루 * aria-label / aria-labelledby — 외부 레이블이 없을 때 반드시 권장 * * · 접근성: * role="switch" + aria-checked={true|false} + aria-disabled (조건부) * 네이티브 를 시각적으로 가리고 (visually-hidden) * 포커스/폼 전송/스크린 리더는 input 그대로 활용. * 외부 레이블에는 "스위치", "켜짐", "꺼짐" 같은 단어를 넣지 말고 기능만 적어요 * (예: aria-label="푸시 알림 받기"). * * · 모션: * thumb translateX(0 ↔ 20px), 250ms cubic-bezier(0.4, 0, 0.2, 1). * hasTouchEffect=true 면 누르는 동안 thumb 가로로 살짝 늘어나고 * 손을 떼면 ripple 이 한 번 퍼져요. * prefers-reduced-motion 시 모션은 모두 OFF. * ========================================================================== */ const { useState: __swUseState, useRef: __swUseRef, useCallback: __swUseCallback } = React; function Switch({ checked, defaultChecked, disabled = false, name, hasTouchEffect = true, onChange, onClick, className = "", style, id, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, ...rest }) { // ----- controlled vs uncontrolled ----- const isControlled = checked !== undefined; const [internalChecked, setInternalChecked] = __swUseState(!!defaultChecked); const currentChecked = isControlled ? !!checked : internalChecked; if (isControlled && typeof onChange !== "function") { if (window.console && console.warn) { console.warn( "[Switch] checked 만 주고 onChange 는 안 줬어요. " + "controlled 모드에서는 onChange(event, checked) 로 외부 상태를 갱신해야 해요." ); } } // 접근성 라벨 안내 — 외부 라벨/aria-* 가 전혀 없으면 콘솔 경고 if ( !ariaLabel && !ariaLabelledBy && process.env.NODE_ENV !== "production" && !rest["aria-describedby"] ) { // process 는 브라우저에 없을 수 있으니 try try { if (window.console && console.warn) { console.warn( "[Switch] aria-label 또는 aria-labelledby 를 권장해요. " + "스위치가 무엇을 토글하는지 스크린 리더가 안내할 라벨이 필요해요." ); } } catch (_) { /* no-op */ } } // ----- 누름 시 리플 / stretch ----- const [isPressed, setIsPressed] = __swUseState(false); const [rippleKey, setRippleKey] = __swUseState(0); // 매 클릭마다 새로 마운트해 리플 재생 const handlePointerDown = function (e) { if (disabled) return; if (!hasTouchEffect) return; // 좌클릭 / 터치 / 펜만 if (e.button !== undefined && e.button !== 0) return; setIsPressed(true); }; const releasePress = function () { if (!hasTouchEffect) return; setIsPressed(false); }; const handleInputChange = function (e) { if (disabled) return; const next = e.target.checked; if (!isControlled) setInternalChecked(next); if (typeof onChange === "function") { // ★ TDS 시그니처: (event, checked) onChange(e, next); } if (hasTouchEffect) { setRippleKey(function (k) { return k + 1; }); } }; const handleClick = function (e) { if (typeof onClick === "function") onClick(e); }; // ----- 클래스 조립 ----- const wrapCls = [ "tds-switch", currentChecked ? "tds-switch--on" : "tds-switch--off", disabled ? "tds-switch--disabled" : "", hasTouchEffect ? "tds-switch--touch" : "tds-switch--no-touch", isPressed ? "is-pressed" : "", className, ] .filter(Boolean) .join(" "); return ( ); } window.Switch = Switch; Object.assign(window, { Switch });