Skip to content

Commit

Permalink
feat: Segmented Control 컴포넌트를 추가해요 (#475)
Browse files Browse the repository at this point in the history
* feat: wip using `useTabs`

(cherry picked from commit ec99733)

# Conflicts:
#	docs/components/example/index.json
#	docs/public/__registry__/ui/index.json
#	docs/registry/registry-ui.ts
#	packages/recipe-generator/preset/src/index.ts
#	packages/vars/src/component/index.ts

* fix: fix styles

(cherry picked from commit c065bcc)

# Conflicts:
#	docs/public/__registry__/ui/segmented-control.json

* fix: fix styles again

(cherry picked from commit 678d3a7)

* feat: support adjustable width

(cherry picked from commit 5f4d6cd)

* feat: dynamic width, placeholder로 overflow 시 layout shift 방지

(cherry picked from commit 3de3849)

# Conflicts:
#	docs/components/example/index.json

* fix: overflow 문제 수정

(cherry picked from commit cf7a835)

# Conflicts:
#	docs/components/example/index.json

* revert: 불필요한 변경 롤백

(cherry picked from commit 2cbdc0f15a86e84bb211d0234a83999f0aa70ac5)

# Conflicts:
#	docs/components/example/index.json

* fix: remove `labelProps` from label placeholder

* fix: remove label from being able to select

* docs: add stories

* refactor: 새 rootage 스키마에 맞게 수정해요

* fix: 스토리 수정

* fix: 폰트 스케일링 대응
  • Loading branch information
te6-in authored Nov 28, 2024
1 parent 7c793a5 commit 5f3d2d3
Show file tree
Hide file tree
Showing 19 changed files with 637 additions and 0 deletions.
4 changes: 4 additions & 0 deletions docs/components/example/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@
"inline-banner-with-icon": "import { InlineBanner } from \"seed-design/ui/inline-banner\";\nimport { IconILowercaseSerifCircleFill } from \"@daangn/react-monochrome-icon\";\n\nexport default function InlineBannerWithIcon() {\n return (\n <InlineBanner variant=\"informativeWeak\" icon={<IconILowercaseSerifCircleFill />}>\n 다른 사람과 예약된 물품이 있어요.\n </InlineBanner>\n );\n}",
"inline-banner-with-link": "import { InlineBanner } from \"seed-design/ui/inline-banner\";\n\nexport default function InlineBannerWithLink() {\n return (\n <InlineBanner variant=\"informativeWeak\" link={{ label: \"자세히 보기\", onClick: () => {} }}>\n 다른 사람과 예약된 물품이 있어요.\n </InlineBanner>\n );\n}",
"inline-banner-with-title-text": "import { InlineBanner } from \"seed-design/ui/inline-banner\";\n\nexport default function InlineBannerWithTitleText() {\n return (\n <InlineBanner variant=\"informativeWeak\" titleText=\"예약\">\n 다른 사람과 예약된 물품이 있어요.\n </InlineBanner>\n );\n}",
"segmented-control-fixed-width": "import { SegmentedControl, SegmentedControlOption } from \"seed-design/ui/segmented-control\";\n\nexport default function SegmentedControlFixedWidth() {\n return (\n <SegmentedControl style={{ width: \"600px\" }}>\n <SegmentedControlOption value=\"new\">New</SegmentedControlOption>\n <SegmentedControlOption value=\"hot\">Hot</SegmentedControlOption>\n </SegmentedControl>\n );\n}",
"segmented-control-long-label-fixed-width": "import { SegmentedControl, SegmentedControlOption } from \"seed-design/ui/segmented-control\";\n\nexport default function SegmentedControlLongLabelFixedWidth() {\n return (\n <SegmentedControl style={{ width: \"600px\" }}>\n <SegmentedControlOption value=\"price\">가격 높은 순</SegmentedControlOption>\n <SegmentedControlOption value=\"discount\">할인율 높은 순</SegmentedControlOption>\n <SegmentedControlOption value=\"popularity\">인기 많은 순</SegmentedControlOption>\n </SegmentedControl>\n );\n}",
"segmented-control-long-label": "import { SegmentedControl, SegmentedControlOption } from \"seed-design/ui/segmented-control\";\n\nexport default function SegmentedControlLongLabel() {\n return (\n <SegmentedControl>\n <SegmentedControlOption value=\"price\">가격 높은 순</SegmentedControlOption>\n <SegmentedControlOption value=\"discount\">할인율 높은 순</SegmentedControlOption>\n <SegmentedControlOption value=\"popularity\">인기 많은 순</SegmentedControlOption>\n </SegmentedControl>\n );\n}",
"segmented-control-preview": "import { useState } from \"react\";\nimport { SegmentedControl, SegmentedControlOption } from \"seed-design/ui/segmented-control\";\n\nexport default function SegmentedControlPreview() {\n const options = [\"New\", \"Hot\"];\n const [value, setValue] = useState(\"New\");\n\n return (\n <div className=\"flex flex-col gap-3 items-center text-center\">\n <SegmentedControl value={value} defaultValue=\"New\" onValueChange={setValue}>\n {options.map((option) => (\n <SegmentedControlOption key={option} value={option}>\n {option}\n </SegmentedControlOption>\n ))}\n </SegmentedControl>\n <div>Selected value: {value}</div>\n </div>\n );\n}",
"skeleton-wave-activity": "import type { ActivityComponentType } from \"@stackflow/react/future\";\nimport type * as React from \"react\";\n\nimport Layout from \"@/components/stackflow/ActivityLayout\";\nimport { Skeleton } from \"seed-design/ui/skeleton\";\nimport {\n useSkeletonDuration,\n useIsRealLoading,\n useSkeletonLoading,\n useSkeletonTimingFunction,\n useSkeletonInitTransitionDuration,\n useSkeletonGradient,\n} from \"@/stores/skeleton\";\n\ndeclare module \"@stackflow/config\" {\n interface Register {\n SkeletonWave: unknown;\n }\n}\n\nconst Fallback = () => {\n return (\n <div style={{ display: \"flex\", flexDirection: \"column\", gap: \"12px\" }}>\n <Skeleton width=\"100%\" height=\"300px\" borderRadius=\"square\" />\n <div style={{ display: \"flex\", flexDirection: \"column\", gap: \"4px\" }}>\n <Skeleton width=\"50px\" height=\"50px\" borderRadius=\"circle\" />\n <Skeleton width=\"100%\" height=\"20px\" borderRadius=\"rounded\" />\n <Skeleton width=\"200px\" height=\"20px\" borderRadius=\"rounded\" />\n </div>\n </div>\n );\n};\n\nconst SkeletonWaveActivity: ActivityComponentType<\"SkeletonWave\"> = () => {\n const isLoading = useSkeletonLoading();\n const isRealLoading = useIsRealLoading();\n const animationDuration = useSkeletonDuration();\n const animationTiming = useSkeletonTimingFunction();\n const initTransitionDuration = useSkeletonInitTransitionDuration();\n const gradient = useSkeletonGradient();\n\n return (\n <Layout>\n <div\n style={\n {\n padding: \"16px\",\n \"--skeleton-gradient\": gradient,\n \"--skeleton-init-transition-duration\": initTransitionDuration,\n \"--skeleton-animation-duration\": animationDuration,\n \"--skeleton-animation-timing-function\": animationTiming,\n } as React.CSSProperties\n }\n >\n {isLoading ? isRealLoading && <Fallback /> : <div>content</div>}\n </div>\n </Layout>\n );\n};\n\nexport default SkeletonWaveActivity;\n\nSkeletonWaveActivity.displayName = \"SkeletonWaveActivity\";",
"switch-disabled": "import { Switch } from \"seed-design/ui/switch\";\n\nexport default function SwitchDisabled() {\n return (\n <div style={{ display: \"flex\", flexDirection: \"column\", gap: 10 }}>\n <Switch disabled />\n <Switch checked disabled />\n </div>\n );\n}",
"switch-medium": "import { useState } from \"react\";\nimport { Switch } from \"seed-design/ui/switch\";\n\nexport default function SwitchMedium() {\n const [isChecked, setIsChecked] = useState(false);\n\n return (\n <Switch size=\"medium\" checked={isChecked} onCheckedChange={setIsChecked} />\n );\n}",
Expand Down
10 changes: 10 additions & 0 deletions docs/components/example/segmented-control-fixed-width.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { SegmentedControl, SegmentedControlOption } from "seed-design/ui/segmented-control";

export default function SegmentedControlFixedWidth() {
return (
<SegmentedControl style={{ width: "600px" }}>
<SegmentedControlOption value="new">New</SegmentedControlOption>
<SegmentedControlOption value="hot">Hot</SegmentedControlOption>
</SegmentedControl>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { SegmentedControl, SegmentedControlOption } from "seed-design/ui/segmented-control";

export default function SegmentedControlLongLabelFixedWidth() {
return (
<SegmentedControl style={{ width: "600px" }}>
<SegmentedControlOption value="price">가격 높은 순</SegmentedControlOption>
<SegmentedControlOption value="discount">할인율 높은 순</SegmentedControlOption>
<SegmentedControlOption value="popularity">인기 많은 순</SegmentedControlOption>
</SegmentedControl>
);
}
11 changes: 11 additions & 0 deletions docs/components/example/segmented-control-long-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { SegmentedControl, SegmentedControlOption } from "seed-design/ui/segmented-control";

export default function SegmentedControlLongLabel() {
return (
<SegmentedControl>
<SegmentedControlOption value="price">가격 높은 순</SegmentedControlOption>
<SegmentedControlOption value="discount">할인율 높은 순</SegmentedControlOption>
<SegmentedControlOption value="popularity">인기 많은 순</SegmentedControlOption>
</SegmentedControl>
);
}
22 changes: 22 additions & 0 deletions docs/components/example/segmented-control-preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"use client";

import { useState } from "react";
import { SegmentedControl, SegmentedControlOption } from "seed-design/ui/segmented-control";

export default function SegmentedControlPreview() {
const options = ["New", "Hot"];
const [value, setValue] = useState("New");

return (
<div className="flex flex-col gap-3 items-center text-center">
<SegmentedControl value={value} defaultValue="New" onValueChange={setValue}>
{options.map((option) => (
<SegmentedControlOption key={option} value={option}>
{option}
</SegmentedControlOption>
))}
</SegmentedControl>
<div>Selected value: {value}</div>
</div>
);
}
51 changes: 51 additions & 0 deletions docs/content/docs/react/components/segmented-control.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
title: Segmented Control
description: 설명 # TODO
---

<ComponentExample name="segmented-control-preview" />

### 설치

<Installation name="segmented-control" />

## Props

### `SegmentedControl`

<AutoTypeTable
path="./registry/ui/segmented-control.tsx"
name="SegmentedControlProps"
/>

### `SegmentedControlOption`

<AutoTypeTable
path="./registry/ui/segmented-control.tsx"
name="SegmentedControlOptionProps"
/>

## 예제

### 최소 너비보다 넓은 옵션 레이블

Pill 형태의 `SegmentControlOption` 한 개는 86px의 최소 너비를 가져요. 제공한 옵션 중 이 너비를 초과하는 옵션이 있다면, 가장 긴 옵션의 너비에 모든 옵션의 너비가 맞춰져요.

<ComponentExample name="segmented-control-long-label" />

### Fixed Width

`SegmentControl``style` prop에 `width`를 제공해서 직접 너비를 설정할 수 있어요.

<Callout type="info" title="지정한 너비가 무시되는 경우">

직접 설정한 너비 안에서 모든 레이블을 overflow 없이 표시할 수 없는 경우, 직접
지정한 너비 대신 overflow 없이 표시할 수 있는 최소 너비로 자동 조정돼요.

</Callout>

<ComponentExample name="segmented-control-fixed-width" />

### 최소 너비보다 넓은 옵션 레이블 & Fixed Width

<ComponentExample name="segmented-control-long-label-fixed-width" />
9 changes: 9 additions & 0 deletions docs/public/__registry__/ui/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,15 @@
"ui:expand-button.tsx"
]
},
{
"name": "segmented-control",
"dependencies": [
"@seed-design/react-tabs@alpha"
],
"files": [
"ui:segmented-control.tsx"
]
},
{
"name": "switch",
"dependencies": [
Expand Down
13 changes: 13 additions & 0 deletions docs/public/__registry__/ui/segmented-control.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "segmented-control",
"dependencies": [
"@seed-design/react-tabs@alpha"
],
"registries": [
{
"name": "segmented-control.tsx",
"type": "ui",
"content": "\"use client\";\n\nimport \"@seed-design/stylesheet/segmentedControl.css\";\nimport {\n useTabs,\n type TriggerProps,\n type UseTabsProps,\n} from \"@seed-design/react-tabs\";\nimport * as React from \"react\";\nimport clsx from \"clsx\";\nimport {\n segmentedControl,\n type SegmentedControlVariantProps,\n} from \"@seed-design/recipe/segmentedControl\";\nimport type { Assign } from \"../util/types\";\nexport interface SegmentedControlProps extends SegmentedControlVariantProps {}\n\nconst TabsContext = React.createContext<{\n api: ReturnType<typeof useTabs>;\n} | null>(null);\n\nconst useTabsContext = () => {\n const context = React.useContext(TabsContext);\n if (!context)\n throw new Error(\n \"SegmentedControlOption cannot be rendered outside the SegmentedControl\",\n );\n\n return context;\n};\n\nexport interface SegmentedControlProps\n extends SegmentedControlVariantProps,\n Pick<UseTabsProps, \"value\" | \"defaultValue\" | \"onValueChange\"> {}\n\ntype ReactSegmentedControlProps = SegmentedControlProps &\n Assign<React.HTMLAttributes<HTMLDivElement>, UseTabsProps>;\n\nexport const SegmentedControl = React.forwardRef<\n // HTMLFieldSetElement,\n HTMLDivElement,\n ReactSegmentedControlProps\n>(({ className, children, style, ...otherProps }, ref) => {\n const api = useTabs(otherProps);\n const { tabTriggerListProps, triggerSize, tabIndicatorProps } = api;\n\n const { left, width } = triggerSize;\n\n // TODO: value/defaultvalue 없는 경우 첫 번째 아이템으로 default (tabs 참고)\n\n const classNames = segmentedControl();\n\n return (\n <div\n style={{\n ...style,\n // XXX: tabCount 썼을 때 hydration 문제\n gridTemplateColumns: `repeat(${React.Children.count(children)}, 1fr)`,\n }}\n className={clsx(classNames.root, className)}\n ref={ref}\n {...tabTriggerListProps}\n {...otherProps}\n >\n <TabsContext.Provider value={{ api }}>{children}</TabsContext.Provider>\n <div\n aria-hidden\n className={classNames.indicator}\n {...tabIndicatorProps}\n style={{ left, width }}\n />\n </div>\n );\n});\nSegmentedControl.displayName = \"SegmentedControl\";\n\nexport interface SegmentedControlOptionProps\n extends SegmentedControlVariantProps,\n Omit<TriggerProps, \"isDisabled\"> {}\n\ntype ReactSegmentedControlOptionProps = Assign<\n React.HTMLAttributes<HTMLButtonElement>,\n SegmentedControlOptionProps\n>;\n\nexport const SegmentedControlOption = React.forwardRef<\n HTMLButtonElement,\n ReactSegmentedControlOptionProps\n>(({ className, children, value, ...otherProps }, ref) => {\n const {\n api: { getTabTriggerProps },\n } = useTabsContext();\n\n const { rootProps, labelProps } = getTabTriggerProps({ value });\n\n const classNames = segmentedControl();\n\n return (\n <button\n ref={ref}\n className={clsx(classNames.option, className)}\n {...rootProps}\n {...otherProps}\n >\n <div className={classNames.optionLabel} {...labelProps} tabIndex={-1}>\n {children}\n </div>\n <div aria-hidden className={classNames.optionLabelPlaceholder}>\n {children}\n </div>\n </button>\n );\n});\n\nSegmentedControlOption.displayName = \"SegmentedControlOption\";\n"
}
]
}
5 changes: 5 additions & 0 deletions docs/registry/registry-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ export const registryUI: RegistryUI = [
dependencies: ["@radix-ui/react-slot"],
files: ["ui:expand-button.tsx"],
},
{
name: "segmented-control",
dependencies: ["@seed-design/react-tabs@alpha"],
files: ["ui:segmented-control.tsx"],
},
{
name: "switch",
// TODO: remove alpha
Expand Down
115 changes: 115 additions & 0 deletions docs/registry/ui/segmented-control.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"use client";

import "@seed-design/stylesheet/segmentedControl.css";
import {
useTabs,
type TriggerProps,
type UseTabsProps,
} from "@seed-design/react-tabs";
import * as React from "react";
import clsx from "clsx";
import {
segmentedControl,
type SegmentedControlVariantProps,
} from "@seed-design/recipe/segmentedControl";
import type { Assign } from "../util/types";
export interface SegmentedControlProps extends SegmentedControlVariantProps {}

const TabsContext = React.createContext<{
api: ReturnType<typeof useTabs>;
} | null>(null);

const useTabsContext = () => {
const context = React.useContext(TabsContext);
if (!context)
throw new Error(
"SegmentedControlOption cannot be rendered outside the SegmentedControl",
);

return context;
};

export interface SegmentedControlProps
extends SegmentedControlVariantProps,
Pick<UseTabsProps, "value" | "defaultValue" | "onValueChange"> {}

type ReactSegmentedControlProps = SegmentedControlProps &
Assign<React.HTMLAttributes<HTMLDivElement>, UseTabsProps>;

export const SegmentedControl = React.forwardRef<
// HTMLFieldSetElement,
HTMLDivElement,
ReactSegmentedControlProps
>(({ className, children, style, ...otherProps }, ref) => {
const api = useTabs(otherProps);
const { tabTriggerListProps, triggerSize, tabIndicatorProps } = api;

const { left, width } = triggerSize;

// TODO: value/defaultvalue 없는 경우 첫 번째 아이템으로 default (tabs 참고)

const classNames = segmentedControl();

return (
<div
style={{
...style,
// XXX: tabCount 썼을 때 hydration 문제
gridTemplateColumns: `repeat(${React.Children.count(children)}, 1fr)`,
}}
className={clsx(classNames.root, className)}
ref={ref}
{...tabTriggerListProps}
{...otherProps}
>
<TabsContext.Provider value={{ api }}>{children}</TabsContext.Provider>
<div
aria-hidden
className={classNames.indicator}
{...tabIndicatorProps}
style={{ left, width }}
/>
</div>
);
});
SegmentedControl.displayName = "SegmentedControl";

export interface SegmentedControlOptionProps
extends SegmentedControlVariantProps,
Omit<TriggerProps, "isDisabled"> {}

type ReactSegmentedControlOptionProps = Assign<
React.HTMLAttributes<HTMLButtonElement>,
SegmentedControlOptionProps
>;

export const SegmentedControlOption = React.forwardRef<
HTMLButtonElement,
ReactSegmentedControlOptionProps
>(({ className, children, value, ...otherProps }, ref) => {
const {
api: { getTabTriggerProps },
} = useTabsContext();

const { rootProps, labelProps } = getTabTriggerProps({ value });

const classNames = segmentedControl();

return (
<button
ref={ref}
className={clsx(classNames.option, className)}
{...rootProps}
{...otherProps}
>
<div className={classNames.optionLabel} {...labelProps} tabIndex={-1}>
{children}
</div>
<div aria-hidden className={classNames.optionLabelPlaceholder}>
{children}
</div>
</button>
);
});

SegmentedControlOption.displayName = "SegmentedControlOption";
53 changes: 53 additions & 0 deletions docs/stories/SegmentedControl.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { Meta, StoryObj } from "@storybook/react";

import {
SegmentedControl,
SegmentedControlOption,
type SegmentedControlProps,
} from "seed-design/ui/segmented-control";

import { segmentedControlVariantMap } from "@seed-design/recipe/segmentedControl";
import { SeedThemeDecorator } from "./components/decorator";
import { VariantTable } from "./components/variant-table";
import { useState } from "react";

const Component = () => {
const values = ["dolor", "magna", "sint"];
const [value, setValue] = useState(values[0]);

return (
<SegmentedControl value={value} onValueChange={setValue}>
{values.map((value) => (
<SegmentedControlOption key={value} value={value}>
{value}
</SegmentedControlOption>
))}
</SegmentedControl>
);
};

const meta = {
component: SegmentedControl,
decorators: [SeedThemeDecorator],
} satisfies Meta<typeof SegmentedControl>;

export default meta;

type Story = StoryObj<typeof meta>;

const CommonStoryTemplate: Story = {
args: {
defaultValue: "1",
},
render: function Render(args) {
return <VariantTable Component={Component} variantMap={segmentedControlVariantMap} {...args} />;
},
};

export const LightTheme = CommonStoryTemplate;

export const DarkTheme = CommonStoryTemplate;

export const FontScalingExtraSmall = CommonStoryTemplate;

export const FontScalingExtraExtraExtraLarge = CommonStoryTemplate;
2 changes: 2 additions & 0 deletions packages/recipe-generator/preset/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import helpBubble from "./help-bubble.recipe";
import inlineBanner from "./inline-banner.recipe";
import progressCircle from "./progress-circle.recipe";
import radio from "./radio.recipe";
import segmentedControl from "./segmented-control.recipe";
import switchRecipe from "./switch.recipe";
import tab from "./tab.recipe";
import tabs from "./tabs.recipe";
Expand All @@ -28,6 +29,7 @@ const recipes = {
actionChip,
controlChip,
callout,
segmentedControl,
switch: switchRecipe,
helpBubble,
inlineBanner,
Expand Down
Loading

0 comments on commit 5f3d2d3

Please sign in to comment.