Skip to content

Commit

Permalink
Add AlphaIconButton component (#2200)
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 #2199

## Summary

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

- 아이콘만 있는 `AlphaIconButton` 컴포넌트를 구현합니다. 

## Details

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

- 스타일은 버튼 컴포넌트에서 거의 그대로 가져왔습니다.#2180 까지는 복붙으로 구현한 후에 후속작업에서 버튼 컴포넌트의
스타일을 background-color, color, hover effect 별로 믹스인으로 분리하고 이를 재사용하는 형태로
리팩토링할 예정입니다.
- 아이콘만 있는 경우 접근성을 위해 적절히 라벨을 주는 것이 필요합니다. 이를 위해서 (i) hidden text 를 주는 방법
(ii) aria-label 을 주는 방법 등이 있는데, hidden text 를 주면 문제가 발생한 경우가 있기도 하고
aria-label 을 주는 것이 간편해보여서 이 방법으로 하려고 합니다.
([#](#1310)). 다만
aria-label을 required로 해야할 지는 의문입니다. 다른 라이브러리를 찾아보니 라이브러리마다 다른 부분이기도 하고,
베지어에서는 접근성 관련 속성을 required로 한 적이 없어서 optional이 좋겠다는 생각입니다.
- `IconButtonStyleVariant`, `IconButtonColor` 타입을 export 할 필요는 없는 것 같아서
하지 않았습니다. 이전에는 enum으로 사용하고 있어서 export 해야만 했는데 현재는 그렇지 않아서
`IconButtonProps['variant']` 처럼 사용하는 것이 가능합니다. 이거는 V2 컴포넌트 전반적으로 컨벤션이
맞춰지면 좋을 것 같아서 의견 부탁드립니다. (@sungik-choi)

### 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.sarasoueidan.com/blog/accessible-icon-buttons/
- https://v2.chakra-ui.com/docs/components/icon-button#usage
- https://primer.style/components/icon-button/react/alpha#iconbutton
  • Loading branch information
yangwooseong authored May 13, 2024
1 parent 9bb4810 commit 75a4f37
Show file tree
Hide file tree
Showing 7 changed files with 450 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/pink-paws-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@channel.io/bezier-react": patch
---

Add `AlphaIconButton` component.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { PlusIcon } from '@channel.io/bezier-icons'
import { type Meta, type StoryObj } from '@storybook/react'

import {
AlphaIconButton,
type AlphaIconButtonProps,
} from '~/src/components/AlphaIconButton'

const meta: Meta<typeof AlphaIconButton> = {
component: AlphaIconButton,
argTypes: {
onClick: { action: 'onClick' },
},
}
export default meta

export const Playground: StoryObj<AlphaIconButtonProps> = {
args: {
disabled: false,
active: false,
loading: false,
icon: PlusIcon,
shape: 'rectangle',
'aria-label': 'invite',
size: 'm',
variant: 'primary',
color: 'blue',
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
@use '../../styles/mixins/dimension';

$chromatic-colors: 'blue', 'red', 'green', 'cobalt', 'orange', 'pink', 'purple';

.IconButton {
position: relative;
box-sizing: border-box;
transition: background-color var(--transition-s);

/* dimension */
&:where(.size-xs) {
@include dimension.square(20px);

padding: 4px;
}

&:where(.size-s) {
@include dimension.square(24px);

padding: 4px;
}

&:where(.size-m) {
@include dimension.square(36px);

padding: 8px;
}

&:where(.size-l) {
@include dimension.square(44px);

padding: 12px;
}

&:where(.size-xl) {
@include dimension.square(54px);

padding: 15px;
}

/* background-color */
&:where(.variant-primary) {
$background-color-by-color: (
blue: var(--alpha-color-primary-bg-normal),
cobalt: var(--alpha-color-accent-bg-normal),
red: var(--alpha-color-critical-bg-normal),
orange: var(--alpha-color-warning-bg-normal),
green: var(--alpha-color-success-bg-normal),
pink: var(--alpha-color-bg-pink-normal),
purple: var(--alpha-color-bg-purple-normal),
dark-grey: var(--alpha-color-bg-grey-darkest),
light-grey: var(--alpha-color-bg-black-dark),
);

@each $color, $background-color in $background-color-by-color {
&:where(.color-#{$color}) {
background-color: $background-color;
}
}
}

&:where(.variant-secondary) {
$background-color-by-color: (
blue: var(--alpha-color-primary-bg-lightest),
cobalt: var(--alpha-color-accent-bg-lightest),
red: var(--alpha-color-critical-bg-lightest),
orange: var(--alpha-color-warning-bg-lightest),
green: var(--alpha-color-success-bg-lightest),
pink: var(--alpha-color-bg-pink-lightest),
purple: var(--alpha-color-bg-purple-lightest),
dark-grey: var(--alpha-color-bg-black-lighter),
light-grey: var(--alpha-color-bg-black-lighter),
);

@each $color, $background-color in $background-color-by-color {
&:where(.color-#{$color}) {
background-color: $background-color;
}
}
}

&:where(.variant-tertiary) {
background-color: initial;
}

/* color */
/* stylelint-disable-next-line no-duplicate-selectors */
&:where(.variant-primary) {
color: var(--alpha-color-fg-absolute-white-dark);

&:where(.color-dark-grey) {
color: var(--alpha-color-fg-white-normal);
}

&:where(.color-light-grey) {
color: var(--alpha-color-fg-absolute-white-normal);
}
}

&:where(.variant-secondary, .variant-tertiary) {
$color-map: (
blue: var(--alpha-color-primary-fg-normal),
cobalt: var(--alpha-color-accent-fg-normal),
red: var(--alpha-color-critical-fg-normal),
orange: var(--alpha-color-warning-fg-normal),
green: var(--alpha-color-success-fg-normal),
pink: var(--alpha-color-fg-pink-normal),
purple: var(--alpha-color-fg-purple-normal),
dark-grey: var(--alpha-color-fg-black-darkest),
light-grey: var(--alpha-color-fg-black-darker),
);

@each $button-color, $color in $color-map {
&:where(.color-#{$button-color}) {
color: $color;
}
}

&:where(.color-dark-grey) {
& :where(.ButtonIcon) {
color: var(--alpha-color-fg-black-darker);
}
}

&:where(.color-light-grey) {
& :where(.ButtonIcon) {
color: var(--alpha-color-fg-black-dark);
}
}
}

&:where(.variant-tertiary.color-white) {
& :where(.ButtonIcon) {
color: var(--alpha-color-fg-absolute-white-normal);
}
}

/* border-radius */
&:where(.shape-rectangle) {
$border-radius-by-size: (
xs: var(--alpha-dimension-6),
s: var(--alpha-dimension-7),
m: var(--alpha-dimension-10),
l: var(--alpha-dimension-12),
xl: var(--alpha-dimension-14),
);

@each $size, $border-radius in $border-radius-by-size {
&:where(.size-#{$size}) {
border-radius: $border-radius;
}
}
}

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

/* TODO: use v2 token when design is specified */

/* visual effect on interaction */
&:where(.active, :hover):where(:not(:disabled)) {
&:where(.variant-primary) {
@each $color in $chromatic-colors {
&:where(.color-#{$color}) {
background-color: var(--bgtxt-#{$color}-dark);
}
}

&:where(.color-dark-grey) {
background-color: var(--bg-grey-darkest);
}

&:where(.color-light-grey) {
background-color: var(--bg-black-darker);
}
}

&:where(.variant-secondary) {
@each $color in $chromatic-colors {
&:where(.color-#{$color}) {
background-color: var(--bgtxt-#{$color}-lighter);
}
}

&:where(.color-dark-grey, .color-light-grey) {
background-color: var(--bg-black-light);
}
}

&:where(.variant-tertiary) {
@each $color in $chromatic-colors {
&:where(.color-#{$color}) {
background-color: var(--bgtxt-#{$color}-lightest);
}
}

&:where(.color-dark-grey, .color-light-grey, .color-white) {
background-color: var(--bg-black-lighter);
}
}

&:where(.color-dark-grey):where(.variant-secondary, .variant-tertiary) {
& :is(.ButtonIcon, .ButtonLoader) {
color: var(--txt-black-darkest);
}
}

&:where(.color-light-grey):where(.variant-secondary, .variant-tertiary) {
& :is(.ButtonIcon, .ButtonLoader) {
color: var(--txt-black-darker);
}
}

&:where(.color-white.variant-tertiary) {
& :where(.ButtonIcon) {
color: var(--alpha-color-fg-absolute-white-normal);
}
}
}

&:where(.variant-primary.color-blue:focus-visible) {
outline: 3px solid var(--bgtxt-blue-light);
}

&:disabled {
cursor: not-allowed;
opacity: var(--alpha-opacity-disabled);
}

/* internal components */
.ButtonContent {
display: flex;
align-items: center;
justify-content: center;

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

.ButtonLoader {
position: absolute;
inset: 0;

display: flex;
align-items: center;
justify-content: center;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import React, { forwardRef } from 'react'

import classNames from 'classnames'

import { type AlphaIconButtonProps } from '~/src/components/AlphaIconButton'
import { BaseButton } from '~/src/components/BaseButton'
import { type ButtonSize } from '~/src/components/Button'
import { Icon } from '~/src/components/Icon'
import { Spinner } from '~/src/components/Spinner'

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

function getIconSize(size: ButtonSize) {
return (
{
xs: 'xxs',
s: 'xs',
m: 's',
l: 's',
xl: 'm',
} as const
)[size]
}

function getSpinnerSize(size: ButtonSize) {
return (
{
xs: 'xs',
s: 'xs',
m: 's',
l: 's',
xl: 's',
} as const
)[size]
}

export const IconButton = forwardRef<HTMLButtonElement, AlphaIconButtonProps>(
function IconButton(
{
as = BaseButton,
color = 'blue',
variant = 'primary',
size = 'm',
disabled,
active,
shape = 'rectangle',
icon,
loading,
className,
...rest
},
forwardedRef
) {
const Comp = as as typeof BaseButton

return (
<Comp
ref={forwardedRef}
className={classNames(
styles.IconButton,
styles[`size-${size}`],
styles[`variant-${variant}`],
styles[`color-${color}`],
styles[`shape-${shape}`],
active && styles.active,
className
)}
{...rest}
>
<div
className={classNames(
styles.ButtonContent,
loading && styles.loading
)}
>
{icon && (
<Icon
size={getIconSize(size)}
source={icon}
className={styles.ButtonIcon}
/>
)}
</div>

{/* TODO: use AlphaSpinner */}
{loading && (
<div className={styles.ButtonLoader}>
<Spinner size={getSpinnerSize(size)} />
</div>
)}
</Comp>
)
}
)
Loading

0 comments on commit 75a4f37

Please sign in to comment.