Skip to content

Commit 759bedc

Browse files
authored
feat: Tab 컴포넌트 구현 (#273)
* feat: 토큰 로직 수정 및 생성 - stroke-weight에도 px이 붙도록 수정 - prettier 적용 * feat: sva 유틸리티 함수 구현 - 추후 공통 유틸함수로 이동 예정 * feat: mergeRefs 구현 - 추후 utils폴더로 이동 예정 * feat: RecipeVariant 타입 선언 * feat: styleContext 구현 * feat: sva를 통해 tab style 선언 * feat: Tab 컴포넌트 구현 * feat: Tab 컴포넌트 스토리북 추가 * chore: storybook reset style 추가 - 추후 변경 예정 * fix: styleContext에서 recipeOrFactory 변수명 변경 및 예외 처리 개선 * feat: cva/sva 타입 및 함수 개선 - 타입 헬퍼 함수로 개선 * feat: tab variant 변경(isItemStreched -> layout) * feat: tab 컴포넌트 스타일 적용 * feat: tab story 수정 * chore: tab indicator width변경
1 parent de2df63 commit 759bedc

File tree

11 files changed

+6221
-4898
lines changed

11 files changed

+6221
-4898
lines changed

packages/jds/.storybook/preview.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { Preview } from '@storybook/react';
2-
import { ThemeProvider } from '@emotion/react';
3-
import { Global } from '@emotion/react';
4-
import { globalStyles } from '../src/tokens/globalStyles';
2+
import { Global, ThemeProvider } from '@emotion/react';
53
import { theme } from '../src/tokens/theme';
4+
import { globalStyles } from '../src/tokens/globalStyles';
5+
import { GlobalStyles } from '../src/style/globalStyle';
66

77
const preview: Preview = {
88
parameters: {
@@ -41,6 +41,7 @@ const preview: Preview = {
4141
return (
4242
<ThemeProvider theme={theme}>
4343
<Global styles={globalStyles} />
44+
<GlobalStyles />
4445
<div data-theme={context.globals.theme}>
4546
<Story />
4647
</div>
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
/* eslint-disable @typescript-eslint/no-empty-object-type */
2+
/* eslint-disable @typescript-eslint/no-unsafe-return */
3+
/* eslint-disable @typescript-eslint/no-explicit-any */
4+
import type { Interpolation, Theme } from '@emotion/react';
5+
import { useTheme } from '@emotion/react';
6+
import { createContext, useContext, forwardRef } from 'react';
7+
import { useMemo } from 'react';
8+
import type { ElementType, ComponentPropsWithoutRef, ElementRef, ComponentType } from 'react';
9+
10+
import type { RecipeVariant as RecipeVariantProps } from './sva';
11+
12+
type EmotionStyle = Interpolation<Theme>;
13+
14+
/**
15+
* sva runtime:
16+
* recipe(variants?) => { root, list, trigger, ... }
17+
* 각 값은 Emotion css prop에 넣을 수 있는 Interpolation<Theme>
18+
*/
19+
// Public helper type aliases (Panda-like)
20+
export type Assign<A, B> = Omit<A, keyof B> & B;
21+
export type JsxStyleProps = { css?: EmotionStyle };
22+
export type JsxHTMLProps<Base, Extra = {}> = Assign<Base, Extra>;
23+
export type UnstyledProps = { unstyled?: boolean };
24+
export type ComponentProps<T extends ElementType> = ComponentPropsWithoutRef<T>;
25+
export type JsxFactoryOptions<P = {}> = { defaultProps?: Partial<P> };
26+
27+
// Slot recipe
28+
type AnySlotRecipeRuntime = (props?: any) => Record<string, EmotionStyle>;
29+
type AnySlotRecipeFactory = (...args: any[]) => AnySlotRecipeRuntime;
30+
type AnyRecipeInput = AnySlotRecipeRuntime | AnySlotRecipeFactory;
31+
32+
type RuntimeOf<T> = T extends (...args: any[]) => infer R
33+
? R extends (...a: any[]) => any
34+
? R
35+
: T
36+
: never;
37+
type SlotsMapOf<T> = ReturnType<RuntimeOf<T>>;
38+
type SlotsOf<T> = keyof SlotsMapOf<T> & string;
39+
type VariantsOf<T> = RecipeVariantProps<RuntimeOf<T>>;
40+
41+
export interface StyleContext<R extends AnyRecipeInput> {
42+
withRootProvider: <T extends ElementType>(
43+
Component: T,
44+
options?: JsxFactoryOptions<ComponentProps<T>>,
45+
) => ComponentType<ComponentProps<T> & UnstyledProps & VariantsOf<R>>;
46+
withProvider: <T extends ElementType>(
47+
Component: T,
48+
slot: SlotsOf<R>,
49+
options?: JsxFactoryOptions<ComponentProps<T>>,
50+
) => ComponentType<
51+
JsxHTMLProps<ComponentProps<T> & UnstyledProps, Assign<VariantsOf<R>, JsxStyleProps>>
52+
>;
53+
withContext: <T extends ElementType>(
54+
Component: T,
55+
slot: SlotsOf<R>,
56+
options?: JsxFactoryOptions<ComponentProps<T>>,
57+
) => ComponentType<JsxHTMLProps<ComponentProps<T> & UnstyledProps, JsxStyleProps>>;
58+
useSlotStyles: () => SlotsMapOf<R>;
59+
useSlotStyle: (slot: SlotsOf<R>) => EmotionStyle | undefined;
60+
}
61+
62+
const getDisplayName = (Component: any) => Component?.displayName || Component?.name || 'Component';
63+
64+
export function createStyleContext<R extends AnyRecipeInput>(recipeOrFactory: R): StyleContext<R> {
65+
type Slots = SlotsOf<R>;
66+
type Variants = VariantsOf<R>;
67+
type StylesMap = SlotsMapOf<R>; // { root: EmotionStyle; list: EmotionStyle; ... }
68+
69+
const StylesContext = createContext<StylesMap | null>(null);
70+
71+
/* ---------- hooks ---------- */
72+
73+
const useSlotStyles = (): StylesMap => {
74+
const value = useContext(StylesContext);
75+
if (!value) {
76+
throw new Error('StyleContext Provider 밖에서 useSlotStyles를 호출했습니다.');
77+
}
78+
return value;
79+
};
80+
81+
const useSlotStyle = (slot: Slots): EmotionStyle | undefined => {
82+
const styles = useSlotStyles();
83+
return styles[slot];
84+
};
85+
86+
/* ---------- Root Provider ---------- */
87+
88+
function withRootProvider<T extends ElementType>(
89+
Component: T,
90+
// _options?: JsxFactoryOptions<ComponentProps<T>>,
91+
) {
92+
type BaseProps = ComponentProps<T>;
93+
type Props = BaseProps & Variants & UnstyledProps & JsxStyleProps;
94+
95+
const Wrapped = forwardRef<ElementRef<T>, Props>((props, ref) => {
96+
// recipe factory 또는 runtime을 처리합니다
97+
const theme = useTheme();
98+
const runtime = useMemo(() => {
99+
try {
100+
const factoryRuntime = (recipeOrFactory as AnySlotRecipeFactory)(theme);
101+
return typeof factoryRuntime === 'function'
102+
? factoryRuntime
103+
: (recipeOrFactory as AnySlotRecipeRuntime);
104+
} catch {
105+
return recipeOrFactory as AnySlotRecipeRuntime;
106+
}
107+
}, [theme]);
108+
109+
// variant props는 전부 runtime으로 넘깁니다
110+
const styles = runtime(props as Variants) as StylesMap;
111+
const rootStyle = props.unstyled ? undefined : styles.root;
112+
113+
return (
114+
<StylesContext.Provider value={styles}>
115+
<Component ref={ref} {...(props as any)} css={[rootStyle, (props as any).css]} />
116+
</StylesContext.Provider>
117+
);
118+
});
119+
120+
Wrapped.displayName = `StyleContextRoot(${getDisplayName(Component)})`;
121+
return Wrapped as unknown as ComponentType<ComponentProps<T> & UnstyledProps & Variants>;
122+
}
123+
124+
/* ---------- Provider (slot + Provider 둘 다) ---------- */
125+
126+
function withProvider<T extends ElementType>(
127+
Component: T,
128+
slot: Slots,
129+
// _options?: JsxFactoryOptions<ComponentProps<T>>,
130+
) {
131+
type BaseProps = ComponentProps<T>;
132+
type Props = BaseProps & Variants & UnstyledProps & JsxStyleProps;
133+
134+
const Wrapped = forwardRef<ElementRef<T>, Props>((props, ref) => {
135+
const theme = useTheme();
136+
const runtime = useMemo(() => {
137+
try {
138+
const maybe = (recipeOrFactory as AnySlotRecipeFactory)(theme);
139+
return typeof maybe === 'function' ? maybe : (recipeOrFactory as AnySlotRecipeRuntime);
140+
} catch {
141+
return recipeOrFactory as AnySlotRecipeRuntime;
142+
}
143+
}, [theme]);
144+
145+
const styles = runtime(props as Variants) as StylesMap;
146+
const slotStyle = props.unstyled ? undefined : styles[slot];
147+
148+
return (
149+
<StylesContext.Provider value={styles}>
150+
<Component ref={ref} {...(props as any)} css={[slotStyle, (props as any).css]} />
151+
</StylesContext.Provider>
152+
);
153+
});
154+
155+
Wrapped.displayName = `StyleContextProvider(${getDisplayName(Component)}:${slot})`;
156+
return Wrapped as unknown as ComponentType<
157+
JsxHTMLProps<ComponentProps<T> & UnstyledProps, Assign<Variants, JsxStyleProps>>
158+
>;
159+
}
160+
161+
/* ---------- Consumer (Context만 사용하는 slot) ---------- */
162+
163+
function withContext<T extends ElementType>(
164+
Component: T,
165+
slot: Slots,
166+
// _options?: JsxFactoryOptions<ComponentProps<T>>,
167+
) {
168+
type BaseProps = ComponentProps<T>;
169+
type Props = BaseProps & UnstyledProps & JsxStyleProps;
170+
171+
const Wrapped = forwardRef<ElementRef<T>, Props>((props, ref) => {
172+
const styles = useContext(StylesContext);
173+
174+
if (!styles) {
175+
if (process.env.NODE_ENV !== 'production') {
176+
console.error(
177+
`StyleContext: "${getDisplayName(
178+
Component,
179+
)}"가 Provider 밖에서 렌더링되었습니다. slot="${slot}".`,
180+
);
181+
}
182+
// Provider 없으면 그냥 원래 css 그대로 내려보냅니다
183+
return <Component ref={ref} {...(props as any)} css={(props as any).css} />;
184+
}
185+
186+
const slotStyle = props.unstyled ? undefined : styles[slot];
187+
188+
return <Component ref={ref} {...(props as any)} css={[slotStyle, (props as any).css]} />;
189+
});
190+
191+
Wrapped.displayName = `StyleContextConsumer(${getDisplayName(Component)}:${slot})`;
192+
return Wrapped as unknown as ComponentType<
193+
JsxHTMLProps<ComponentProps<T> & UnstyledProps, JsxStyleProps>
194+
>;
195+
}
196+
197+
return {
198+
withRootProvider,
199+
withProvider,
200+
withContext,
201+
useSlotStyles,
202+
useSlotStyle,
203+
} as unknown as StyleContext<R>;
204+
}

0 commit comments

Comments
 (0)