Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions packages/jds/.storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Preview } from '@storybook/react';
import { ThemeProvider } from '@emotion/react';
import { Global } from '@emotion/react';
import { globalStyles } from '../src/tokens/globalStyles';
import { Global, ThemeProvider } from '@emotion/react';
import { theme } from '../src/tokens/theme';
import { globalStyles } from '../src/tokens/globalStyles';
import { GlobalStyles } from '../src/style/globalStyle';

const preview: Preview = {
parameters: {
Expand Down Expand Up @@ -41,6 +41,7 @@ const preview: Preview = {
return (
<ThemeProvider theme={theme}>
<Global styles={globalStyles} />
<GlobalStyles />
<div data-theme={context.globals.theme}>
<Story />
</div>
Expand Down
204 changes: 204 additions & 0 deletions packages/jds/src/components/Tab/styleContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/* eslint-disable @typescript-eslint/no-empty-object-type */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-explicit-any */
Comment on lines +1 to +3
Copy link
Contributor

Choose a reason for hiding this comment

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

아직 휴리스틱한 부분이 있는 로직이지만, 추후 타입 정의에 대해 어느정도까지 허용해줄 것인가 에 대한 논의가 있으면 좋을 거 같네요!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

네 일단
2번째 줄의 eslint경우에는 너무 엄격하게 체크하고 있는것같아서, 전역적으로 끄면 좋을것같고
1,3번은 지금 케이스 정도에서만 지역적으로 끄고, 전역적으로는 막아놓는게 좋을것 같습니다

import type { Interpolation, Theme } from '@emotion/react';
import { useTheme } from '@emotion/react';
import { createContext, useContext, forwardRef } from 'react';
import { useMemo } from 'react';
import type { ElementType, ComponentPropsWithoutRef, ElementRef, ComponentType } from 'react';

import type { RecipeVariant as RecipeVariantProps } from './sva';

type EmotionStyle = Interpolation<Theme>;

/**
* sva runtime:
* recipe(variants?) => { root, list, trigger, ... }
* 각 값은 Emotion css prop에 넣을 수 있는 Interpolation<Theme>
*/
// Public helper type aliases (Panda-like)
export type Assign<A, B> = Omit<A, keyof B> & B;
export type JsxStyleProps = { css?: EmotionStyle };
export type JsxHTMLProps<Base, Extra = {}> = Assign<Base, Extra>;
export type UnstyledProps = { unstyled?: boolean };
export type ComponentProps<T extends ElementType> = ComponentPropsWithoutRef<T>;
export type JsxFactoryOptions<P = {}> = { defaultProps?: Partial<P> };

// Slot recipe
type AnySlotRecipeRuntime = (props?: any) => Record<string, EmotionStyle>;
type AnySlotRecipeFactory = (...args: any[]) => AnySlotRecipeRuntime;
type AnyRecipeInput = AnySlotRecipeRuntime | AnySlotRecipeFactory;

type RuntimeOf<T> = T extends (...args: any[]) => infer R
? R extends (...a: any[]) => any
? R
: T
: never;
type SlotsMapOf<T> = ReturnType<RuntimeOf<T>>;
type SlotsOf<T> = keyof SlotsMapOf<T> & string;
type VariantsOf<T> = RecipeVariantProps<RuntimeOf<T>>;

export interface StyleContext<R extends AnyRecipeInput> {
withRootProvider: <T extends ElementType>(
Component: T,
options?: JsxFactoryOptions<ComponentProps<T>>,
) => ComponentType<ComponentProps<T> & UnstyledProps & VariantsOf<R>>;
withProvider: <T extends ElementType>(
Component: T,
slot: SlotsOf<R>,
options?: JsxFactoryOptions<ComponentProps<T>>,
) => ComponentType<
JsxHTMLProps<ComponentProps<T> & UnstyledProps, Assign<VariantsOf<R>, JsxStyleProps>>
>;
withContext: <T extends ElementType>(
Component: T,
slot: SlotsOf<R>,
options?: JsxFactoryOptions<ComponentProps<T>>,
) => ComponentType<JsxHTMLProps<ComponentProps<T> & UnstyledProps, JsxStyleProps>>;
useSlotStyles: () => SlotsMapOf<R>;
useSlotStyle: (slot: SlotsOf<R>) => EmotionStyle | undefined;
}

const getDisplayName = (Component: any) => Component?.displayName || Component?.name || 'Component';

export function createStyleContext<R extends AnyRecipeInput>(recipeOrFactory: R): StyleContext<R> {
type Slots = SlotsOf<R>;
type Variants = VariantsOf<R>;
type StylesMap = SlotsMapOf<R>; // { root: EmotionStyle; list: EmotionStyle; ... }

const StylesContext = createContext<StylesMap | null>(null);

/* ---------- hooks ---------- */

const useSlotStyles = (): StylesMap => {
const value = useContext(StylesContext);
if (!value) {
throw new Error('StyleContext Provider 밖에서 useSlotStyles를 호출했습니다.');
}
return value;
};

const useSlotStyle = (slot: Slots): EmotionStyle | undefined => {
const styles = useSlotStyles();
return styles[slot];
};

/* ---------- Root Provider ---------- */

function withRootProvider<T extends ElementType>(
Component: T,
Copy link
Contributor

Choose a reason for hiding this comment

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

withRootProviderwithProvider 는 매개변수의 차이 로 보이는데, withProvider로 일원화 하지 않으신 이유가 추후 복잡한 variant까지 고려한 케이스 인가요? 또는 Slot의 root를 명시적으로 사용하기 위함 인가요?

Copy link
Collaborator Author

@whdgur5717 whdgur5717 Nov 24, 2025

Choose a reason for hiding this comment

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

headless ui류의 라이브러리에서 가끔 Root요소에 아무 props를 붙이지 못하는 경우가 존재하는데, 그런 이유때문에 나눠놓는다고 생각하시면 됩니다

// _options?: JsxFactoryOptions<ComponentProps<T>>,
) {
type BaseProps = ComponentProps<T>;
type Props = BaseProps & Variants & UnstyledProps & JsxStyleProps;

const Wrapped = forwardRef<ElementRef<T>, Props>((props, ref) => {
// recipe factory 또는 runtime을 처리합니다
const theme = useTheme();
const runtime = useMemo(() => {
try {
const factoryRuntime = (recipeOrFactory as AnySlotRecipeFactory)(theme);
return typeof factoryRuntime === 'function'
? factoryRuntime
: (recipeOrFactory as AnySlotRecipeRuntime);
} catch {
return recipeOrFactory as AnySlotRecipeRuntime;
Copy link
Contributor

Choose a reason for hiding this comment

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

에러 자체를 pass 해버리기 보다 production 상황일 때라도 에러를 명시해주는 것도 괜찮아보여요!

}
}, [theme]);

// variant props는 전부 runtime으로 넘깁니다
const styles = runtime(props as Variants) as StylesMap;
const rootStyle = props.unstyled ? undefined : styles.root;

return (
<StylesContext.Provider value={styles}>
<Component ref={ref} {...(props as any)} css={[rootStyle, (props as any).css]} />
Copy link
Contributor

Choose a reason for hiding this comment

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

기존에 스타일링 구현 시 styled 선언을 할 때 HTML attributes가 아닌 것을 제외하는 방식을 사용했었는데, 해당 방식을 사용한 Tab의 스토리북을 보니 실제로

Image

가 출력되더라구요! props 를 any로 전달하는 방식은 추후 변경되어야할 거 같습니다!

</StylesContext.Provider>
);
});

Wrapped.displayName = `StyleContextRoot(${getDisplayName(Component)})`;
return Wrapped as unknown as ComponentType<ComponentProps<T> & UnstyledProps & Variants>;
}

/* ---------- Provider (slot + Provider 둘 다) ---------- */

function withProvider<T extends ElementType>(
Component: T,
slot: Slots,
// _options?: JsxFactoryOptions<ComponentProps<T>>,
) {
type BaseProps = ComponentProps<T>;
type Props = BaseProps & Variants & UnstyledProps & JsxStyleProps;

const Wrapped = forwardRef<ElementRef<T>, Props>((props, ref) => {
const theme = useTheme();
const runtime = useMemo(() => {
try {
const maybe = (recipeOrFactory as AnySlotRecipeFactory)(theme);
return typeof maybe === 'function' ? maybe : (recipeOrFactory as AnySlotRecipeRuntime);
} catch {
return recipeOrFactory as AnySlotRecipeRuntime;
}
}, [theme]);

const styles = runtime(props as Variants) as StylesMap;
const slotStyle = props.unstyled ? undefined : styles[slot];

return (
<StylesContext.Provider value={styles}>
<Component ref={ref} {...(props as any)} css={[slotStyle, (props as any).css]} />
</StylesContext.Provider>
);
});

Wrapped.displayName = `StyleContextProvider(${getDisplayName(Component)}:${slot})`;
return Wrapped as unknown as ComponentType<
JsxHTMLProps<ComponentProps<T> & UnstyledProps, Assign<Variants, JsxStyleProps>>
>;
}

/* ---------- Consumer (Context만 사용하는 slot) ---------- */

function withContext<T extends ElementType>(
Component: T,
slot: Slots,
// _options?: JsxFactoryOptions<ComponentProps<T>>,
) {
type BaseProps = ComponentProps<T>;
type Props = BaseProps & UnstyledProps & JsxStyleProps;

const Wrapped = forwardRef<ElementRef<T>, Props>((props, ref) => {
const styles = useContext(StylesContext);

if (!styles) {
if (process.env.NODE_ENV !== 'production') {
console.error(
`StyleContext: "${getDisplayName(
Component,
)}"가 Provider 밖에서 렌더링되었습니다. slot="${slot}".`,
);
}
// Provider 없으면 그냥 원래 css 그대로 내려보냅니다
return <Component ref={ref} {...(props as any)} css={(props as any).css} />;
}

const slotStyle = props.unstyled ? undefined : styles[slot];

return <Component ref={ref} {...(props as any)} css={[slotStyle, (props as any).css]} />;
});

Wrapped.displayName = `StyleContextConsumer(${getDisplayName(Component)}:${slot})`;
return Wrapped as unknown as ComponentType<
JsxHTMLProps<ComponentProps<T> & UnstyledProps, JsxStyleProps>
>;
}

return {
withRootProvider,
withProvider,
withContext,
useSlotStyles,
useSlotStyle,
} as unknown as StyleContext<R>;
}
Loading