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
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 @@ -17,3 +17,5 @@ export { TabButton } from "./button/tab-button/TabButton";
export { SidebarButton } from "./button/sidebar-button/SidebarButton";
export { TodayButton } from "./button/today-button/TodayButton";
export { WeeklyButton } from "./button/weekly-button/WeeklyButton";
export { TimeSelector } from "./time/time-selector/TimeSelector";
export { RepeatSelector } from "./repeat/repeat-selector/RepeatSelector";
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,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 @@ -130,9 +130,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 @@ -141,7 +148,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,124 @@
import { useState } from "react";

import { RepeatSelector, RepeatSelectorProps } from "./RepeatSelector";

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,222 @@
"use client";

import { Fragment, useId, useState } from "react";

import { ChevronDownIcon } from "../../../icons";
import { cn } from "../../../lib";
import { Checkbox } from "../../checkbox/Checkbox";
import { Dropdown } from "../../layout/dropdown/Dropdown";

import type { ReactNode } from "react";

export type RepeatFrequency = "daily" | "weekly" | "monthly";
Comment thread
kimminna marked this conversation as 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.

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.
);
};

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로 선언해주세요!

RepeatMonthlyDetailSectionPropstype + 교차 타입으로 선언되어 있어요. 컨벤션상 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

const inputId = useId();

return (
<div className="flex items-center gap-0.5">
<label htmlFor={inputId} className="sr-only">
{ariaLabel}
</label>
<div className="bg-timo-gray-300 rounded-4 flex h-6.25 w-14.75 items-center justify-end px-1">
<input
id={inputId}
type="text"
value={repeatDay}
onChange={(event) => onRepeatDayChange?.(event.target.value)}
className="typo-headline-m-14 text-timo-black focus-visible:ring-timo-blue-300 w-full bg-transparent text-right outline-none focus-visible:ring-2"
/>
Comment thread
kimminna marked this conversation as resolved.
</div>
Comment thread
coderabbitai[bot] marked this conversation as 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.

<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