Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
2 changes: 2 additions & 0 deletions packages/timo-design-system/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ export { AddTaskButton } from "@components/button/add-task-button/AddTaskButton"
export { TodayBadge } from "@components/badge/today-badge/TodayBadge";
export { TogglePanel } from "@components/toggle-panel/TogglePanel";
export { PlayButton } from "@components/button/play-button/PlayButton";
export { TimeSelector } from "@components/time/time-selector/TimeSelector";
export { RepeatSelector } from "@components/repeat/repeat-selector/RepeatSelector";
export { TabButton } from "@components/button/tab-button/TabButton";
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ const DropdownPanel = ({
<div
{...rest}
className={cn(
"rounded-4 absolute top-full left-0 z-10 mt-1 flex flex-col items-start bg-white p-2",
"rounded-4 absolute top-full left-0 z-10 flex flex-col items-start bg-white p-2",
className,
)}
>
Expand All @@ -129,9 +129,16 @@ const DropdownPanel = ({
);
};

export type DropdownItemProps = ButtonHTMLAttributes<HTMLButtonElement>;
export interface DropdownItemProps extends ButtonHTMLAttributes<HTMLButtonElement> {
closeOnSelect?: boolean;
}

const DropdownItem = ({ className, onClick, ...rest }: DropdownItemProps) => {
const DropdownItem = ({
className,
onClick,
closeOnSelect = true,
...rest
}: DropdownItemProps) => {
const { close } = useDropdownContext();

return (
Expand All @@ -140,7 +147,7 @@ const DropdownItem = ({ className, onClick, ...rest }: DropdownItemProps) => {
{...rest}
onClick={(e) => {
onClick?.(e);
close();
if (closeOnSelect) close();
}}
className={cn(
"rounded-4 flex w-full items-center transition-colors duration-200 ease-in-out",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import {
RepeatSelector,
type RepeatSelectorProps,
} from "@components/repeat/repeat-selector/RepeatSelector";
import { useState } from "react";

import type { Meta, StoryObj } from "@storybook/react";

const TRIGGER = (
<span className="typo-headline-r-14 text-timo-black rounded-4 bg-timo-gray-300 px-3 py-1.5">
반복 설정
</span>
);

const KOREAN_OPTIONS = [
{ frequency: "daily", label: "매일" },
{ frequency: "weekly", label: "매주" },
{ frequency: "monthly", label: "매달" },
] as const;

const WEEKDAYS_KO = [
{ id: "mon", label: "월요일마다" },
{ id: "tue", label: "화요일마다" },
{ id: "wed", label: "수요일마다" },
{ id: "thu", label: "목요일마다" },
{ id: "fri", label: "금요일마다" },
{ id: "sat", label: "토요일마다" },
{ id: "sun", label: "일요일마다" },
];

const meta = {
title: "Components/Repeat/RepeatSelector",
component: RepeatSelector,
parameters: {
layout: "centered",
backgrounds: {
default: "light-gray",
values: [
{ name: "light-gray", value: "#F5F5F5" },
{ name: "dark", value: "#333333" },
{ name: "white", value: "#FFFFFF" },
],
},
},
argTypes: {
frequency: {
control: "select",
options: ["daily", "weekly", "monthly"],
},
},
args: {
trigger: TRIGGER,
detailHeading: "세부 설정",
options: [...KOREAN_OPTIONS],
weekly: {
weekdays: WEEKDAYS_KO,
selectedWeekdayIds: [],
},
monthly: {
repeatDayLabel: "일 마다",
repeatDay: "3",
},
},
} satisfies Meta<typeof RepeatSelector>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Daily: Story = {
args: {
frequency: "daily",
},
};

const WeeklyDemo = (args: RepeatSelectorProps) => {
const [selectedWeekdayIds, setSelectedWeekdayIds] = useState<string[]>([]);

return (
<RepeatSelector
{...args}
frequency="weekly"
weekly={{
...args.weekly,
weekdays: WEEKDAYS_KO,
selectedWeekdayIds,
onWeekdayToggle: (id) =>
setSelectedWeekdayIds((prev) =>
prev.includes(id)
? prev.filter((item) => item !== id)
: [...prev, id],
),
}}
/>
);
};

export const Weekly: Story = {
args: {
frequency: "weekly",
},
render: (args) => <WeeklyDemo {...args} />,
};

const MonthlyDemo = (args: RepeatSelectorProps) => {
const [repeatDay, setRepeatDay] = useState("3");

return (
<RepeatSelector
{...args}
frequency="monthly"
monthly={{
...args.monthly,
repeatDayLabel: "일 마다",
repeatDay,
onRepeatDayChange: setRepeatDay,
}}
/>
);
};

export const Monthly: Story = {
args: {
frequency: "monthly",
},
render: (args) => <MonthlyDemo {...args} />,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
"use client";

import { Checkbox } from "@components/checkbox/Checkbox";
import { Dropdown } from "@components/layout/dropdown/Dropdown";
import { ChevronDownIcon } from "@icons";
import { cn } from "@lib";
import { Fragment, useState } from "react";

import type { ReactNode } from "react";

export type RepeatFrequency = "daily" | "weekly" | "monthly";
Comment thread
kimminna marked this conversation as resolved.
Resolved

export interface RepeatOption {
frequency: RepeatFrequency;
label: string;
}

export interface WeekdayOption {
id: string;
label: string;
}

export interface RepeatWeeklyDetail {
weekdays: WeekdayOption[];
selectedWeekdayIds: string[];
onWeekdayToggle?: (id: string) => void;
}

export interface RepeatMonthlyDetail {
repeatDayLabel: string;
repeatDay: string;
onRepeatDayChange?: (value: string) => void;
}

const DETAIL_ALIGN: Record<RepeatFrequency, string> = {
daily: "items-start",
weekly: "items-start",
monthly: "items-end",
};

export interface RepeatSelectorProps {
trigger: ReactNode;
detailHeading: string;
options: RepeatOption[];
frequency: RepeatFrequency;
onFrequencyChange?: (frequency: RepeatFrequency) => void;
weekly: RepeatWeeklyDetail;
monthly: RepeatMonthlyDetail;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

interface RepeatFrequencyListProps {
options: RepeatOption[];
selectedFrequency: RepeatFrequency;
onSelect: (frequency: RepeatFrequency) => void;
}

const RepeatFrequencyList = ({
options,
selectedFrequency,
onSelect,
}: RepeatFrequencyListProps) => {
return (
<div className="border-timo-gray-500 flex w-full flex-col items-start gap-2 border-t px-3.5 py-2.5">
{options.map(({ frequency: value, label }, index) => (
<Fragment key={value}>
{index > 0 && (
<div className="border-timo-gray-500 h-px w-full border-t" />
)}
<Dropdown.Item
onClick={() => onSelect(value)}
closeOnSelect={false}
aria-pressed={value === selectedFrequency}
>
<span className="typo-headline-r-14 text-timo-black whitespace-nowrap">
{label}
</span>
</Dropdown.Item>
</Fragment>
))}
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
);
};

const RepeatWeeklyDetailSection = ({
weekdays,
selectedWeekdayIds,
onWeekdayToggle,
}: RepeatWeeklyDetail) => {
return (
<div className="flex w-full flex-col items-start gap-2.5">
{weekdays.map(({ id, label }) => (
<div key={id} className="flex w-full items-center justify-between">
<span className="typo-headline-r-14 text-timo-black whitespace-nowrap">
{label}
</span>
<Checkbox
checked={selectedWeekdayIds.includes(id)}
onChange={() => onWeekdayToggle?.(id)}
/>
</div>
))}
</div>
);
};

type RepeatMonthlyDetailSectionProps = RepeatMonthlyDetail & {
ariaLabel: string;
};

const RepeatMonthlyDetailSection = ({
repeatDayLabel,
repeatDay,
onRepeatDayChange,
ariaLabel,
}: RepeatMonthlyDetailSectionProps) => {
Comment on lines +107 to +116

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Props 타입은 interface로 선언해주세요!

RepeatMonthlyDetailSectionProps가 type + 교차 타입으로 선언되어 있어요. 컨벤션상 Props 타입은 interface를 사용해야 해요.

✏️ 제안
-type RepeatMonthlyDetailSectionProps = RepeatMonthlyDetail & {
-  ariaLabel: string;
-};
+interface RepeatMonthlyDetailSectionProps extends RepeatMonthlyDetail {
+  ariaLabel: string;
+}

As per path instructions, Props 타입은 interface로 선언하고 접미사 Props 사용.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
type RepeatMonthlyDetailSectionProps = RepeatMonthlyDetail & {
ariaLabel: string;
};
const RepeatMonthlyDetailSection = ({
repeatDayLabel,
repeatDay,
onRepeatDayChange,
ariaLabel,
}: RepeatMonthlyDetailSectionProps) => {
interface RepeatMonthlyDetailSectionProps extends RepeatMonthlyDetail {
ariaLabel: string;
}
const RepeatMonthlyDetailSection = ({
repeatDayLabel,
repeatDay,
onRepeatDayChange,
ariaLabel,
}: RepeatMonthlyDetailSectionProps) => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/timo-design-system/src/components/repeat/repeat-selector/RepeatSelector.tsx`
around lines 105 - 114, `RepeatMonthlyDetailSectionProps` is currently declared
as a `type` with an intersection, but the Props convention here requires an
`interface` with the `Props` suffix. Update the
`RepeatMonthlyDetailSectionProps` definition in `RepeatMonthlyDetailSection` to
use an `interface` that extends `RepeatMonthlyDetail`, keeping the `ariaLabel`
field, and leave the component signature unchanged.

Source: Path instructions

return (
<div className="flex items-center gap-0.5">
<div className="bg-timo-gray-300 rounded-4 flex h-6.25 w-14.75 items-center justify-end px-1">
<input
type="text"
value={repeatDay}
onChange={(event) => onRepeatDayChange?.(event.target.value)}
aria-label={ariaLabel}
className="typo-headline-m-14 text-timo-black w-full bg-transparent text-right outline-none"
/>
Comment thread
kimminna marked this conversation as resolved.
Resolved
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
<span className="typo-headline-r-14 text-timo-black whitespace-nowrap">
{repeatDayLabel}
</span>
</div>
);
};

export const RepeatSelector = ({
trigger,
detailHeading,
options,
frequency,
onFrequencyChange,
weekly,
monthly,
}: RepeatSelectorProps) => {
const [isPicking, setIsPicking] = useState(true);
const [selectedFrequency, setSelectedFrequency] =
useState<RepeatFrequency>(frequency);

const selectedLabel = options.find(
(option) => option.frequency === selectedFrequency,
)?.label;

const handleSelectFrequency = (value: RepeatFrequency) => {
setSelectedFrequency(value);
onFrequencyChange?.(value);
setIsPicking(false);
};

return (
<Dropdown>
<Dropdown.Trigger aria-haspopup="menu">{trigger}</Dropdown.Trigger>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

<Dropdown.Panel className="w-32.5 gap-1 p-0">
<div className="flex w-full flex-col gap-1 px-3.5 py-2">
<span className="typo-body-r-12 text-timo-gray-700 w-full whitespace-nowrap">
반복 일정
</span>

<button
type="button"
onClick={() => setIsPicking((prev) => !prev)}
aria-expanded={isPicking}
className="flex w-full items-center justify-between"
>
<span className="typo-headline-m-14 text-timo-black whitespace-nowrap">
{selectedLabel}
</span>
<ChevronDownIcon
className={cn(
"shrink-0 transition-transform duration-200 ease-in-out",
isPicking && "rotate-180",
)}
/>
</button>
</div>

{isPicking ? (
<RepeatFrequencyList
options={options}
selectedFrequency={selectedFrequency}
onSelect={handleSelectFrequency}
/>
) : (
<div
className={cn(
"border-timo-gray-500 flex w-full flex-col gap-2 border-t px-3.5 py-1.5",
DETAIL_ALIGN[selectedFrequency],
)}
>
<span className="typo-body-r-12 text-timo-gray-700 w-full whitespace-nowrap">
{detailHeading}
</span>

{selectedFrequency === "weekly" && (
<RepeatWeeklyDetailSection {...weekly} />
)}
{selectedFrequency === "monthly" && (
<RepeatMonthlyDetailSection
{...monthly}
ariaLabel={detailHeading}
/>
)}
</div>
)}
</Dropdown.Panel>
</Dropdown>
);
};
Loading
Loading