diff --git a/src/components/atoms/SegmentedControl/index.module.scss b/src/components/atoms/SegmentedControl/index.module.scss new file mode 100644 index 0000000..8668d96 --- /dev/null +++ b/src/components/atoms/SegmentedControl/index.module.scss @@ -0,0 +1,56 @@ +@use "src/styles/libs" as *; + +.toggle { + display: inline-flex; + gap: 2px; + align-items: center; + padding: 3px; + background-color: $BLUISH-GRAY-50; + border-radius: 8px; +} + +.toggle__item { + display: flex; + align-items: center; + justify-content: center; + font-weight: 500; + color: $BLUISH-GRAY-500; + white-space: nowrap; + cursor: pointer; + background-color: transparent; + border: none; + border-radius: 6px; + transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out; + + .toggle--small & { + padding: 4px 10px; + font-size: $SMALL2X; + } + + .toggle--medium & { + padding: 6px 14px; + font-size: $SMALL; + } + + .toggle--large & { + padding: 8px 18px; + font-size: $NORMAL; + } + + &:hover:not(&--disabled):not(&--selected) { + color: $BLUISH-GRAY-700; + background-color: $BLUISH-GRAY-100; + } + + &--selected { + color: $BLUISH-GRAY-800; + cursor: default; + background-color: $WHITE; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + } + + &--disabled { + color: $BLUISH-GRAY-300; + cursor: not-allowed; + } +} diff --git a/src/components/atoms/SegmentedControl/index.stories.tsx b/src/components/atoms/SegmentedControl/index.stories.tsx new file mode 100644 index 0000000..e850b8c --- /dev/null +++ b/src/components/atoms/SegmentedControl/index.stories.tsx @@ -0,0 +1,97 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import type { Meta, StoryObj } from "@storybook/react"; +import { SegmentedControl, SegmentedControlProps } from "."; +import { useState } from "react"; + +type Args = SegmentedControlProps & { disabledValues?: string[] }; + +const meta: Meta = { + title: "atoms/SegmentedControl", + component: SegmentedControl, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + size: { + control: { type: "radio" }, + options: ["small", "medium", "large"], + }, + disabled: { + control: { type: "boolean" }, + }, + }, + args: { + size: "medium", + disabled: false, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Period: Story = { + argTypes: { + disabledValues: { + name: "disabled options", + control: { type: "check" }, + options: ["daily", "weekly", "monthly"], + }, + }, + args: { disabledValues: [] }, + render: ({ disabledValues = [], ...args }) => { + const [period, setPeriod] = useState("daily"); + const options = [ + { label: "일간", value: "daily" }, + { label: "주간", value: "weekly" }, + { label: "월간", value: "monthly" }, + ].map((opt) => ({ ...opt, disabled: disabledValues.includes(opt.value) })); + return ( + + ); + }, +}; + +export const ViewMode: Story = { + argTypes: { + disabledValues: { + name: "disabled options", + control: { type: "check" }, + options: ["list", "grid"], + }, + }, + args: { disabledValues: [] }, + render: ({ disabledValues = [], ...args }) => { + const [viewMode, setViewMode] = useState("list"); + const options = [ + { label: "리스트", value: "list" }, + { label: "그리드", value: "grid" }, + ].map((opt) => ({ ...opt, disabled: disabledValues.includes(opt.value) })); + return ( + + ); + }, +}; + +export const Filter: Story = { + argTypes: { + disabledValues: { + name: "disabled options", + control: { type: "check" }, + options: ["all", "active", "done"], + }, + }, + args: { disabledValues: [] }, + render: ({ disabledValues = [], ...args }) => { + const [filter, setFilter] = useState("all"); + const options = [ + { label: "전체", value: "all" }, + { label: "진행중", value: "active" }, + { label: "완료", value: "done" }, + ].map((opt) => ({ ...opt, disabled: disabledValues.includes(opt.value) })); + return ( + + ); + }, +}; diff --git a/src/components/atoms/SegmentedControl/index.tsx b/src/components/atoms/SegmentedControl/index.tsx new file mode 100644 index 0000000..46ef7d9 --- /dev/null +++ b/src/components/atoms/SegmentedControl/index.tsx @@ -0,0 +1,102 @@ +import { useRef } from "react"; +import { cleanClassName } from "@/utils"; +import styles from "./index.module.scss"; + +export interface SegmentedControlOption { + label: string; + value: T; + disabled?: boolean; +} + +export interface SegmentedControlProps { + options: SegmentedControlOption[]; + value?: T; + onChange?: (value: T) => void; + size?: "small" | "medium" | "large"; + disabled?: boolean; + className?: string; + ariaLabel?: string; +} + +export const SegmentedControl = ({ + options, + value, + onChange, + size = "medium", + disabled = false, + className, + ariaLabel, +}: SegmentedControlProps) => { + const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]); + + const enabledOptions = options.filter((opt) => !opt.disabled && !disabled); + + const handleKeyDown = ( + e: React.KeyboardEvent, + currentValue: T, + ) => { + const currentIndex = enabledOptions.findIndex( + (opt) => opt.value === currentValue, + ); + if (currentIndex === -1) return; + + let nextIndex: number | null = null; + + if (e.key === "ArrowRight" || e.key === "ArrowDown") { + e.preventDefault(); + nextIndex = (currentIndex + 1) % enabledOptions.length; + } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + e.preventDefault(); + nextIndex = (currentIndex - 1 + enabledOptions.length) % enabledOptions.length; + } else if (e.key === "Home") { + e.preventDefault(); + nextIndex = 0; + } else if (e.key === "End") { + e.preventDefault(); + nextIndex = enabledOptions.length - 1; + } + + if (nextIndex !== null) { + const nextOption = enabledOptions[nextIndex]; + onChange?.(nextOption.value); + const refIndex = options.findIndex((opt) => opt.value === nextOption.value); + buttonRefs.current[refIndex]?.focus(); + } + }; + + return ( +
+ {options.map((option, index) => { + const isSelected = option.value === value; + const isDisabled = disabled || option.disabled; + + return ( + + ); + })} +
+ ); +}; diff --git a/src/components/atoms/index.tsx b/src/components/atoms/index.tsx index 4ac8687..0239c8f 100644 --- a/src/components/atoms/index.tsx +++ b/src/components/atoms/index.tsx @@ -8,5 +8,6 @@ export * from "./Backdrop"; export * from "./Dropdown"; export * from "./Input"; export * from "./Switch"; +export * from "./SegmentedControl"; export * from "./Calendar"; export * from "./NewTable";