Skip to content

refactor: JDS Radio 컴포넌트 vanilla-extract 마이그레이션#437

Merged
itwillbeoptimal merged 14 commits into
devfrom
refactor/433-jds-radio-migration
May 23, 2026
Merged

refactor: JDS Radio 컴포넌트 vanilla-extract 마이그레이션#437
itwillbeoptimal merged 14 commits into
devfrom
refactor/433-jds-radio-migration

Conversation

@itwillbeoptimal
Copy link
Copy Markdown
Member

@itwillbeoptimal itwillbeoptimal commented May 14, 2026

💡 작업 내용

  • Radio vanilla-extract 마이그레이션
  • @react-aria/radio 도입 (그룹 접근성, 화살표키 내비게이션)
  • useContainerPressable 훅 분리
  • 기존 스타일 및 인터랙션 동작 확인
  • 불필요한 Emotion 의존성 제거

💡 자세한 설명

1. 개요

Radio의 스타일링 레이어를 Emotion에서 vanilla-extract로 마이그레이션했습니다. 기존 Emotion 파일을 제거하고 동일 디렉토리에 VE 구현체로 대체했습니다.

Radio/
├── Radio.style.ts           ← Emotion (삭제)
├── RadioGroupContext.tsx    ← @react-aria/radio 도입으로 제거
├── radio.css.ts             ← VE 스타일 (신규)
├── Radio.tsx                ← VE 기반 구현으로 교체
├── RadioContext.tsx         ← 단일 컨텍스트로 통합
├── radio.types.ts           ← Emotion 전용 타입 제거
├── Radio.stories.tsx        ← 리팩토링
├── index.ts
└── preset/
    ├── RadioContent.tsx
    └── radioContent.types.ts

2. useContainerPressable 훅 도입

Radio는 버튼 컴포넌트들과 달리 usePressable을 그대로 적용하기 어려웠습니다. usePressable은 다음 조합으로 구성됩니다.

useButton + useHover + useFocusRing()

1) useButton 사용 불가

useButton은 대상 element에 role="button"과 Enter/Space 키 인터랙션을 추가합니다. 하지만 Radio.Item은 실제 인터랙션 주체가 내부 <input type="radio">인 컨테이너 구조입니다. 이 상태에서 바깥 <div>에 button 시맨틱까지 부여하면, native radio와 button 역할이 동시에 존재하게 되어 스크린리더가 올바르게 해석하지 못할 수 있고, 키보드 입력 역시 컨테이너와 input 양쪽에서 처리될 수 있습니다.

따라서 Radio.Item에는 useButton을 적용하지 않고, usePress(@react-aria/interactions)를 사용했습니다.

useHover + usePress + useFocusRing({ within: true })

usePressuseButton과 달리 role이나 tabIndex를 주입하지 않고 pressed 상태만 제공합니다.

2) useFocusRing({ within: true }) 사용

Radio.Item 자체는 포커스를 받지 않고 내부 <input>이 포커스를 받습니다. 기존 usePressable 내부의 useFocusRing()within: true 없이 호출되므로, Radio.Item에서는 isFocusVisible이 항상 false였습니다.

이를 위해 useFocusRing({ within: true })를 사용했습니다. 자식 input에 포커스가 이동해도 컨테이너에서 focus-visible 상태를 감지할 수 있습니다.

해당 훅 조합은 usePressable 구조를 참고해 useContainerPressable로 분리했습니다. Checkbox처럼 내부에 native form 요소를 포함하는 컨테이너 컴포넌트에서도 동일하게 재사용할 수 있습니다.

export function useContainerPressable({ disabled = false } = {}) {
  const { hoverProps, isHovered } = useHover({ isDisabled: disabled });
  const { pressProps, isPressed } = usePress({ isDisabled: disabled });
  const { focusProps, isFocusVisible } = useFocusRing({ within: true });

  return {
    containerPressableProps: {
      ...mergeProps(hoverProps, pressProps, focusProps),
      "data-hovered": isHovered || undefined,
      "data-pressed": isPressed || undefined,
      "data-focus-visible": isFocusVisible || undefined,
      "data-disabled": disabled || undefined,
    },
  };
}

3. 인터랙션 상태 처리 방식 변경

상태 Emotion (Interaction()) VE
hover (RadioItem) :hover::after useHoverdata-hovered
pressed (RadioItem) :active::after usePressdata-pressed
focus (RadioItem) :focus-visible (실제 미동작) useFocusRing({ within: true })
hover (radioVisual) :hover::after CSS :hover 유지
active (radioVisual) :active::after CSS :active 유지
focus (radioVisual) :focus-visible input:focus-visible + .visual

기존 Radio.Item:focus-visible 스타일은 실제로 포커스를 받지 않는 요소에 선언되어 있어 동작하지 않는 상태였고, useFocusRing({ within: true }) 기반으로 교체했습니다.

radioVisual의 hover 상태는 단순 장식 요소(aria-hidden="true")이므로, 별도 상태 관리 없이 CSS :hover를 유지합니다.


4. 컨텍스트 구조 단일화

기존 구현은 두 개의 컨텍스트가 중첩된 구조였습니다.

RadioGroupContext
└── RadioContext

Radio.Item이 상위 컨텍스트를 읽어 다시 하위 컨텍스트로 전달하는 구조였으며, 실제 사용 값도 중복되어 있어 하나의 컨텍스트로 통합했습니다.

interface RadioContextValue {
  size: RadioSize;
  style: RadioStyle;
  align: RadioAlign;
  disabled: boolean;
  state?: RadioGroupState;
}

Radio.Root가 전체 값을 제공하고, Radio.Item은 부모 컨텍스트를 읽어 자신의 props와 합쳐 override하는 방식으로 정리했습니다.

const rootContext = useRadioContext();

const size = radioSize ?? rootContext?.size ?? "md";
const isDisabled = disabled || (rootContext?.disabled ?? false);

createContext<RadioContextValue | null>(null)로 기본값을 두어 단독 사용 여부를 컨텍스트 존재 여부로 구분할 수 있도록 했습니다.


5. radioSizeMap 타입 정리 및 토큰화

기존 구현에서는 동일한 맵 내부에 숫자와 문자열 타입이 혼재되어 있었습니다.

{
  radioSize: number;
  border: string;
}

radioSize는 런타임 계산용 숫자였고, border는 실제로는 토큰 키였지만 string으로 표현되고 있어 모두 최종 사용 형태 기준으로 통일했습니다.

type StrokeWeightKey =
  keyof typeof vars.scheme.semantic.strokeWeight;

const radioSizeMap = {
  lg: { sizeRem: pxToRem(20), borderKey: "6" },
  md: { sizeRem: pxToRem(18), borderKey: "5" },
  sm: { sizeRem: pxToRem(16), borderKey: "5" },
  xs: { sizeRem: pxToRem(14), borderKey: "4" },
};

6. overlay 확장 영역 처리

Radio.Itemempty 스타일에서는 ::after(overlay)와 ::before(focus ring)가 element 경계 바깥으로 확장됩니다.

// 원본 Emotion
width: `calc(100% + ${interactionWidth}px)`,
height: `calc(100% + ${interactionHeight}px)`,
transform: `translate(-${Math.floor(interactionWidth / 2) + 1}px, -${Math.floor(interactionHeight / 2)}px)`,

현재 LabelButton에서는 확장 영역을 inset 기반으로 표현하고 있어, Radio 역시 동일한 방식으로 통일했습니다.

// VE compound variant
const itemInsetBySize: Record<RadioSize, string> = {
  lg: `${pxToRem(-4)} ${pxToRem(-6)}`,
  md: `${pxToRem(-4)} ${pxToRem(-6)}`,
  sm: `${pxToRem(-3)} ${pxToRem(-5)}`,
  xs: `${pxToRem(-3)} ${pxToRem(-4)}`,
};

const expansionCompoundVariants = sizeKeys.map(size => ({
  variants: { size, styleOutline: "empty" as const },
  style: {
    selectors: {
      "&::after": { inset: itemInsetBySize[size] },
      "&::before": { inset: itemInsetBySize[size] },
    },
  },
}));

기존 Math.floor(w / 2) + 1 계산은 왼쪽 영역이 1px~2px 더 확장되어 비대칭을 만들고 있었습니다. 다만 해당 오차가 의도된 구현이었는지는 명확하지 않고, 피그마에서도 관련 기준을 확인할 수 없어 우선 대칭으로 수정해 두었습니다.


7. VE selector 제약 대응

VE의 selectors는 내부에서 현재 클래스(&) 바깥의 임의 클래스 선택을 제한합니다. 초기 구현에서 다음 패턴들이 이 규칙을 위반했습니다.

1) 부모에서 형제 요소 타겟

'& input[type="radio"]:checked + .visual'

.visual이 최종 타겟이므로 VE 런타임 에러가 발생합니다. 이를 해결하기 위해 선택자를 radioVisual 내부로 이동했습니다.

'input[type="radio"]:checked + &'

2) recipe 내부에서 자식 selector 직접 타겟

"& > :nth-child(1)"

이 역시 VE 제약에 걸리므로, 자식 레이아웃은 globalStyle로 분리했습니다.

globalStyle(`${radioItemGrid} > :nth-child(1)`, {
  gridColumn: "1",
});

그리드 정렬도 style() 클래스 + globalStyle 조합으로 분리했습니다.


8. disabled 색상 전파 방식 변경

$isDisabled prop을 직접 전달해 텍스트 색상을 변경하던 로직을 부모의 data-disabled 상태를 기준으로 변경하여 prop drilling 없이 disabled 상태가 하위 텍스트까지 전파되도록 했습니다.

selectors: {
  "[data-disabled] &": {
    color: vars.color.semantic.object.subtle,
  },
}

9. @react-aria/radio 도입

접근성과 그룹 상태 관리는 @react-aria/radio + react-stately에 위임했습니다. 버튼 컴포넌트가 @react-aria/buttonuseButton 기반으로 접근성을 처리하므로 Radio 역시 react-aria 훅 기반으로 상태 관리와 접근성 처리를 가져가도록 했습니다.

@react-aria/radio
- useRadio
- useRadioGroup

react-stately
- useRadioGroupState

1) Radio.Root

const state = useRadioGroupState({
  value,
  defaultValue,
  onChange,
  isDisabled: disabled,
  name,
});

const { radioGroupProps } =
  useRadioGroup({ isDisabled: disabled }, state);

기존에는 래퍼 요소가 없었지만, radioGroupProps 적용을 위해 래퍼 div를 추가했습니다. 해당 요소가 ARIA 속성은 DOM에 유지하면서, 레이아웃을 방해하지 않도록 display: contents를 적용했습니다.

<div
  {...radioGroupProps}
  style={{ display: "contents" }}
>
  {children}
</div>

2) Radio.Basic

useRadioRadioGroupState를 필수로 요구하므로 그룹/단독 사용을 분기했습니다. 이를 통해 그룹 내부에서는 checked, name, aria-*, onChange 등이 자동으로 관리됩니다.

if (context?.state) {
  return <RadioBasicGrouped state={context.state} />;
}

return <input type="radio" />;

10. 접근성 개선 사항

항목 기존 변경 후
그룹 role 없음 role="radiogroup" 자동 적용
그룹 disabled data-disabled 수동 처리 aria-disabled 자동 적용
화살표 키 이동 native input 의존 useRadioGroupState 기반
input name 관리 수동 전달 state 기반 자동 관리

⚠️ 선택 상태 관리 방식 변경

이제 Radio.Root 내부에서는 checked prop을 직접 사용할 수 없습니다. 선택 상태는 RadioGroupState 기준으로 관리됩니다.

// Before
<Radio.Root>
  <Radio.Basic value="2" checked />
</Radio.Root>

// After
<Radio.Root defaultValue="2">
  <Radio.Basic value="2" />
</Radio.Root>

📗 참고 자료 (선택)

📢 리뷰 요구 사항 (선택)

✅ 셀프 체크리스트

  • 머지할 브랜치 확인했나요?
  • 이슈는 close 했나요?
  • Reviewers, Labels, Projects를 등록했나요?
  • 기능이 잘 동작하나요?
  • 불필요한 코드는 제거했나요?

closes #433

@itwillbeoptimal itwillbeoptimal self-assigned this May 14, 2026
@itwillbeoptimal itwillbeoptimal added the ♻refactor 리팩토링 label May 14, 2026
@vercel
Copy link
Copy Markdown

vercel Bot commented May 14, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ject-official-web-site-client-web Ready Ready Preview, Comment May 19, 2026 2:54pm

Copy link
Copy Markdown
Contributor

@WonJuneKim WonJuneKim left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정 사항 확인 했습니다! useContainerPresable의 경우 Checkbox 작업 시에 활용할 수 있을 거 같네요. 간단한 코멘트 한번 확인 부탁드립니다!

Comment on lines +273 to +286
overlay,
focusRing,
{
position: "relative",
vars: { [overlayColor]: vars.color.semantic.interaction.normal },
selectors: {
// checked 상태에서 overlay 색상을 accent으로 전환
'&:has(input[type="radio"]:checked)': {
vars: { [overlayColor]: vars.color.semantic.accent.neutral },
},
// overlay shape: element 경계와 동일. empty 모드에서는 compoundVariants로 확장
"&::after": { inset: 0, borderRadius: "inherit" },
// focus ring shape: element 경계와 동일. empty 모드에서는 compoundVariants로 확장
"&::before": { inset: 0, borderRadius: "inherit" },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 부분은 interation 정책을 잘 따르고 있는 구간인거 같아요 :)

radioAlign?: RadioAlign;
disabled?: boolean;
value?: string;
defaultValue?: string;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 컴포넌트를 처음 구현할때에는 비제어로 사용하는 부분을 배제했었는데 완성도가 더 올라간거 같습니다.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

다만 제어 형태와 비제어 형태를 동시에 사용하는 케이스가 없기 때문에 추가로 narrowing 할 수 있는 부분인거 같아요.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

60e8397 에서 타입 내로잉을 적용해 보았습니다!

*/
export function useContainerPressable({ disabled = false }: UseContainerPressableOptions = {}) {
const { hoverProps, isHovered } = useHover({ isDisabled: disabled });
const { pressProps, isPressed } = usePress({ isDisabled: disabled });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useRadio가 노출하는 isPressed 값을 사용하지 않고 usePress 로 가져온 이유가 있으신가요?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useRadioisPressed를 제공하는 것은 맞지만 아래 이유로 별도의 usePress를 사용했습니다

  1. useRadio는 내부 컴포넌트인 RadioBasicGrouped에서 호출되고, data-pressed가 필요한 요소는 부모인 RadioItem의 div입니다. isPressed를 상위로 전달하려면 컨텍스트나 prop drilling이 필요해 구조가 복잡해질 것으로 생각했습니다.

  2. useContainerPressableusePressable에 대응되는 컨테이너용 범용 훅으로 설계했습니다. Radio뿐 아니라 Checkbox처럼 native form 요소를 감싸는 다양한 컨테이너 컴포넌트에서 재사용할 수 있도록 특정 훅에 의존하지 않도록 했습니다.

: radioStyle === "outline";
const parentContext = useRadioContext();

const size = radioSize ?? parentContext?.size ?? "md";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(참고) 예측가능성면에서 내부 자체 prop 과 context 의 prop을 함수 등으로 추출할 수 있을 거 같아요.
RadioItem(현재 부분) 에서는 직접 선언한 prop이 우선시 되고 Basic에서는 Context 우선이 되기 때문에 생각하기에 따라서 일관성이 떨어질 수 있을 거 같아요.

다만 저 역시 Basic이 당연히 Context를 따를 거라는 생각은 가지고 있어서 단순 코멘트로 남깁니다!

암묵적으로 이해하고 있는 Item 내에서 선언한 prop과 Context 내부 공통 prop 값, 그리고 선언되지 않았을 때 할당되는 default 값을 밖으로 드러내도 좋을 거 같습니다.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

우선 해당 우선 순위 차이는 말씀해 주신 것처럼 의도한 동작이 맞습니다

RadioItemprop > context > default 순으로 값을 결정한 뒤 하위 컨텍스트로 전달하고, RadioBasic은 항상 상위에서 최종 결정된 값을 따르도록 context > prop > default 순으로 처리했습니다. (그래서 개별 항목의 size 변경은 RadioBasic보다는 RadioItem에서 처리해야 합니다)

하지만 말씀 주신 것처럼 코드만 봐서는 의도가 암묵적으로 느껴질 수 있을 것 같아 언급해주신 방법을 포함해서 개선 방향을 고민해보겠습니다 🙂

Copy link
Copy Markdown
Member

@ccconac ccconac left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확인하면서 질문 및 문제가 될 수 있을 만한 부분 코멘트 남겨 두었습니다~
간단하게 확인 부탁드려요. 작업하시느라 수고 많으셨습니다! 👏

>
{children}
</RadioGroupProvider>
<div {...radioGroupProps} style={{ display: "contents" }}>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

display: "contents"className이 아닌 inline style로 작성하신 특별한 이유가 있을까요?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

단순한 스타일링이라 별다른 의도 없이 인라인으로 작성했던 것 같습니다..😅 8cbeaf3 에서 VE className 기반으로 교체했습니다

Comment on lines +124 to +131
<RadioBasicGrouped
size={size}
value={String(value ?? "")}
isDisabled={isDisabled}
state={context.state}
forwardedRef={forwardedRef}
restProps={restProps}
/>
Copy link
Copy Markdown
Member

@ccconac ccconac May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(참고) line:117에서 onChange prop을 받고 있는데, 그룹으로는 전달되지 않고 있어서 Radio.Basic 개별 onChange가 호출되지 않을 수 있다는 이슈가 있어 보여요.

삭제된 기존 코드를 보면:

  1. 그룹 onChange 호출
  2. 개별 input onChange 호출

의 흐름이라 기존 사용처의 side effect가 누락될 수 있어 보여서 검토 부탁드립니다!

const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
  if (groupContext?.onChange && value !== undefined) {
    groupContext.onChange(String(value));
  }
  onChange?.(e);
};

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확인해 보니 실제로 그룹 케이스에서 onChange 전달이 누락된 상태였네요 😮 구조분해에서 onChangerestProps에서 빠져 나왔는데, 이후 RadioBasicGrouped로 전달되지 않아 실제로 호출되지 않고 있었습니다

RadioBasicGroupedPropsonChange를 추가하고, mergeProps(inputProps, restProps, { onChange }) 형태로 내부 핸들러와 체이닝되도록 수정했습니다! (9946e13)

({ radioSize, value, checked, disabled, onChange, name, ...restProps }, forwardedRef) => {
const context = useRadioContext();
const size = context?.size ?? radioSize ?? "md";
const isDisabled = disabled ?? context?.disabled ?? false;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Root/Item 둘 중 하나가 disabled면 하위 요소가 모두 disabled 된다는 조건문이 line:64에 작성되어 있는데, 이 부분은 앞서 말한 조건을 해제할 수 있어 보여요.

// line:64 → Radio.Item이 disabled거나 parentContext(Radio.Root)가 disabled일 경우 true
const isDisabled = disabled || (parentContext?.disabled ?? false);

// line:120 → 상위 context가 disabled여도 Basic에서 false 지정할 경우 isDisabled: false가 됨
// false ?? true ?? false → false
const isDisabled = disabled ?? context?.disabled ?? false;

전체적으로 비활성화 상태인데 혼자 활성화가 되는 등, 상위 컨텍스트를 무시할 수 있어 보여서 아래처럼 교체되어야 할 것 같습니다.

const isDisabled = disabled || (context?.disabled ?? false);

Copy link
Copy Markdown
Member Author

@itwillbeoptimal itwillbeoptimal May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null 병합을 이상하게 쓰고 있었네요 😭 d3ce66e 에서 수정했습니다!

return (
<RadioBasicGrouped
size={size}
value={String(value ?? "")}
Copy link
Copy Markdown
Member

@ccconac ccconac May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(참고) 타입을 확인해 보니 RadioBasicProps에서 value가 optional이라 value가 누락된 item들이 동일한 value로 등록될 수 있어 보여요. 예외를 막기 위해 required로 제한하는 등의 guard가 필요할 듯합니다.

예외적 상황이 발생할 수 있어 참고 코멘트로 남깁니다!

Copy link
Copy Markdown
Member Author

@itwillbeoptimal itwillbeoptimal May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

마이그레이션 전에는 valueundefined인 경우 onChange를 호출하지 않는 방식으로 처리하고 있었는데요, 기존 스토리 파일에서도 value 없이 사용되고 있었고, 해당 케이스들이 정상 동작하는 것처럼 보여 value가 optional이어도 괜찮다고 생각했던 것 같습니다

이후 마이그레이션 과정에서 useRadiovalue를 필수값으로 요구하면서, optional인 value를 처리하기 위해 String(value ?? "")로 처리했었습니다.

8158441 에서 value를 필수값으로 강제하고, 스토리 파일에도 value를 추가했습니다!

@itwillbeoptimal itwillbeoptimal merged commit 607210f into dev May 23, 2026
4 of 5 checks passed
@itwillbeoptimal itwillbeoptimal deleted the refactor/433-jds-radio-migration branch May 23, 2026 08:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

♻refactor 리팩토링

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor: (JDS) Radio 컴포넌트 vanilla-extract 적용

3 participants