refactor: JDS 버튼 컴포넌트 vanilla-extract 마이그레이션#432
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| {(["xs", "sm", "md", "lg"] as const).map(size => ( | ||
| <FlexColumn key={size} gap='12px'> | ||
| <Label>{size.toUpperCase()}:</Label> | ||
| <h3 className='semantic-textStyle-label-lg-bold'>{size.toUpperCase()}:</h3> |
There was a problem hiding this comment.
Label, Title 등을 더 이상 사용하지 않는다고 하셔서 스토리북에도 h3 요소로 교체해 두었습니다
There was a problem hiding this comment.
지금 확인해 보니 스토리북에서 사용 중인 Label은 .storybook/utils/에 별도로 정의된 컴포넌트였네요..🫠
FlexRow, FlexColumn, Label도 현재 Emotion 기반으로 작성되어 있는데, 이참에 함께 마이그레이션해서 사용하는 게 좋을까요?
There was a problem hiding this comment.
@itwillbeoptimal 질문을 먼저 보게 되어서 질문에 대한 코멘트 먼저 남겨 둡니다.
추후에 마이그레이션이 완료되면 Emotion은 완전히 프로젝트에서 제거 예정이기 때문에, storybook에서만 사용하는 유틸이어도 함께 마이그레이션해야 할 듯합니다!
WonJuneKim
left a comment
There was a problem hiding this comment.
확인이 늦었네요! 죄송합니다 😢 전반적으로 잘 작동하는거 같고 추가로 관련된 코멘트들만 확인 부탁드립니다.
수고 많으셨습니다 :)
| {prefixIcon && <Icon name={prefixIcon} size={iconSize} />} | ||
| {children} | ||
| {suffixIcon && <Icon name={suffixIcon} size={iconSize} />} |
There was a problem hiding this comment.
(선택) 해당 컴포넌트 자체에서는 문제가 없는데, data-part="icon" 으로 선언 후에 별도로 스타일 선언을 해주는 방식도 있을 거 같아요.
다만 이 컴포넌트(+LabelButton 포함) 의 경우 디자인 에셋에서 icon과 label의 색상이 동일하게 배합되어있어 실질적으로 문제는 없습니다!
| { | ||
| children, | ||
| size = "md", | ||
| intent = "destructive", |
There was a problem hiding this comment.
이전 구현에서 누락된 부분인 거 같은데, 해당 type이 required(intent가 required) 인 상태이기 때문에 default 값이 무의미해지는 이슈가 있는 거 같아요!
There was a problem hiding this comment.
또한 해당 부분의 경우 IconButton의 경우 별도의 컴포넌트로 분기하지 않고 통합하는 방식을 채택했었는데 BlockButton, LabelButton의 경우 엮여있는 스타일 세트가 다소 많다보니 기존의 구현을 유지하는 방향도 괜찮아보이네요.
혹시 Feedback 을 IconButton의 사례처럼 오버라이딩 용으로만 처리하려는 시도를 해보셨는지, 그렇다면 해당 작업에서 어떤 이슈가 있었는지 알 수 있을까요?
There was a problem hiding this comment.
이전 구현에서 누락된 부분인 거 같은데, 해당 type이 required(intent가 required) 인 상태이기 때문에 default 값이 무의미해지는 이슈가 있는 거 같아요!
2c6e72c 피그마에 디폴트 값이 정의되어 있어서 해당 prop을 optional로 수정했습니다!
There was a problem hiding this comment.
또한 해당 부분의 경우 IconButton의 경우 별도의 컴포넌트로 분기하지 않고 통합하는 방식을 채택했었는데 BlockButton, LabelButton의 경우 엮여있는 스타일 세트가 다소 많다보니 기존의 구현을 유지하는 방향도 괜찮아보이네요.
혹시 Feedback 을 IconButton의 사례처럼 오버라이딩 용으로만 처리하려는 시도를 해보셨는지, 그렇다면 해당 작업에서 어떤 이슈가 있었는지 알 수 있을까요?
지금처럼 분리시켜 두는 게 더 적절한 것 같습니다~ BlockButton.Feedback은 항상 solid 스타일을 사용하고 intent만으로 색상이 결정되지만, BlockButton.Basic은 variant × hierarchy 조합을 기반으로 동작해서 API 성격 자체가 다른 것 같습니다
그래서 IconButton과 같은 방식으로 통합하려고 하면 타입 복잡도만 증가하고 구조적으로 얻는 이점은 크지 않을 것 같아요
| lg: { inset: "-0.25rem -0.5rem", borderRadius: vars.scheme.semantic.radius["6"] }, | ||
| md: { inset: "-0.1875rem -0.375rem", borderRadius: vars.scheme.semantic.radius["6"] }, | ||
| sm: { inset: "-0.125rem -0.25rem", borderRadius: vars.scheme.semantic.radius["4"] }, | ||
| xs: { inset: "-0.0625rem -0.1875rem", borderRadius: vars.scheme.semantic.radius["4"] }, |
There was a problem hiding this comment.
LabelButton은 기본적으로 padding: 0이므로 시각 영역보다 더 넓은 탭/포커스 영역이 필요해 기존 offsetMap의 px 값을 size별 음수 inset rem 값으로 변환했습니다.
sizeVariants의 selector에서 ::before, ::after에 동일한 inset 값을 적용해 focusRing과 overlay가 동일한 확장 영역을 갖도록 했습니다.
IconButton에서는 아이콘 자체가 px로 사용되어서 해당 부분을 px로 변경했었는데 focusRing이 px 단위로 선언되어있어서 반영한 결정이었어요. 다만 LabelButton의 경우 텍스트 자체가 em 단위를 따르고 있기 때문에 적절한 결정인거 같아요.
There was a problem hiding this comment.
해당 부분 pxToRem 유틸 사용하도록 수정해 두었습니다!
| // Compound variants | ||
| // keyof typeof <map> 이 satisfies Record<BlockButtonHierarchy, ...> 에 의해 | ||
| // BlockButtonHierarchy로 좁혀지므로 Object.keys 단언이 안전하다. | ||
| type SolidKey = keyof typeof solidColorsByHierarchy; | ||
| type OutlinedKey = keyof typeof outlinedColorsByHierarchy; | ||
| type EmptyKey = keyof typeof emptyColorsByHierarchy; | ||
|
|
||
| const solidCompoundVariants = (Object.keys(solidColorsByHierarchy) as SolidKey[]).map( |
There was a problem hiding this comment.
(참고) Object.keys(solidColorsByHierarchy) as SolidKey[] 의 경우 string[]을 반환하기 때문에 타입 단언이 강제되는거 같아요.
types 파일에 해당 옵션들을 선언해서 SSOT로 사용한다면 해당 타입을 스토리파일이나 스타일 파일에 모두 동일하게 적용할 수도 있을 거 같아요. 다만 정적인 디자인 조합인 현재 상황에서는 크게 문제될 부분은 아닌거 같아요.
There was a problem hiding this comment.
기존에 Object.keys(solidColorsByHierarchy)를 사용한 이유는 해당 파일 내부에서 맵 자체를 SSOT로 두기 위함이었는데요, hierarchy를 맵에 추가하면 compound variant도 자동으로 생성되는 구조라 누락을 방지할 수 있었습니다
다만 말씀해 주신 것처럼 스토리 파일까지 포함하면 동일한 값이 여러 곳에 분산되고, Object.keys의 string[] 반환으로 인한 불필요한 타입 단언도 사용하고 있어 types 파일에 as const 배열을 두도록 정리했습니당 (21440fa)
| defaultVariants: { | ||
| hierarchy: "primary", | ||
| variant: "solid", | ||
| size: "md", |
There was a problem hiding this comment.
이전 구현에서 제가 누락한 부분이기도 한데, 컴포넌트 선언 부에서 작성한 인터페이스 기본 값이 SSOT가 되어야한다고 생각해요. 결국에 스타일 코드는 컴포넌트가 소비하는 입장이라 가독성 면에서도 컴포넌트에 우선적으로 선언된 default를 본다고 생각해요.
추가로 컴포넌트에 default 값이 선언되어있으면 recipe의 defaultVariants는 실질적으로 의미를 상실하는 거 같더라구요.
There was a problem hiding this comment.
컴포넌트 props 기본값이 SSOT라 recipe의 defaultVariants가 dead code였겠네요! 451d4b7 에서 IconButton도 같이 defaultVariants를 제거해 두었습니다 🙂
| fontSize: "var(--typo-primitive-fontSize-label-lg)", | ||
| lineHeight: "var(--typo-primitive-font-lineHeight-label-lg)", | ||
| fontFamily: "var(--typo-primitive-typeface-label)", |
There was a problem hiding this comment.
(단순 의견) typo 부분만 var -- 형태로 선언되는걸 보니 (backgroundColor 등과는 다르게) 해당 부분도 토큰화를 진행해야할 거 같네요. 해당 작업은 이 브랜치 외의 스코프이라고 생각하는데 보다 보니 생각이 들어서 공유드립니다.
There was a problem hiding this comment.
(참고) @WonJuneKim @itwillbeoptimal typo 토큰화는 Hero/Title/Label component 스코프라고 생각되어 아래에 반영해 두었습니다.
다만, 위 작업에서는 세 컴포넌트 제거에 대한 대응까지만 해 둔 상태입니다.
위처럼 var -- 형태로 선언되는 것들은 따로 교체해 두지 않았으니 참고 부탁드려요.
There was a problem hiding this comment.
88ddd9f 우선 문자열 리터럴에서 primitive 토큰을 참조하도록 수정했습니다!
| { | ||
| children, | ||
| size = "md", | ||
| intent = "destructive", |
There was a problem hiding this comment.
이전 구현에서 누락된 부분인 거 같은데, 해당 type이 required(intent가 required) 인 상태이기 때문에 default 값이 무의미해지는 이슈가 있는 거 같아요!
원준 님이 BlockButton 코멘트로 남겨 주셨던 내용인데, 동일한 이슈가 LabelButton에서도 반복되는 것 같아요.
| disabled={disabled} | ||
| {...restProps} | ||
| {...mergeProps(pressableProps, restProps)} | ||
| data-part='root' |
There was a problem hiding this comment.
(참고) 커스텀 가능성이 크지 않은 prop이긴 하지만, 타입 상으로 만일 사용자가 data-part={custom} 속성을 component에 주입했을 때 root가 항상 덮어쓴다는 문제점이 있어 보여요.
최상단 요소임을 명시하는 값이니 외부에서 주입하지 못하도록 아래처럼 data-part를 타입에서 막아 버리는 것도 방법이 될 수 있어 보이네요.
interface BlockButtonProps extends Omit<ComponentPropsWithoutRef<"button">, "data-part"> {
...
}There was a problem hiding this comment.
해당 컴포넌트들이 항상 루트 요소로 사용된다는 것이 불변 사항이라면 타입 레벨에서 외부 주입을 제한하는 것도 괜찮은 방향인 것 같네요!
다만 리액트에서 data-* 속성을 Omit으로 완전히 제거하기 어려워 never 타입으로 제한했습니다 (b8579d3)
💡 작업 내용
BlockButtonvanilla-extract 마이그레이션LabelButtonvanilla-extract 마이그레이션💡 자세한 설명
1. 개요
BlockButton과LabelButton의 스타일링 레이어를 Emotion에서 vanilla-extract로 마이그레이션했습니다. 기존 Emotion 스타일 파일을 제거하고 동일 디렉토리에 VE 구현체로 대체했습니다.recipe구조,overlay/focusRing유틸 합성,usePressable+mergeProps기반 컴포넌트 패턴은 마이그레이션된IconButton구조를 참고했습니다. (#427)2. hierarchy 기반 compoundVariant 생성
BlockButton의
variant × hierarchy조합은 총 12가지(solid/outlined/empty × accent/primary/secondary/tertiary)로, 직접 나열하게 되면 누락될 가능성이 높습니다.satisfies기반 color map에서Object.keys를 파생시키므로, hierarchy 변경 시 color map만 수정하면 compoundVariants도 자동으로 동기화됩니다.LabelButton의hierarchyVariants/intentVariants는 항목 수가 적어 compoundVariant 대신 inline variant 객체 +satisfies로 구현했습니다.3. recipe 내부에서 타이포그래피 스타일 관리
textStyleClassNames.labelLg같은 globalStyle 클래스를 별도className으로 합성하는 방식을 고민했었는데,sizevariant 하나로 padding, borderRadius, typography가 함께 결정되므로 typography도 recipe 내부에서 함께 관리하도록 했습니다.globalStyle기반 클래스는 recipe 내부 스타일과 우선 순위가 충돌될 수 있어, typography 값을 CSS custom property 형태로 인라인 적용했습니다.4. border 스타일 역할 분리
VE는 base → variant 순서로 CSS를 출력하므로 cascade 순서가 보장됩니다.
borderWidth/borderStyle은 variant 레벨에서,borderColor는 compoundVariant 레벨에서 관리하도록 했습니다.5.
LabelButton탭 영역 확장LabelButton은 기본적으로padding: 0이므로 시각 영역보다 더 넓은 탭/포커스 영역이 필요해 기존offsetMap의 px 값을 size별 음수 inset rem 값으로 변환했습니다.sizeVariants의 selector에서::before,::after에 동일한 inset 값을 적용해 focusRing과 overlay가 동일한 확장 영역을 갖도록 했습니다.📗 참고 자료 (선택)
📢 리뷰 요구 사항 (선택)
✅ 셀프 체크리스트
closes #428