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
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions packages/jds/.storybook/utils/layout.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createVar, style } from "@vanilla-extract/css";

export const gapVar = createVar();

export const flexRow = style({
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: gapVar,
});

export const flexColumn = style({
display: "flex",
flexDirection: "column",
gap: gapVar,
});

export const label = style({
width: "100px",
});
53 changes: 34 additions & 19 deletions packages/jds/.storybook/utils/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,38 @@
import styled from "@emotion/styled";
import { assignInlineVars } from "@vanilla-extract/dynamic";
import type { ComponentPropsWithoutRef } from "react";

/**
* Storybook 전용 레이아웃 컴포넌트
* Stories 파일에서 반복되는 레이아웃 스타일을 재사용하기 위한 유틸리티
*/
import { flexColumn, flexRow, gapVar, label } from "./layout.css";

export const FlexRow = styled.div<{ gap?: string }>(({ gap = "16px" }) => ({
display: "flex",
flexDirection: "row",
gap,
alignItems: "center",
}));
type WithGap = { gap?: string };

export const FlexColumn = styled.div<{ gap?: string }>(({ gap = "24px" }) => ({
display: "flex",
flexDirection: "column",
gap,
}));
export function FlexRow({
gap = "16px",
style,
...props
}: ComponentPropsWithoutRef<"div"> & WithGap) {
return (
<div
className={flexRow}
style={{ ...assignInlineVars({ [gapVar]: gap }), ...style }}
{...props}
/>
);
}

export const Label = styled.span({
width: "100px",
});
export function FlexColumn({
gap = "24px",
style,
...props
}: ComponentPropsWithoutRef<"div"> & WithGap) {
return (
<div
className={flexColumn}
style={{ ...assignInlineVars({ [gapVar]: gap }), ...style }}
{...props}
/>
);
}

export function Label({ ...props }: ComponentPropsWithoutRef<"span">) {
return <span className={label} {...props} />;
}
1 change: 1 addition & 0 deletions packages/jds/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vanilla-extract/css": "^1.20.1",
"@vanilla-extract/dynamic": "^2.1.5",
"@vanilla-extract/esbuild-plugin": "^2.3.22",
"@vanilla-extract/recipes": "^0.5.7",
"@vanilla-extract/vite-plugin": "^5.2.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FlexRow, FlexColumn, Label } from "@storybook-utils/layout";
import { BlockButton } from "components";

import { BlockButton } from "./BlockButton";
import { BLOCK_BUTTON_HIERARCHY_OPTIONS, BLOCK_BUTTON_STYLE_OPTIONS } from "./blockButton.types";

const meta = {
title: "Components/BlockButton",
Expand All @@ -18,7 +20,7 @@ const meta = {
},
hierarchy: {
control: "select",
options: ["accent", "primary", "secondary", "tertiary"],
options: BLOCK_BUTTON_HIERARCHY_OPTIONS,
description: "버튼의 시각적 위계",
table: {
defaultValue: { summary: "primary" },
Expand Down Expand Up @@ -149,11 +151,11 @@ export const InteractionStates: Story = {
docs: {
description: {
story:
"InteractionLayer 기반 인터랙션 시스템:\n\n" +
"VE overlay 유틸 기반 인터랙션 시스템 (data attribute 방식):\n\n" +
"- **rest**: 기본 상태 (opacity: 0)\n" +
"- **hover**: 마우스 오버 시 (opacity: 0.08, fluent motion 100ms)\n" +
"- **active**: 클릭 중 (opacity: 0.12, transition 없음)\n" +
"- **focus**: 키보드 포커스 시 (focus outline 표시, transition 없음)",
"- **data-hovered**: 마우스 오버 시 (opacity: 0.08, fluent motion 100ms)\n" +
"- **data-pressed**: 클릭 중 (opacity: 0.12, transition 없음)\n" +
"- **data-focus-visible**: 키보드 포커스 시 (focus ring 표시)",
},
},
},
Expand All @@ -165,13 +167,11 @@ export const ComprehensiveMatrix: Story = {
},
render: () => (
<FlexColumn gap='32px'>
{(["solid", "outlined", "empty"] as const).map(variant => (
{BLOCK_BUTTON_STYLE_OPTIONS.map(variant => (
<FlexColumn key={variant} gap='12px'>
<h3 style={{ margin: 0, fontSize: "14px", fontWeight: "bold" }}>
{variant.charAt(0).toUpperCase() + variant.slice(1)}
</h3>
<Label>{variant.charAt(0).toUpperCase() + variant.slice(1)}</Label>
<FlexRow gap='12px'>
{(["accent", "primary", "secondary", "tertiary"] as const).map(hierarchy => (
{BLOCK_BUTTON_HIERARCHY_OPTIONS.map(hierarchy => (
<BlockButton.Basic key={hierarchy} variant={variant} hierarchy={hierarchy}>
{hierarchy}
</BlockButton.Basic>
Expand Down Expand Up @@ -222,36 +222,18 @@ export const FeedbackButtons: Story = {
},
render: () => (
<FlexColumn>
<Label>Positive:</Label>
<FlexRow gap='12px'>
<BlockButton.Feedback intent='positive' size='xs'>
저장
</BlockButton.Feedback>
<BlockButton.Feedback intent='positive' size='sm'>
저장
</BlockButton.Feedback>
<BlockButton.Feedback intent='positive' size='md'>
저장
</BlockButton.Feedback>
<BlockButton.Feedback intent='positive' size='lg'>
저장
</BlockButton.Feedback>
</FlexRow>
<Label>Destructive:</Label>
<FlexRow gap='12px'>
<BlockButton.Feedback intent='destructive' size='xs'>
삭제
</BlockButton.Feedback>
<BlockButton.Feedback intent='destructive' size='sm'>
삭제
</BlockButton.Feedback>
<BlockButton.Feedback intent='destructive' size='md'>
삭제
</BlockButton.Feedback>
<BlockButton.Feedback intent='destructive' size='lg'>
삭제
</BlockButton.Feedback>
</FlexRow>
{(["positive", "destructive"] as const).map(intent => (
<FlexColumn key={intent} gap='12px'>
<Label>{intent.charAt(0).toUpperCase() + intent.slice(1)}:</Label>
<FlexRow gap='12px'>
{(["xs", "sm", "md", "lg"] as const).map(size => (
<BlockButton.Feedback key={size} intent={intent} size={size}>
{intent === "positive" ? "저장" : "삭제"}
</BlockButton.Feedback>
))}
</FlexRow>
</FlexColumn>
))}
</FlexColumn>
),
parameters: {
Expand Down
39 changes: 20 additions & 19 deletions packages/jds/src/components/Button/BlockButton/BlockButton.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { mergeProps } from "@react-aria/utils";
import { clsx } from "clsx";
import type { BlockButtonBasicProps, BlockButtonFeedbackProps } from "components";
import { Icon } from "components";
import { usePressable } from "hooks";
import { forwardRef } from "react";

import { iconSizeMap, StyledBlockButton } from "./blockButton.styles";
import { basicRoot, feedbackRoot, iconSizeMap } from "./blockButton.css";

const BlockButtonBasic = forwardRef<HTMLButtonElement, BlockButtonBasicProps>(
(
Expand All @@ -14,27 +17,25 @@ const BlockButtonBasic = forwardRef<HTMLButtonElement, BlockButtonBasicProps>(
prefixIcon,
suffixIcon,
disabled = false,
className,
...restProps
},
ref,
forwardedRef,
) => {
//Todo: 아이콘 사이즈도 전부 스타일의 theme 단위에서 해결하면 좋을듯(Theme 구조 추가 필요)
const { ref, pressableProps } = usePressable(forwardedRef, { disabled });
const iconSize = iconSizeMap[size];

return (
<StyledBlockButton
<button
ref={ref}
$hierarchy={hierarchy}
$variant={variant}
$size={size}
$disabled={disabled}
disabled={disabled}
{...restProps}
{...mergeProps(pressableProps, restProps)}
data-part='root'
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.

(참고) 커스텀 가능성이 크지 않은 prop이긴 하지만, 타입 상으로 만일 사용자가 data-part={custom} 속성을 component에 주입했을 때 root가 항상 덮어쓴다는 문제점이 있어 보여요.

최상단 요소임을 명시하는 값이니 외부에서 주입하지 못하도록 아래처럼 data-part를 타입에서 막아 버리는 것도 방법이 될 수 있어 보이네요.

interface BlockButtonProps extends Omit<ComponentPropsWithoutRef<"button">, "data-part"> {
  ...
}

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.

해당 컴포넌트들이 항상 루트 요소로 사용된다는 것이 불변 사항이라면 타입 레벨에서 외부 주입을 제한하는 것도 괜찮은 방향인 것 같네요!

다만 리액트에서 data-* 속성을 Omit으로 완전히 제거하기 어려워 never 타입으로 제한했습니다 (b8579d3)

className={clsx(basicRoot({ hierarchy, variant, size }), className)}
>
{prefixIcon && <Icon name={prefixIcon} size={iconSize} />}
{children}
{suffixIcon && <Icon name={suffixIcon} size={iconSize} />}
Comment on lines 35 to 37
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.

(선택) 해당 컴포넌트 자체에서는 문제가 없는데, data-part="icon" 으로 선언 후에 별도로 스타일 선언을 해주는 방식도 있을 거 같아요.

다만 이 컴포넌트(+LabelButton 포함) 의 경우 디자인 에셋에서 icon과 label의 색상이 동일하게 배합되어있어 실질적으로 문제는 없습니다!

</StyledBlockButton>
</button>
);
},
);
Expand All @@ -50,25 +51,25 @@ const BlockButtonFeedback = forwardRef<HTMLButtonElement, BlockButtonFeedbackPro
prefixIcon,
suffixIcon,
disabled = false,
className,
...restProps
},
ref,
forwardedRef,
) => {
const { ref, pressableProps } = usePressable(forwardedRef, { disabled });
const iconSize = iconSizeMap[size];

return (
<StyledBlockButton
<button
ref={ref}
$intent={intent}
$size={size}
$disabled={disabled}
disabled={disabled}
{...restProps}
{...mergeProps(pressableProps, restProps)}
data-part='root'
className={clsx(feedbackRoot({ intent, size }), className)}
>
{prefixIcon && <Icon name={prefixIcon} size={iconSize} />}
{children}
{suffixIcon && <Icon name={suffixIcon} size={iconSize} />}
</StyledBlockButton>
</button>
);
},
);
Expand Down
Loading
Loading