diff --git a/.changeset/many-tips-float.md b/.changeset/many-tips-float.md new file mode 100644 index 0000000000..1cd0493c3c --- /dev/null +++ b/.changeset/many-tips-float.md @@ -0,0 +1,5 @@ +--- +'@channel.io/bezier-tokens': patch +--- + +Add source size tokens diff --git a/.changeset/stupid-countries-yawn.md b/.changeset/stupid-countries-yawn.md new file mode 100644 index 0000000000..a169adcaba --- /dev/null +++ b/.changeset/stupid-countries-yawn.md @@ -0,0 +1,5 @@ +--- +'@channel.io/bezier-react': patch +--- + +Add `AlphaIcon` component diff --git a/packages/bezier-react/src/components/AlphaAvatar/Avatar.module.scss b/packages/bezier-react/src/components/AlphaAvatar/Avatar.module.scss index 4280403bc0..ae7d3aeb5a 100644 --- a/packages/bezier-react/src/components/AlphaAvatar/Avatar.module.scss +++ b/packages/bezier-react/src/components/AlphaAvatar/Avatar.module.scss @@ -1,7 +1,5 @@ @use '../../styles/mixins/dimension'; -$avatar-sizes: 16, 20, 24, 30, 36, 42, 48, 72, 90, 120; - .Avatar { position: relative; display: block; @@ -10,12 +8,6 @@ $avatar-sizes: 16, 20, 24, 30, 36, 42, 48, 72, 90, 120; pointer-events: none; opacity: var(--alpha-opacity-disabled); } - - @each $size in $avatar-sizes { - &:where(.size-#{$size}) { - @include dimension.square(#{$size}px); - } - } } .AvatarImage { diff --git a/packages/bezier-react/src/components/AlphaAvatar/Avatar.tsx b/packages/bezier-react/src/components/AlphaAvatar/Avatar.tsx index e8cc6f6b4d..9a8f652435 100644 --- a/packages/bezier-react/src/components/AlphaAvatar/Avatar.tsx +++ b/packages/bezier-react/src/components/AlphaAvatar/Avatar.tsx @@ -4,6 +4,7 @@ import { forwardRef, isValidElement, useMemo } from 'react' import classNames from 'classnames' +import { getSourceSizeClassName } from '~/src/types/alpha-props-helpers' import { isEmpty } from '~/src/utils/type' import { useAvatarContext } from '~/src/components/AlphaAvatar/AvatarSizeContext' @@ -131,7 +132,7 @@ export const Avatar = forwardRef(function Avatar(
* + * { margin-left: var(--b-avatar-group-spacing); } @@ -49,5 +40,5 @@ position: relative; display: flex; align-items: center; - height: var(--b-avatar-group-size); + height: 100%; } diff --git a/packages/bezier-react/src/components/AlphaAvatarGroup/AvatarGroup.tsx b/packages/bezier-react/src/components/AlphaAvatarGroup/AvatarGroup.tsx index ee65fe7897..cc53dcc2ea 100644 --- a/packages/bezier-react/src/components/AlphaAvatarGroup/AvatarGroup.tsx +++ b/packages/bezier-react/src/components/AlphaAvatarGroup/AvatarGroup.tsx @@ -37,6 +37,8 @@ function getRestAvatarListCountText(count: number, max: number) { function getProperIconSize(size: AlphaAvatarSize) { return ( { + 10: 'xxs', + 12: 'xxs', 16: 'xxs', 20: 'xxs', 24: 'xs', @@ -44,6 +46,7 @@ function getProperIconSize(size: AlphaAvatarSize) { 36: 'm', 42: 'm', 48: 'l', + 60: 'l', 72: 'l', 90: 'l', 120: 'l', @@ -55,6 +58,8 @@ function getProperIconSize(size: AlphaAvatarSize) { function getProperTypoSize(size: AlphaAvatarSize) { return ( { + 10: '12', + 12: '12', 16: '12', 20: '12', 24: '13', @@ -62,6 +67,7 @@ function getProperTypoSize(size: AlphaAvatarSize) { 36: '16', 42: '18', 48: '24', + 60: '24', 72: '24', 90: '24', 120: '24', @@ -202,11 +208,7 @@ export const AvatarGroup = forwardRef(
@@ -101,7 +101,7 @@ exports[`AvatarGroup Ellipsis type - Count Snapshot 1`] = ` exports[`AvatarGroup Ellipsis type - Icon Snapshot 1`] = `
diff --git a/packages/bezier-react/src/components/AlphaIcon/AlphaIcon.stories.tsx b/packages/bezier-react/src/components/AlphaIcon/AlphaIcon.stories.tsx new file mode 100644 index 0000000000..49005e0760 --- /dev/null +++ b/packages/bezier-react/src/components/AlphaIcon/AlphaIcon.stories.tsx @@ -0,0 +1,66 @@ +import { + ChannelBtnFilledIcon, + type IconName, + icons, +} from '@channel.io/bezier-icons' +import { type Meta, type StoryObj } from '@storybook/react' + +import { camelCase } from '~/src/utils/string' + +import { AlphaIconButton } from '~/src/components/AlphaIconButton' +import { Stack } from '~/src/components/Stack' +import { Tooltip } from '~/src/components/Tooltip' + +import { Icon } from './Icon' +import { type IconProps } from './Icon.types' + +const meta: Meta = { + component: Icon, +} + +export default meta + +export const Primary: StoryObj = { + args: { + source: ChannelBtnFilledIcon, + size: '24', + color: 'fg-black-darker', + }, +} + +const pascalCase = (str: string) => + camelCase(str).replace(/^./, (char) => char.toUpperCase()) + +const iconNames = Object.keys(icons) as IconName[] + +export const IconGallery: StoryObj = { + render: () => ( + + {iconNames.map((iconName) => ( + + + + + + ))} + + ), +} diff --git a/packages/bezier-react/src/components/AlphaIcon/Icon.module.scss b/packages/bezier-react/src/components/AlphaIcon/Icon.module.scss new file mode 100644 index 0000000000..fb3e60a49b --- /dev/null +++ b/packages/bezier-react/src/components/AlphaIcon/Icon.module.scss @@ -0,0 +1,7 @@ +.Icon { + --b-icon-color: initial; + + flex: 0 0 auto; + color: var(--b-icon-color); + transition: color var(--transition-s); +} diff --git a/packages/bezier-react/src/components/AlphaIcon/Icon.test.tsx b/packages/bezier-react/src/components/AlphaIcon/Icon.test.tsx new file mode 100644 index 0000000000..3ff9f3d38f --- /dev/null +++ b/packages/bezier-react/src/components/AlphaIcon/Icon.test.tsx @@ -0,0 +1,43 @@ +import { ChannelBtnFilledIcon } from '@channel.io/bezier-icons' +import { render, screen } from '@testing-library/react' + +import { Icon } from './Icon' + +describe('Icon', () => { + const renderIcon = (props = {}) => + render( + + ) + + it('should render', () => { + renderIcon() + expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument() + }) + + describe('Accessibility', () => { + it('should be decorative by default', () => { + renderIcon() + expect(screen.getByRole('img', { hidden: true })).toHaveAttribute( + 'aria-hidden', + 'true' + ) + }) + + it('should be accessible with aria-label', () => { + renderIcon({ 'aria-label': 'Channel Button' }) + expect(screen.getByRole('img')).toHaveAttribute( + 'aria-label', + 'Channel Button' + ) + expect(screen.getByRole('img')).toBeInTheDocument() + }) + + it('should respect explicit aria-hidden', () => { + renderIcon({ 'aria-hidden': false }) + expect(screen.getByRole('img')).toBeInTheDocument() + }) + }) +}) diff --git a/packages/bezier-react/src/components/AlphaIcon/Icon.tsx b/packages/bezier-react/src/components/AlphaIcon/Icon.tsx new file mode 100644 index 0000000000..9ff8ea1ecd --- /dev/null +++ b/packages/bezier-react/src/components/AlphaIcon/Icon.tsx @@ -0,0 +1,71 @@ +'use client' + +import { forwardRef, memo } from 'react' +import * as React from 'react' + +import classNames from 'classnames' + +import { getSourceSizeClassName } from '~/src/types/alpha-props-helpers' +import { getMarginStyles, splitByMarginProps } from '~/src/types/props-helpers' +import { alphaColorTokenCssVar } from '~/src/utils/style' + +import { type IconProps } from './Icon.types' + +import styles from './Icon.module.scss' + +/** + * `Icon` is a component that renders SVG icons from "@channel.io/bezier-icons" + * @example + * ```tsx + * import { ChannelBtnFilledIcon } from '@channel.io/bezier-icons' + * + * + * ``` + */ +export const Icon = memo( + forwardRef(function Icon(props, forwardedRef) { + const [marginProps, marginRest] = splitByMarginProps(props) + const marginStyles = getMarginStyles(marginProps) + + const { + className, + size = '24', + color, + source: SourceElement, + style, + 'aria-hidden': ariaHidden, + 'aria-label': ariaLabel, + role = 'img', + ...rest + } = marginRest + + const isDecorative = !ariaLabel && ariaHidden !== false + + return ( + + ) + }) +) diff --git a/packages/bezier-react/src/components/AlphaIcon/Icon.types.ts b/packages/bezier-react/src/components/AlphaIcon/Icon.types.ts new file mode 100644 index 0000000000..bebaf4228d --- /dev/null +++ b/packages/bezier-react/src/components/AlphaIcon/Icon.types.ts @@ -0,0 +1,54 @@ +import { type BezierIcon } from '@channel.io/bezier-icons' + +import type { + FunctionalColor, + SemanticColor, + SourceSize, +} from '~/src/types/alpha-tokens' +import { + type BezierComponentProps, + type MarginProps, + type SizeProps, +} from '~/src/types/props' + +export type IconSize = SourceSize + +interface IconOwnProps { + /** + * Controls which icon should be rendered. + * Inject the icon component from the `@channel.io/bezier-icons` package into this prop. + * @example + * ```tsx + * import { HeartFilledIcon } from '@channel.io/bezier-icons' + * import { Icon } from '@channel.io/bezier-react' + * + * + * ``` + */ + source: BezierIcon + /** + * Color from the design system's functional or semantic color. + */ + color?: FunctionalColor | SemanticColor + /** + * Accessible label for the icon + * @example "Close", "Menu", "More" + */ + 'aria-label'?: string + /** + * Whether to hide the icon from screen readers + * @default true when `aria-label` is not provided + */ + 'aria-hidden'?: boolean + /** + * ARIA role + * @default "img" + */ + role?: string +} + +export interface IconProps + extends Omit, keyof IconOwnProps>, + MarginProps, + SizeProps, + IconOwnProps {} diff --git a/packages/bezier-react/src/components/AlphaIcon/index.ts b/packages/bezier-react/src/components/AlphaIcon/index.ts new file mode 100644 index 0000000000..ba3adaa35d --- /dev/null +++ b/packages/bezier-react/src/components/AlphaIcon/index.ts @@ -0,0 +1,5 @@ +export { Icon as AlphaIcon } from './Icon' +export type { + IconProps as AlphaIconProps, + IconSize as AlphaIconSize, +} from './Icon.types' diff --git a/packages/bezier-react/src/components/AlphaIconButton/IconButton.module.scss b/packages/bezier-react/src/components/AlphaIconButton/IconButton.module.scss index c900b6d333..52b149371b 100644 --- a/packages/bezier-react/src/components/AlphaIconButton/IconButton.module.scss +++ b/packages/bezier-react/src/components/AlphaIconButton/IconButton.module.scss @@ -1,43 +1,39 @@ @use '../../styles/mixins/dimension'; -@use '../Icon/Icon.module'; - $chromatic-colors: 'blue', 'red', 'green', 'cobalt', 'orange', 'pink', 'purple'; .IconButton { + --b-icon-button-padding: initial; + position: relative; box-sizing: border-box; + padding: var(--b-icon-button-padding); transition: background-color var(--transition-s); /* dimension */ &:where(.size-xs) { + --b-icon-button-padding: 2px; @include dimension.square(20px); - - padding: 2px; } &:where(.size-s) { + --b-icon-button-padding: 4px; @include dimension.square(24px); - - padding: 4px; } &:where(.size-m) { + --b-icon-button-padding: 8px; @include dimension.square(36px); - - padding: 8px; } &:where(.size-l) { + --b-icon-button-padding: 12px; @include dimension.square(44px); - - padding: 12px; } &:where(.size-xl) { + --b-icon-button-padding: 15px; @include dimension.square(54px); - - padding: 15px; } /* background-color */ @@ -265,18 +261,16 @@ $chromatic-colors: 'blue', 'red', 'green', 'cobalt', 'orange', 'pink', 'purple'; & :where(.ButtonLoader) { position: absolute; - inset: 0; + top: var(--b-icon-button-padding); + left: var(--b-icon-button-padding); display: flex; align-items: center; justify-content: center; - @each $size, $value in Icon.$size-map { - &:where(.size-#{$size}) { - & :is(.Loader) { - @include dimension.square(#{$value}px); - } - } + & :is(.Loader) { + width: inherit; + height: inherit; } } } diff --git a/packages/bezier-react/src/components/AlphaIconButton/IconButton.tsx b/packages/bezier-react/src/components/AlphaIconButton/IconButton.tsx index 52d076f103..4aa5a6f733 100644 --- a/packages/bezier-react/src/components/AlphaIconButton/IconButton.tsx +++ b/packages/bezier-react/src/components/AlphaIconButton/IconButton.tsx @@ -5,22 +5,24 @@ import { forwardRef } from 'react' import { isBezierIcon } from '@channel.io/bezier-icons' import classNames from 'classnames' +import { getSourceSizeClassName } from '~/src/types/alpha-props-helpers' + +import { AlphaIcon } from '~/src/components/AlphaIcon' import { type AlphaIconButtonProps } from '~/src/components/AlphaIconButton' import { AlphaLoader } from '~/src/components/AlphaLoader' import { BaseButton } from '~/src/components/BaseButton' import { type ButtonSize } from '~/src/components/Button' -import { Icon } from '~/src/components/Icon' import styles from './IconButton.module.scss' function getIconSize(size: ButtonSize) { return ( { - xs: 'xs', - s: 'xs', - m: 's', - l: 's', - xl: 'm', + xs: '16', + s: '16', + m: '20', + l: '20', + xl: '24', } as const )[size] } @@ -68,7 +70,7 @@ export const IconButton = forwardRef( )} > {isBezierIcon(content) ? ( - (
+export type SourceSize = RemovePrefix< + 'alpha-source-size', + keyof GlobalToken['source-size'] +> export type GlobalGradient = RemovePrefix< 'alpha-gradient', keyof GlobalToken['gradient'] diff --git a/packages/bezier-react/src/utils/style.ts b/packages/bezier-react/src/utils/style.ts index 705e0f367c..d1a380f022 100644 --- a/packages/bezier-react/src/utils/style.ts +++ b/packages/bezier-react/src/utils/style.ts @@ -1,3 +1,4 @@ +import type * as AlphaTokens from '~/src/types/alpha-tokens' import { type FlattenAllToken } from '~/src/types/tokens' import { isNil, isString } from '~/src/utils/type' @@ -59,3 +60,18 @@ export function tokenCssVar( export function cssUrl(url?: string) { return isNil(url) ? undefined : `url(${url})` } + +/** + * TODO: (@ed) Implement + */ +export function alphaTokenCssVar< + PropertyName extends AlphaTokens.FlattenAllToken | undefined, +>(propertyName: PropertyName) { + return cssVar(propertyName) +} + +export function alphaColorTokenCssVar< + PropertyName extends AlphaTokens.BaseSemanticColor | undefined, +>(propertyName: PropertyName) { + return cssVar(`alpha-color-${propertyName}`) +} diff --git a/packages/bezier-tokens/src/alpha/global/source-size.json b/packages/bezier-tokens/src/alpha/global/source-size.json new file mode 100644 index 0000000000..4686275831 --- /dev/null +++ b/packages/bezier-tokens/src/alpha/global/source-size.json @@ -0,0 +1,56 @@ +{ + "source-size": { + "10": { + "value": "10px", + "type": "dimension" + }, + "12": { + "value": "12px", + "type": "dimension" + }, + "16": { + "value": "16px", + "type": "dimension" + }, + "20": { + "value": "20px", + "type": "dimension" + }, + "24": { + "value": "24px", + "type": "dimension" + }, + "30": { + "value": "30px", + "type": "dimension" + }, + "36": { + "value": "36px", + "type": "dimension" + }, + "42": { + "value": "42px", + "type": "dimension" + }, + "48": { + "value": "48px", + "type": "dimension" + }, + "60": { + "value": "60px", + "type": "dimension" + }, + "72": { + "value": "72px", + "type": "dimension" + }, + "90": { + "value": "90px", + "type": "dimension" + }, + "120": { + "value": "120px", + "type": "dimension" + } + } +} diff --git a/packages/bezier-vscode/src/server.ts b/packages/bezier-vscode/src/server.ts index 67c806412d..2a14e59761 100644 --- a/packages/bezier-vscode/src/server.ts +++ b/packages/bezier-vscode/src/server.ts @@ -90,8 +90,9 @@ const tokenGroupPatterns = { shadow: /box-shadow:/, gradient: /background:|background-image:/, 'z-index': /z-index:/, - // FIXME: delete Exclude when dimension token is removed -} satisfies Record, RegExp> + // NOTE: (@ed) `source-size` is used in the internal component + // so we don't need to suggest it +} satisfies Record, RegExp> const allCompletionItems = Object.values(completionItemsByTokenGroup).flat()