Skip to content

Commit

Permalink
Add ToggleButton and ToggleButtonGroup component (#2276)
Browse files Browse the repository at this point in the history
<!--
  How to write a good PR title:
- Follow [the Conventional Commits
specification](https://www.conventionalcommits.org/en/v1.0.0/).
  - Give as much context as necessary and as little as possible
  - Prefix it with [WIP] while it’s a work in progress
-->

## Self Checklist

- [x] I wrote a PR title in **English** and added an appropriate
**label** to the PR.
- [x] I wrote the commit message in **English** and to follow [**the
Conventional Commits
specification**](https://www.conventionalcommits.org/en/v1.0.0/).
- [x] I [added the
**changeset**](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md)
about the changes that needed to be released. (or didn't have to)
- [x] I wrote or updated **documentation** related to the changes. (or
didn't have to)
- [x] I wrote or updated **tests** related to the changes. (or didn't
have to)
- [x] I tested the changes in various browsers. (or didn't have to)
  - Windows: Chrome, Edge, (Optional) Firefox
  - macOS: Chrome, Edge, Safari, (Optional) Firefox

## Related Issue

<!-- Please link to issue if one exists -->

<!-- Fixes #0000 -->

- Fixes #2271 
- Fixes #2277 

## Summary

<!-- Please brief explanation of the changes made -->

- ToggleButton와 ToggleButtonGroup컴포넌트를 구현합니다. radix-ui/primitive를 사용해서
구현했고, uncontrolled/controlled모두 지원하도록 했습니다.

## Details

<!-- Please elaborate description of the changes -->

- ~~uncontrolled로도 사용할 수 있게 하기 위해 defaultSelected를 옵셔널로 받을 수 있게 했습니다. 다만
텍스트의 볼드 유무가 selected에 따라 바뀌다 보니, defaultSelected를 라딕스 컴포넌트에 바로 넘기지 못하고
isSelected 상태를 내부에 한번 더 선언하고 defaultSelected 와 동기화 했습니다.~~ ->
ToggleButtonGroup을 구현하다보니 selected, defaultSelected 속성이 아니라 value로
선택되었는지를 판단해야해서 이렇게 하면 안될 것 같네요. Text의 bold속성을 주지 않고 css 에서 직접
font-weight을 주는 것으로 변경했습니다.
- toggle 할 때 bold가 on/off되면서 너비가 약간 달라져서 레이아웃이 살짝 shift되는 현상이 있었습니다.
안보이는 상태로 렌더링되는 bold상태의 텍스트를 기준으로 너비를 정해서 이런 현상을 막았습니다.
- shape 속성은 본래 ToggleButton에만 있는 속성이지만 개발의 편의성을 위해 ToggleButtonGroup에
넣어줘서 한번만 써도 되도록 했습니다. Form에서도 비슷한 접근을 하고 있어서 일관성을 해치지 않을 것 같습니다.

### Breaking change? (Yes/No)

<!-- If Yes, please describe the impact and migration path for users -->

- No

## References

<!-- Please list any other resources or points the reviewer should be
aware of -->

-
https://www.radix-ui.com/primitives/docs/components/toggle-group#api-reference
- https://www.radix-ui.com/primitives/docs/components/toggle
- [디자인
(internal)](https://www.figma.com/design/fPXP9zfjZU9NyARnhTWL6o/Input?node-id=426-1255&t=XUHjD3u6gWmZOgqj-0)
- [스펙
(internal)](https://www.notion.so/channelio/Toggle-Button-1efbe7a0b118456dac4b6d2bdd36b633)
  • Loading branch information
yangwooseong committed Jun 20, 2024
1 parent d8964fc commit 8f6f948
Show file tree
Hide file tree
Showing 25 changed files with 633 additions and 11 deletions.
5 changes: 5 additions & 0 deletions .changeset/forty-turkeys-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@channel.io/bezier-react': patch
---

Add `AlphaToggleButton` component.
5 changes: 5 additions & 0 deletions .changeset/honest-fans-tease.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@channel.io/bezier-react': patch
---

Add `AlphaToggleButtonGroup` component.
2 changes: 2 additions & 0 deletions packages/bezier-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-toolbar": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@radix-ui/react-visually-hidden": "^1.0.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const meta: Meta<typeof AlphaButton> = {
}
export default meta

export const Playground: StoryObj<AlphaButtonProps> = {
export const Primary: StoryObj<AlphaButtonProps> = {
args: {
text: 'Invite',
disabled: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const meta: Meta<typeof AlphaFloatingButton> = {
}
export default meta

export const Playground: StoryObj<AlphaFloatingButtonProps> = {
export const Primary: StoryObj<AlphaFloatingButtonProps> = {
args: {
text: 'Invite',
disabled: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const meta: Meta<typeof AlphaFloatingIconButton> = {
}
export default meta

export const Playground: StoryObj<AlphaFloatingIconButtonProps> = {
export const Primary: StoryObj<AlphaFloatingIconButtonProps> = {
args: {
disabled: false,
active: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const meta: Meta<typeof AlphaIconButton> = {
}
export default meta

export const Playground: StoryObj<AlphaIconButtonProps> = {
export const Primary: StoryObj<AlphaIconButtonProps> = {
args: {
disabled: false,
active: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ArrowRightIcon, GiftIcon } from '@channel.io/bezier-icons'
import { type Meta, type StoryObj } from '@storybook/react'

import { AlphaToggleButton } from '~/src/components/AlphaToggleButton'

const meta = {
component: AlphaToggleButton,
argTypes: {
onClick: { action: 'onClick' },
},
} satisfies Meta<typeof AlphaToggleButton>

export default meta

export const Primary = {
args: {
text: 'Invite',
selected: false,
loading: false,
prefixContent: GiftIcon,
suffixContent: ArrowRightIcon,
size: 'm',
shape: 'capsule',
value: 'invite',
variant: 'primary',
},
} satisfies StoryObj<typeof meta>
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
@use '../../styles/mixins/dimension';
@use 'sass:map';

@import '../Icon/Icon.module';

.Button {
position: relative;

box-sizing: border-box;
max-width: 200px;

color: var(--txt-black-darkest);

transition: background-color var(--transition-s);

/* dimension, variant, and shape */
&:where(.size-m) {
height: 36px;
padding: 8px 12px;

& :where(.ButtonText) {
padding: 1px 4px;
}
}

&:where(.variant-primary) {
color: var(--alpha-color-fg-black-darkest);
background-color: var(--alpha-color-bg-grey-lightest);
box-shadow: var(--alpha-shadow-input-default);
}

&:where(.variant-secondary) {
background-color: var(--alpha-color-bg-black-lightest);
}

&:where(.shape-rectangle) {
border-radius: var(--alpha-dimension-10);
}

&:where(.shape-capsule) {
border-radius: 9999px;
}

/* visual effect on interaction */
&:where(:hover) {
&:where(.variant-primary) {
background-color: var(--alpha-color-bg-grey-lighter);
}

&:where(.variant-secondary) {
background-color: var(--alpha-color-bg-black-lighter);
}

&:where([data-state='off']) {
& :is(.ButtonIcon) {
color: var(--txt-black-darker);
}
}
}

&:where([data-state='on']) {
color: var(--alpha-color-fg-blue-normal);

&:where(.variant-primary) {
background-color: var(--alpha-color-bg-blue-lightest);
box-shadow: var(--alpha-shadow-input-default);
box-shadow: 0 0 0 1px var(--alpha-color-primary-bg-normal) inset;
}

&:where(.variant-secondary) {
background-color: var(--alpha-color-primary-bg-lighter);
}

& :where(.ButtonText) {
font-weight: var(--font-weight-700);
}
}

&:where(:focus-visible) {
box-shadow: var(--alpha-shadow-input-active);
}

/* internal components */
&:where([data-state='off']) :where(.ButtonIcon) {
color: var(--alpha-color-fg-black-dark);
}

& :where(.ButtonContent) {
display: flex;
align-items: center;
justify-content: center;

&:where(.loading) {
visibility: hidden;
}
}

& :where(.ButtonLoader) {
position: absolute;
inset: 0;

display: flex;
align-items: center;
justify-content: center;

&:where(.size-s) {
& :is(.Spinner) {
@include dimension.square(#{map.get($size-map, 's')}px);
}
}
}

/* NOTE: this fixes container width when bold toggles */
& :where(.ButtonText)::after {
content: attr(data-text);

overflow: hidden;
display: block;

height: 0;

font-weight: var(--font-weight-700);
color: transparent;

visibility: hidden;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import React, { forwardRef } from 'react'

import { isBezierIcon } from '@channel.io/bezier-icons'
import * as TogglePrimitive from '@radix-ui/react-toggle'
import classNames from 'classnames'

import { AlphaSpinner } from '~/src/components/AlphaSpinner'
import { useToggleButtonContext } from '~/src/components/AlphaToggleButton/ToggleButtonContext'
import { BaseButton } from '~/src/components/BaseButton'
import { Icon, type IconSize } from '~/src/components/Icon'
import { Text } from '~/src/components/Text'

import { type ToggleButtonProps } from './ToggleButton.types'

import styles from './ToggleButton.module.scss'

function SideContent({
size,
content,
}: {
size: IconSize
content?: ToggleButtonProps['prefixContent']
}) {
return isBezierIcon(content) ? (
<Icon
source={content}
size={size}
className={styles.ButtonIcon}
/>
) : (
content
)
}

export const ToggleButton = forwardRef<HTMLButtonElement, ToggleButtonProps>(
function ToggleButton(
{
as = BaseButton,
text,
prefixContent,
suffixContent,
variant = 'primary',
shape: shapeProps,
size = 'm',
className,
loading,
onSelectedChange,
...rest
},
forwardedRef
) {
const { shape: shapeContext } = useToggleButtonContext()
const shape = shapeProps ?? shapeContext ?? 'capsule'
const Comp = as as typeof BaseButton
const ICON_SIZE = 's'

return (
<TogglePrimitive.Root
asChild
onPressedChange={onSelectedChange}
{...rest}
>
<Comp
ref={forwardedRef}
className={classNames(
styles.Button,
styles[`size-${size}`],
styles[`variant-${variant}`],
shape && styles[`shape-${shape}`],
className
)}
>
<div
className={classNames(
styles.ButtonContent,
loading && styles.loading
)}
>
<SideContent
size={ICON_SIZE}
content={prefixContent}
/>

{/* TODO: use AlphaText later, add typo */}
<Text
className={styles.ButtonText}
typo="14"
data-text={text}
truncated
>
{text}
</Text>

<SideContent
size={ICON_SIZE}
content={suffixContent}
/>
</div>

{loading && (
<div
className={classNames(
styles.ButtonLoader,
styles[`size-${ICON_SIZE}`]
)}
>
<AlphaSpinner
className={styles.Spinner}
variant="secondary"
/>
</div>
)}
</Comp>
</TogglePrimitive.Root>
)
}
)
Loading

0 comments on commit 8f6f948

Please sign in to comment.