From 5eebbd2fc5393b76dabd01364795e512431bfccc Mon Sep 17 00:00:00 2001 From: Gwangseo Go Date: Fri, 16 Aug 2024 16:05:11 +0900 Subject: [PATCH] [ORT-1] feat: add design tokens & add `Button` (#1) * feat: add design tokens * feat: add `Button` * feat: add `rgba` util * refactor: modify return type of `getRGBFromHex` * style: update comments of `getMediaQuery` * refactor: import types properly * fix: add font weight range & remove default button style --- app/components/Test/Test.css.ts | 9 + app/components/Test/Test.tsx | 6 +- app/components/common/Button/Button.css.ts | 82 +++++++++ .../common/Button/Button.stories.tsx | 22 +++ app/components/common/Button/Button.tsx | 35 ++++ app/components/common/Button/index.ts | 3 + app/constants/style.ts | 8 + app/root.css.ts | 6 + app/root.tsx | 5 +- app/styles/text.css.ts | 174 ++++++++++++++++++ app/styles/theme.css.ts | 47 +++++ app/utils/style.ts | 54 ++++++ 12 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 app/components/common/Button/Button.css.ts create mode 100644 app/components/common/Button/Button.stories.tsx create mode 100644 app/components/common/Button/Button.tsx create mode 100644 app/components/common/Button/index.ts create mode 100644 app/constants/style.ts create mode 100644 app/styles/text.css.ts create mode 100644 app/styles/theme.css.ts create mode 100644 app/utils/style.ts diff --git a/app/components/Test/Test.css.ts b/app/components/Test/Test.css.ts index 5c3ea44..6bc7ae2 100644 --- a/app/components/Test/Test.css.ts +++ b/app/components/Test/Test.css.ts @@ -1,4 +1,6 @@ import { style } from '@vanilla-extract/css'; +import { textStyle } from '@/styles/text.css'; +import { themeVars } from '@/styles/theme.css'; export const container = style({ width: 200, @@ -8,3 +10,10 @@ export const container = style({ alignItems: 'center', backgroundColor: '#f2f2f2', }); + +export const labelText = style([ + textStyle.body1R, + { + color: themeVars.color.primary.normal.hex, + }, +]); diff --git a/app/components/Test/Test.tsx b/app/components/Test/Test.tsx index c79aade..2ba86dd 100644 --- a/app/components/Test/Test.tsx +++ b/app/components/Test/Test.tsx @@ -1,5 +1,9 @@ import * as styles from './Test.css'; -const Test = () =>
Test
; +const Test = () => ( +
+ Test +
+); export default Test; diff --git a/app/components/common/Button/Button.css.ts b/app/components/common/Button/Button.css.ts new file mode 100644 index 0000000..5e836e5 --- /dev/null +++ b/app/components/common/Button/Button.css.ts @@ -0,0 +1,82 @@ +import { style, styleVariants } from '@vanilla-extract/css'; +import { Breakpoint } from '@/constants/style'; +import { themeVars } from '@/styles/theme.css'; +import { getMediaQuery, rgba } from '@/utils/style'; + +export const buttonBase = style({ + cursor: 'pointer', + appearance: 'none', + WebkitAppearance: 'none', + ':disabled': { + cursor: 'not-allowed', + }, +}); + +export const buttonStyleByVariant = styleVariants({ + primary: { + backgroundColor: themeVars.color.primary.normal.hex, + color: themeVars.color.grayscale.white.hex, + border: 0, + ':hover': { + backgroundColor: themeVars.color.primary.dark.hex, + }, + ':active': { + backgroundColor: themeVars.color.primary.darker.hex, + }, + ':disabled': { + backgroundColor: themeVars.color.grayscale.gray2.hex, + color: themeVars.color.grayscale.gray4.hex, + }, + }, + secondary: { + color: themeVars.color.primary.normal.hex, + borderWidth: '1px', + borderStyle: 'solid', + borderColor: themeVars.color.primary.normal.hex, + backgroundColor: 'transparent', + ':hover': { + backgroundColor: rgba(themeVars.color.primary.normal.rgb, 0.1), + }, + ':active': { + backgroundColor: rgba(themeVars.color.primary.normal.rgb, 0.2), + }, + ':disabled': { + backgroundColor: 'transparent', + borderColor: themeVars.color.grayscale.gray3.hex, + color: themeVars.color.grayscale.gray4.hex, + }, + }, +}); + +export const buttonStyleBySize = styleVariants({ + small: { + padding: '6px 12px', + borderRadius: '12px', + '@media': { + [getMediaQuery([Breakpoint.MOBILE1, Breakpoint.MOBILE2])]: { + padding: '6px 12px', + borderRadius: '10px', + }, + }, + }, + medium: { + padding: '12px 28px', + borderRadius: '12px', + '@media': { + [getMediaQuery([Breakpoint.MOBILE1, Breakpoint.MOBILE2])]: { + padding: '10px 24px', + borderRadius: '10px', + }, + }, + }, + large: { + padding: '12px 32px', + borderRadius: '12px', + '@media': { + [getMediaQuery([Breakpoint.MOBILE1, Breakpoint.MOBILE2])]: { + padding: '12px 32px', + borderRadius: '12px', + }, + }, + }, +}); diff --git a/app/components/common/Button/Button.stories.tsx b/app/components/common/Button/Button.stories.tsx new file mode 100644 index 0000000..037706c --- /dev/null +++ b/app/components/common/Button/Button.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; + +import Button from './Button'; + +const meta: Meta = { + component: Button, +}; + +export default meta; + +type Story = StoryObj; + +export const Main: Story = { + args: { + variant: 'primary', + size: 'medium', + children: '버튼 텍스트', + disabled: false, + onClick: fn(), + }, +}; diff --git a/app/components/common/Button/Button.tsx b/app/components/common/Button/Button.tsx new file mode 100644 index 0000000..815026a --- /dev/null +++ b/app/components/common/Button/Button.tsx @@ -0,0 +1,35 @@ +import { type ButtonHTMLAttributes, type DetailedHTMLProps } from 'react'; +import * as styles from './Button.css'; +import { textStyle } from '@/styles/text.css'; + +interface ButtonProps + extends DetailedHTMLProps< + ButtonHTMLAttributes, + HTMLButtonElement + > { + variant: 'primary' | 'secondary'; + size: 'small' | 'medium' | 'large'; +} + +const textStyleBySize = { + small: textStyle.body1R, + medium: textStyle.body1SB, + large: textStyle.headline2B, +}; + +const Button = ({ + variant, + size, + className, + children, + ...buttonProps +}: ButtonProps) => ( + +); + +export default Button; diff --git a/app/components/common/Button/index.ts b/app/components/common/Button/index.ts new file mode 100644 index 0000000..803f51f --- /dev/null +++ b/app/components/common/Button/index.ts @@ -0,0 +1,3 @@ +import Button from './Button'; + +export default Button; diff --git a/app/constants/style.ts b/app/constants/style.ts new file mode 100644 index 0000000..257718f --- /dev/null +++ b/app/constants/style.ts @@ -0,0 +1,8 @@ +export const Breakpoint = { + PC2: [1504, Infinity], + PC1: [1140, 1503], + TABLET2: [832, 1139], + TABLET1: [728, 831], + MOBILE2: [580, 727], + MOBILE1: [0, 579], +} as const; diff --git a/app/root.css.ts b/app/root.css.ts index a3352b0..9f159bb 100644 --- a/app/root.css.ts +++ b/app/root.css.ts @@ -3,6 +3,8 @@ import SUITVariable from '@/assets/SUIT-Variable.woff2'; globalFontFace('SUIT-Variable', { src: `url(${SUITVariable}) format('woff2')`, + /** @link https://stackoverflow.com/questions/77467442/font-weight-too-bold-on-mobile */ + fontWeight: '100 900', }); globalStyle('*, *::before, *::after', { @@ -12,6 +14,10 @@ globalStyle('*, *::before, *::after', { fontFamily: 'SUIT-Variable', }); +globalStyle('html', { + fontSize: '62.5%', +}); + globalStyle('body', { margin: 0, }); diff --git a/app/root.tsx b/app/root.tsx index cbed337..ecbfa15 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -5,9 +5,12 @@ import { Scripts, ScrollRestoration, } from '@remix-run/react'; +import { type ReactNode } from 'react'; + import './root.css'; +import '@/styles/theme.css'; -export const Layout = ({ children }: { children: React.ReactNode }) => ( +export const Layout = ({ children }: { children: ReactNode }) => ( diff --git a/app/styles/text.css.ts b/app/styles/text.css.ts new file mode 100644 index 0000000..be3b9bb --- /dev/null +++ b/app/styles/text.css.ts @@ -0,0 +1,174 @@ +import { type ComplexStyleRule, styleVariants } from '@vanilla-extract/css'; +import { Breakpoint } from '@/constants/style'; +import { getMediaQuery } from '@/utils/style'; + +const FontWeight = { + HEAVY: 900, + EXTRA_BOLD: 800, + BOLD: 700, + SEMI_BOLD: 600, + MEDIUM: 500, + REGULAR: 400, + LIGHT: 300, + EXTRA_LIGHT: 200, + THIN: 100, +} as const; + +interface TextStyleInfo { + [styleName: string]: { + [device in 'pc' | 'mobile']: { + /** px 단위 */ + fontSize: number; + /** px 단위 */ + lineHeight: number; + fontWeight: number; + }; + }; +} + +const textStyleInfo = { + headline1B: { + pc: { + fontSize: 32, + lineHeight: 40, + fontWeight: FontWeight.BOLD, + }, + mobile: { + fontSize: 26, + lineHeight: 32, + fontWeight: FontWeight.BOLD, + }, + }, + headline2B: { + pc: { + fontSize: 24, + lineHeight: 30, + fontWeight: FontWeight.BOLD, + }, + mobile: { + fontSize: 20, + lineHeight: 25, + fontWeight: FontWeight.BOLD, + }, + }, + subtitle1B: { + pc: { + fontSize: 20, + lineHeight: 25, + fontWeight: FontWeight.BOLD, + }, + mobile: { + fontSize: 16, + lineHeight: 20, + fontWeight: FontWeight.BOLD, + }, + }, + subtitle2B: { + pc: { + fontSize: 16, + lineHeight: 20, + fontWeight: FontWeight.BOLD, + }, + mobile: { + fontSize: 14, + lineHeight: 17, + fontWeight: FontWeight.BOLD, + }, + }, + subtitle2SB: { + pc: { + fontSize: 16, + lineHeight: 20, + fontWeight: FontWeight.SEMI_BOLD, + }, + mobile: { + fontSize: 14, + lineHeight: 17, + fontWeight: FontWeight.SEMI_BOLD, + }, + }, + body1SB: { + pc: { + fontSize: 14, + lineHeight: 17, + fontWeight: FontWeight.SEMI_BOLD, + }, + mobile: { + fontSize: 12, + lineHeight: 15, + fontWeight: FontWeight.SEMI_BOLD, + }, + }, + body1R: { + pc: { + fontSize: 14, + lineHeight: 17, + fontWeight: FontWeight.REGULAR, + }, + mobile: { + fontSize: 12, + lineHeight: 15, + fontWeight: FontWeight.REGULAR, + }, + }, + body2SB: { + pc: { + fontSize: 12, + lineHeight: 15, + fontWeight: FontWeight.SEMI_BOLD, + }, + mobile: { + fontSize: 10, + lineHeight: 12, + fontWeight: FontWeight.SEMI_BOLD, + }, + }, + body2R: { + pc: { + fontSize: 12, + lineHeight: 15, + fontWeight: FontWeight.REGULAR, + }, + mobile: { + fontSize: 10, + lineHeight: 12, + fontWeight: FontWeight.REGULAR, + }, + }, +} as const satisfies TextStyleInfo; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const getTypedKeysFromObject = >( + object: T, +): (keyof T)[] => Object.keys(object); + +const getTextStyleFromInfo = < + T extends (typeof textStyleInfo)[keyof typeof textStyleInfo], +>( + info: T, +): ComplexStyleRule => ({ + fontSize: `${info.pc.fontSize / 10}rem`, + lineHeight: `${info.pc.lineHeight / 10}rem`, + fontWeight: info.pc.fontWeight, + '@media': { + [getMediaQuery([Breakpoint.MOBILE1, Breakpoint.MOBILE2])]: { + fontSize: `${info.mobile.fontSize / 10}rem`, + lineHeight: `${info.mobile.lineHeight / 10}rem`, + fontWeight: info.mobile.fontWeight, + }, + }, +}); + +export const textStyle = styleVariants( + getTypedKeysFromObject(textStyleInfo).reduce<{ + [key in keyof typeof textStyleInfo]: ComplexStyleRule; + }>( + (prev, styleName) => ({ + ...prev, + [styleName]: getTextStyleFromInfo(textStyleInfo[styleName]), + }), + {} as { + [key in keyof typeof textStyleInfo]: ComplexStyleRule; + }, + ), +); diff --git a/app/styles/theme.css.ts b/app/styles/theme.css.ts new file mode 100644 index 0000000..0e29ab9 --- /dev/null +++ b/app/styles/theme.css.ts @@ -0,0 +1,47 @@ +import { createGlobalTheme } from '@vanilla-extract/css'; +import { getRGBFromHex } from '@/utils/style'; + +type ColorVars = { + hex: string; + rgb: string; +}; + +const getColorVarsFromHex = (hex: string): ColorVars => { + const { r, g, b } = getRGBFromHex(hex); + return { + hex, + rgb: `${r}, ${g}, ${b}`, + }; +}; + +export const themeVars = createGlobalTheme(':root', { + color: { + background: { + general: getColorVarsFromHex('#FFFFFF'), + article: getColorVarsFromHex('#FBFAF6'), + }, + system: { + caution: getColorVarsFromHex('#FF0000'), + }, + primary: { + lighter: getColorVarsFromHex('#FAD6BD'), + light: getColorVarsFromHex('#F4A871'), + normal: getColorVarsFromHex('#EF7F2E'), + dark: getColorVarsFromHex('#DA6511'), + darker: getColorVarsFromHex('#AB4F0D'), + }, + grayscale: { + white: getColorVarsFromHex('#FFFFFF'), + gray1: getColorVarsFromHex('#F9F9F9'), + gray2: getColorVarsFromHex('#EEEEEE'), + gray3: getColorVarsFromHex('#E0E0E0'), + gray4: getColorVarsFromHex('#BDBDBD'), + gray5: getColorVarsFromHex('#9E9E9E'), + gray6: getColorVarsFromHex('#757575'), + gray7: getColorVarsFromHex('#616161'), + gray8: getColorVarsFromHex('#424242'), + gray9: getColorVarsFromHex('#212121'), + black: getColorVarsFromHex('#1D1D1D'), + }, + }, +}); diff --git a/app/utils/style.ts b/app/utils/style.ts new file mode 100644 index 0000000..d755f03 --- /dev/null +++ b/app/utils/style.ts @@ -0,0 +1,54 @@ +/** + * Media Query String을 얻는 함수 + * + * - `Breakpoint` 객체와 함께 사용할 것을 권장 + * + * - 주어진 breakpoint 중 최소와 최대만 뽑는 것으로, 중간에 비어 있는 구간이 없음을 유의 + * >- (ex) [10, 12], [15, 18]이 주어질 경우, 13px-14px 구간이 비어야 하지만, 결과 string은 10px-18px 전부를 포함 + * + * - 이론상 가장 작은 breakpoint와 가장 큰 breakpoint만 포함해도 작동하지만, + * 해당 스타일이 어떤 화면에 각각 적용되는지를 한 번에 파악하기 어려워 human error가 발생하기 쉬우므로 + * 모든 breakpoint를 명시하기를 권장 + * >- (ex) + * ``` + * getMediaQuery([Breakpoint.MOBILE2, Breakpoint.TABLET2]) // (X) As-is + * getMediaQuery([Breakpoint.MOBILE2, Breakpoint.MOBILE1, Breakpoint.TABLET2]) // (O) To-be + * ``` + * + * @param breakpoints [breakpoint 시작, breakpoint 끝] 타입의 모음 + * @returns Media Query String + * @example + * ``` + * const mediaQueryString = getMediaQuery([Breakpoint.MOBILE1, Breakpoint.MOBILE2]); + * ``` + */ +export const getMediaQuery = ( + breakpoints: readonly (readonly [number, number])[], +) => { + const { minimum, maximum } = breakpoints.reduce( + (prev, cur) => ({ + minimum: Math.min(prev.minimum, cur[0], cur[1]), + maximum: Math.max(prev.maximum, cur[0], cur[1]), + }), + { minimum: Infinity, maximum: 0 }, + ); + return `screen and (min-width: ${minimum}px)${isFinite(maximum) ? `and (max-width: ${maximum}px)` : ''}`; +}; + +export const getRGBFromHex = (hex: string) => { + const hexToConvert = hex.replace('#', ''); + const aRgbHex = hexToConvert.match(/.{1,2}/g); + + if (aRgbHex === null) { + return { r: 0, g: 0, b: 0 }; + } + + return { + r: parseInt(aRgbHex[0], 16), + g: parseInt(aRgbHex[1], 16), + b: parseInt(aRgbHex[2], 16), + }; +}; + +export const rgba = (cssVar: string, alpha: number) => + `rgba(${cssVar}, ${alpha})`;