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
13 changes: 13 additions & 0 deletions src/app/(marketing)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useState } from "react";
export default function Home() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<InputStatus>("default");
const [isReady, setIsReady] = useState(false);

return (
<div className="flex flex-col justify-center items-center gap-8">
Expand Down Expand Up @@ -103,6 +104,18 @@ export default function Home() {

<SignupGroupButton />

<Button
preset="cta"
disabled={!isReady} // false๋ฉด ํ™œ์„ฑ, true๋ฉด ๋น„ํ™œ์„ฑ
onClick={() => alert("๋‹ค์Œ ๋‹จ๊ณ„๋กœ ์ด๋™")}
>
์„ ํƒ ์™„๋ฃŒ
</Button>

<Button preset="cta" disabled={false} onClick={() => setIsReady((prev) => !prev)}>
{isReady ? "์„ ํƒ ํ•ด์ œ" : "ํ™œ์„ฑํ™” ํ† ๊ธ€"}
</Button>

<SelectModuleCard
kind="module" // "module" | "design"
title="์ผ๊ฐ„ ํ”Œ๋ž˜๋„ˆ"
Expand Down
37 changes: 37 additions & 0 deletions src/lib/variants/button.cta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { cva } from "class-variance-authority";

export const ctaButtonVariants = cva(
// ๊ณตํ†ต ๋ฒ ์ด์Šค
[
"inline-flex items-center justify-center select-none",
"rounded-full px-14 py-4 t-16-m",
"transition-[background-color,color,box-shadow] duration-200 ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]",
"will-change-[background-color,color,box-shadow]",
].join(" "),
{
variants: {
/** ๋…ผ๋ฆฌ ์ƒํƒœ: disabled(์ดˆ๊ธฐ) | active(ํ™œ์„ฑ) */
state: {
/** ์ดˆ๊ธฐ ๋น„ํ™œ์„ฑ โ€” ํšŒ์ƒ‰ ๋ฐฐ๊ฒฝ, ์—ฐํ•œ ํ…์ŠคํŠธ */
disabled: [
"bg-[var(--color-gray-400)]",
"text-[var(--color-gray-600)]",
"cursor-not-allowed",
"shadow-none",
].join(" "),
/** ํ™œ์„ฑ โ€” ํฐ ๋ฐฐ๊ฒฝ/์ง™์€ ํ…์ŠคํŠธ + hover ์‹œ ๋‹คํฌ/ํ™”์ดํŠธ ๋ฐ˜์ „, hover ๊ทธ๋ฆผ์ž */
active: [
"bg-white text-[var(--color-gray-900)]",
"border border-[var(--color-gray-200)]",
"shadow-[var(--shadow-soft)]",
"hover:bg-[var(--color-gray-900)] hover:text-white",
"hover:shadow-[var(--shadow-hover)]",
].join(" "),
},
},
defaultVariants: {
state: "disabled",
},
},
);
14 changes: 11 additions & 3 deletions src/lib/variants/button.presets.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ButtonPreset } from "@/types/button";
import type { VariantProps } from "class-variance-authority";
import { loginButtonVariants } from "./button.auth";
import { ctaButtonVariants } from "./button.cta";
import { featureButtonVariants } from "./button.feature";
import { heroButtonVariants } from "./button.hero";
import { signupButtonVariants } from "./button.signup";
Expand All @@ -10,26 +11,29 @@ type HeroVariantProps = VariantProps<typeof heroButtonVariants>;
type FeatureVariantProps = VariantProps<typeof featureButtonVariants>;
type AuthVariantProps = VariantProps<typeof loginButtonVariants>;
type SignupVariantProps = VariantProps<typeof signupButtonVariants>;
type CtaVariantProps = VariantProps<typeof ctaButtonVariants>;

type HeroOpts = Partial<Pick<HeroVariantProps, "intent" | "glow" | "pill">>;
type FeatureOpts = Partial<Pick<FeatureVariantProps, "radius">>;
type AuthOpts = Partial<Pick<AuthVariantProps, "color">>;
type SignupOpts = Partial<Pick<SignupVariantProps, "bg">>;
type CtaOpts = Partial<Pick<CtaVariantProps, "state">>;

// โœ… ํ”„๋ฆฌ์…‹๋ณ„ ์˜ค๋ฒ„๋กœ๋“œ (์ž๋™์™„์„ฑ/ํƒ€์ž…๊ฐ€๋“œ ์ •ํ™•)
// โœ… ํ”„๋ฆฌ์…‹๋ณ„ ์˜ค๋ฒ„๋กœ๋“œ
export function getButtonClasses(preset: "hero", opts?: HeroOpts): string;
export function getButtonClasses(preset: "feature", opts?: FeatureOpts): string;
export function getButtonClasses(preset: "auth", opts?: AuthOpts): string;
export function getButtonClasses(preset: "signup", opts?: SignupOpts): string;
export function getButtonClasses(preset: "cta", opts?: CtaOpts): string;

// ๊ตฌํ˜„์ฒด
export function getButtonClasses(
preset: ButtonPreset,
opts: HeroOpts | FeatureOpts | AuthOpts | SignupOpts = {},
opts: HeroOpts | FeatureOpts | AuthOpts | SignupOpts | CtaOpts = {},
): string {
switch (preset) {
case "hero": {
const { intent, glow, pill } = opts as HeroOpts;
// ํ•„์š”ํ•œ ํ‚ค๋งŒ ์ „๋‹ฌ (๋ถˆํ•„์š” ํ‚ค ์œ ์ž… ์ฐจ๋‹จ)
return heroButtonVariants({ intent, glow, pill });
}
case "feature": {
Expand All @@ -44,5 +48,9 @@ export function getButtonClasses(
const { bg } = opts as SignupOpts;
return signupButtonVariants({ bg });
}
case "cta": {
const { state } = opts as CtaOpts;
return ctaButtonVariants({ state });
}
}
}
24 changes: 23 additions & 1 deletion src/shared/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as React from "react";

type NativeBtn = React.ButtonHTMLAttributes<HTMLButtonElement>;

/** ์ผ๋ถ€ ํ‚ค๋ฅผ ์ œ์™ธํ•œ props ๋ฐ˜ํ™˜ (ํƒ€์ž… ์•ˆ์ „) */
function omitKeys<T extends object, K extends readonly (keyof T)[]>(
obj: T,
keys: K,
Expand All @@ -24,11 +25,13 @@ function omitKeys<T extends object, K extends readonly (keyof T)[]>(
return out as Omit<T, K[number]>;
}

/** preset๋ณ„ ์ฒ˜๋ฆฌ ๊ณ„ํš ์ˆ˜๋ฆฝ */
function computePlan(props: ButtonProps) {
const asChild = props.asChild ?? false;
const className = props.className;
const children = (props as React.PropsWithChildren).children;

// 1๏ธโƒฃ Hero
if (props.preset === "hero") {
const intent = props.intent ?? "primary";
const glow = props.glow ?? false;
Expand All @@ -47,6 +50,7 @@ function computePlan(props: ButtonProps) {
return { asChild, className, children, classes, native };
}

// 2๏ธโƒฃ Feature
if (props.preset === "feature") {
const radius = props.radius ?? "2xl";

Expand All @@ -61,6 +65,7 @@ function computePlan(props: ButtonProps) {
return { asChild, className, children, classes, native };
}

// 3๏ธโƒฃ Signup
if (props.preset === "signup") {
const bg = props.bg ?? "basic";

Expand All @@ -69,13 +74,30 @@ function computePlan(props: ButtonProps) {
return { asChild, className, children, classes, native };
}

// auth
// 4๏ธโƒฃ CTA
if (props.preset === "cta") {
const state = props.disabled ? "disabled" : "active";
const classes = cn(getButtonClasses("cta", { state }));

const native = omitKeys(props, [
"preset",
"disabled",
"fullWidth",
"className",
"asChild",
"children",
] as const);
return { asChild, className, children, classes, native };
Comment on lines +77 to +90

Choose a reason for hiding this comment

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

P1 Badge Disabled CTA buttons still fire click handlers

In the new CTA branch disabled is used only to select the style and then stripped from the native props via omitKeys. Because the rendered <button> never receives its disabled attribute, a CTA marked as disabled is still focusable and its onClick executes (e.g. the marketing pageโ€™s ์„ ํƒ ์™„๋ฃŒ button shows an alert even when disabled={!isReady}). This breaks accessibility expectations and allows actions to run while the UI appears disabled. The prop should be forwarded to the native button or the handler suppressed when disabled is true.

Useful? React with ๐Ÿ‘ย / ๐Ÿ‘Ž.

}

// 5๏ธโƒฃ Auth (๊ธฐ๋ณธ)
const color = props.color ?? "black";
const classes = getButtonClasses("auth", { color });
const native = omitKeys(props, ["preset", "color", "className", "asChild", "children"] as const);
return { asChild, className, children, classes, native };
}

/** ๊ณต์šฉ Button ์ปดํฌ๋„ŒํŠธ */
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
const plan = computePlan(props);
const Comp = plan.asChild ? Slot : "button";
Expand Down
24 changes: 21 additions & 3 deletions src/types/button.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { ButtonHTMLAttributes } from "react";

export type ButtonPreset = "hero" | "feature" | "auth" | "signup";
export type ButtonPreset = "hero" | "feature" | "auth" | "signup" | "cta";
export type ButtonIntent = "primary";
export type Radius = "sm" | "md" | "lg" | "xl" | "2xl";
export type AuthColor = "black" | "white";
export type SignupBg = "basic" | "google" | "kakao";

/** ๊ณตํ†ต ๊ธฐ๋ณธ ํƒ€์ž… */
export interface BaseButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
className?: string;
Expand Down Expand Up @@ -44,7 +45,7 @@ export type AuthProps = BaseButtonProps & {
radius?: never;
};

/** signup ์ „์šฉ **/
/** signup ์ „์šฉ */
export type SignupProps = BaseButtonProps & {
preset: "signup";
bg?: SignupBg;
Expand All @@ -56,4 +57,21 @@ export type SignupProps = BaseButtonProps & {
color?: never;
};

export type ButtonProps = HeroProps | FeatureProps | AuthProps | SignupProps;
/** cta ์ „์šฉ */
export type CtaProps = BaseButtonProps & {
preset: "cta";
/** ์ „์ฒด ํญ ํ™•์žฅ ์—ฌ๋ถ€ */
fullWidth?: boolean;
/** ๊ธฐ๋ณธ์ ์œผ๋กœ `disabled`๋กœ ๋น„ํ™œ์„ฑํ™” โ†’ active์ผ ๋•Œ hover ๋“ฑ ๋™์ž‘ */
disabled?: boolean;
// ๊ธˆ์ง€
intent?: never;
glow?: never;
pill?: never;
radius?: never;
color?: never;
bg?: never;
};

/** ์ „์ฒด ๋ฒ„ํŠผ ํƒ€์ž… ์œ ๋‹ˆ์˜จ */
export type ButtonProps = HeroProps | FeatureProps | AuthProps | SignupProps | CtaProps;