diff --git a/libs/design-tokens/src/lib/sd.ts b/libs/design-tokens/src/lib/sd.ts index 5e3a1ba..2610cf9 100644 --- a/libs/design-tokens/src/lib/sd.ts +++ b/libs/design-tokens/src/lib/sd.ts @@ -27,6 +27,16 @@ const RN_NUMERIC_TYPES = new Set([ 'fontWeight', ]); +/** + * Web rem 변환 대상 토큰 타입. + * tokens-studio align-types preprocessor가 spacing/borderRadius/borderWidth를 `dimension`으로 정규화하므로 + * `dimension`을 포함시켜야 spacing/radius/border-width가 px → rem 변환된다. + */ +const WEB_REM_TYPES = new Set(['dimension', 'fontSize', 'lineHeight']); + +/** rem 변환 base. CSS 표준 16px. */ +const REM_BASE_PX = 16; + /** 배열·객체가 아닌 평범한 record 객체인지 검사. */ const isPlainObj = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); @@ -59,6 +69,42 @@ const rnNumberTransform: Transform = { transform: (t: TransformedToken) => coerceNum(getTokenValue(t)), }; +/** + * Web용 rem 변환 transform. spacing/fontSize/lineHeight 값을 px → rem으로 변환. + * html font-size override에 반응하도록 unitless/px 값을 rem 기반으로 노출. + */ +const webRemTransform: Transform = { + name: 'ds/web/rem', + type: 'value', + transitive: true, + filter: (t) => { + const type = getTokenType(t); + return typeof type === 'string' && WEB_REM_TYPES.has(type); + }, + transform: (t: TransformedToken) => { + const v = getTokenValue(t); + const n = toNumeric(v); + if (n === null) return v; + return `${stripTrailingZeros(n / REM_BASE_PX)}rem`; + }, +}; + +/** 숫자 또는 숫자 문자열을 number로 변환. 단위가 붙어 있으면 null. */ +const toNumeric = (v: unknown): number | null => { + if (typeof v === 'number') return Number.isFinite(v) ? v : null; + if (typeof v !== 'string') return null; + const s = v.trim(); + if (!/^-?\d+(\.\d+)?$/.test(s)) return null; + const n = Number(s); + return Number.isFinite(n) ? n : null; +}; + +/** 부동소수점 표기에서 불필요한 trailing 0 제거. `0.1250000` → `0.125`. */ +const stripTrailingZeros = (n: number): string => { + const s = n.toFixed(6); + return s.replace(/\.?0+$/, ''); +}; + let registered = false; /** Tokens Studio + 자체 transform을 SD에 1회만 등록. 중복 호출 안전. */ @@ -67,6 +113,7 @@ const registerOnce = () => { registered = true; registerTokensStudio(StyleDictionary); StyleDictionary.registerTransform(rnNumberTransform); + StyleDictionary.registerTransform(webRemTransform); }; /** `arr`에서 `rm`에 포함된 항목을 제거한 새 배열을 반환. */ @@ -79,7 +126,11 @@ const baseTransforms = getTransforms({ platform: 'css' }) .filter((t): t is string => typeof t === 'string') .filter((t) => !t.startsWith('name/')); -const WEB_TRANSFORMS = [...without(baseTransforms, ['ts/color/css/hexrgba']), 'name/kebab']; +const WEB_TRANSFORMS = [ + ...without(baseTransforms, ['ts/color/css/hexrgba', 'ts/size/px']), + 'ds/web/rem', + 'name/kebab', +]; const RN_TRANSFORMS = [ ...without(baseTransforms, ['ts/size/px', 'ts/size/css/letterspacing', 'ts/color/css/hexrgba']), diff --git a/libs/design-tokens/tokens/dark/color.json b/libs/design-tokens/tokens/dark/color.json index fb12947..edb9942 100644 --- a/libs/design-tokens/tokens/dark/color.json +++ b/libs/design-tokens/tokens/dark/color.json @@ -27,7 +27,7 @@ "disabled": { "$value": "{neutral.ne700}", "$type": "color" }, "focusRipple": { "$value": "{primary.pr500}", "$type": "color" }, "outlinedFocusRipple": { "$value": "{primary.pr700}", "$type": "color" }, - "outlinedHover": { "$value": "{primary.pr800}", "$type": "color" } + "outlinedHover": { "$value": "{primary.pr300}", "$type": "color" } }, "text": { "default": { "$value": "{neutral.ne100}", "$type": "color" }, @@ -47,17 +47,34 @@ "default": { "$value": "{neutral.ne900}", "$type": "color" }, "surface": { "$value": "{neutral.ne800}", "$type": "color" }, "dark": { "$value": "{neutral.ne100}", "$type": "color" }, + "placeholder": { "$value": "{text.placeholder}", "$type": "color" }, "primary": { "$value": "{primary.pr700}", "$type": "color" }, "secondary": { "$value": "{secondary.se700}", "$type": "color" }, "error": { "$value": "{error.er700}", "$type": "color" }, "warning": { "$value": "{warning.wa700}", "$type": "color" }, "success": { "$value": "{success.su700}", "$type": "color" }, - "grey": { "$value": "{neutral.ne600}", "$type": "color" } + "grey": { "$value": "{neutral.ne600}", "$type": "color" }, + "disable": { "$value": "{text.disable}", "$type": "color" } + }, + "icon": { + "default": { "$value": "{text.default}", "$type": "color" }, + "contrastText": { "$value": "{text.contrastText}", "$type": "color" }, + "disable": { "$value": "{text.disable}", "$type": "color" }, + "error": { "$value": "{text.error}", "$type": "color" }, + "light": { "$value": "{text.light}", "$type": "color" }, + "link": { "$value": "{text.link}", "$type": "color" }, + "linkSelected": { "$value": "{text.linkSelected}", "$type": "color" }, + "placeholder": { "$value": "{text.placeholder}", "$type": "color" }, + "primary": { "$value": "{text.primary}", "$type": "color" }, + "secondary": { "$value": "{text.secondary}", "$type": "color" }, + "success": { "$value": "{text.success}", "$type": "color" }, + "warning": { "$value": "{text.warning}", "$type": "color" } }, "stroke": { "default": { "$value": "{neutral.ne700}", "$type": "color" }, "dark": { "$value": "{neutral.ne100}", "$type": "color" }, "light": { "$value": "{neutral.ne800}", "$type": "color" }, + "disable": { "$value": "{text.disable}", "$type": "color" }, "primary": { "$value": "{primary.pr400}", "$type": "color" }, "secondary": { "$value": "{secondary.se400}", "$type": "color" }, "error": { "$value": "{error.er400}", "$type": "color" }, diff --git a/libs/design-tokens/tokens/light/color.json b/libs/design-tokens/tokens/light/color.json index d784e69..b90b334 100644 --- a/libs/design-tokens/tokens/light/color.json +++ b/libs/design-tokens/tokens/light/color.json @@ -69,14 +69,14 @@ "default": { "$value": "{primary.pr700}", "$type": "color" }, "hover": { "$value": "{primary.pr800}", "$type": "color" }, "disabled": { "$value": "{neutral.ne300}", "$type": "color" }, - "focusRipple": { "$value": "{neutral.ne100}", "$type": "color" }, + "focusRipple": { "$value": "{primary.pr500}", "$type": "color" }, "outlinedFocusRipple": { "$value": "{primary.pr700}", "$type": "color" }, "outlinedHover": { "$value": "{primary.pr100}", "$type": "color" } }, "text": { "default": { "$value": "{neutral.ne900}", "$type": "color" }, "light": { "$value": "{neutral.ne600}", "$type": "color" }, - "placeholder": { "$value": "{neutral.ne400}", "$type": "color" }, + "placeholder": { "$value": "{neutral.ne500}", "$type": "color" }, "disable": { "$value": "{neutral.ne500}", "$type": "color" }, "primary": { "$value": "{primary.pr700}", "$type": "color" }, "secondary": { "$value": "{secondary.se700}", "$type": "color" }, diff --git a/libs/design-tokens/tokens/light/spacing.json b/libs/design-tokens/tokens/light/spacing.json index f0cc090..9f9b9ec 100644 --- a/libs/design-tokens/tokens/light/spacing.json +++ b/libs/design-tokens/tokens/light/spacing.json @@ -1,21 +1,16 @@ { "spacing": { - "xxxsm": { "$value": "2", "$type": "spacing" }, - "xxsm": { "$value": "{spacing.xxxsm} * 2", "$type": "spacing" }, - "xsm": { "$value": "{spacing.xxxsm} * 3", "$type": "spacing" }, - "sm": { "$value": "{spacing.xxxsm} * 4", "$type": "spacing" }, - "sml": { "$value": "{spacing.xxxsm} * 6", "$type": "spacing" }, - "md": { "$value": "{spacing.xxxsm} * 7", "$type": "spacing" }, - "mdl": { "$value": "{spacing.xxxsm} * 8", "$type": "spacing" }, - "lg": { "$value": "{spacing.xxxsm} * 10", "$type": "spacing" }, - "xlg": { "$value": "{spacing.xxxsm} * 12", "$type": "spacing" }, - "xl": { "$value": "{spacing.xxxsm} * 14", "$type": "spacing" }, - "xxl": { "$value": "{spacing.xxxsm} * 16", "$type": "spacing" }, - "3xl": { "$value": "{spacing.xxxsm} * 18", "$type": "spacing" }, - "4xl": { "$value": "{spacing.xxxsm} * 20", "$type": "spacing" }, - "5xl": { "$value": "{spacing.xxxsm} * 24", "$type": "spacing" }, - "6xl": { "$value": "{spacing.xxxsm} * 32", "$type": "spacing" }, - "7xl": { "$value": "{spacing.xxxsm} * 48", "$type": "spacing" }, - "8xl": { "$value": "{spacing.xxxsm} * 64", "$type": "spacing" } + "2xs": { "$value": "2", "$type": "spacing" }, + "xs": { "$value": "{spacing.2xs} * 2", "$type": "spacing" }, + "sm": { "$value": "{spacing.2xs} * 3", "$type": "spacing" }, + "md": { "$value": "{spacing.2xs} * 6", "$type": "spacing" }, + "lg": { "$value": "{spacing.2xs} * 8", "$type": "spacing" }, + "xl": { "$value": "{spacing.2xs} * 12", "$type": "spacing" }, + "2xl": { "$value": "{spacing.2xs} * 16", "$type": "spacing" }, + "3xl": { "$value": "{spacing.2xs} * 20", "$type": "spacing" }, + "4xl": { "$value": "{spacing.2xs} * 24", "$type": "spacing" }, + "5xl": { "$value": "{spacing.2xs} * 32", "$type": "spacing" }, + "6xl": { "$value": "{spacing.2xs} * 48", "$type": "spacing" }, + "7xl": { "$value": "{spacing.2xs} * 64", "$type": "spacing" } } } diff --git a/libs/react-ui/.storybook/preview.ts b/libs/react-ui/.storybook/preview.ts index 555ed1a..83fa084 100644 --- a/libs/react-ui/.storybook/preview.ts +++ b/libs/react-ui/.storybook/preview.ts @@ -4,9 +4,16 @@ import '../src/styles'; import { createElement } from 'react'; import type { Preview } from '@storybook/react'; +import { INITIAL_VIEWPORTS } from 'storybook/viewport'; import { ThemeProvider } from '../src/theme/ThemeProvider'; +const dsViewports = { + mobile: { name: 'Mobile', styles: { width: '375px', height: '812px' }, type: 'mobile' }, + tablet: { name: 'Tablet', styles: { width: '768px', height: '1024px' }, type: 'tablet' }, + desktop: { name: 'Desktop', styles: { width: '1440px', height: '900px' }, type: 'desktop' }, +} as const; + const preview: Preview = { parameters: { controls: { @@ -16,6 +23,12 @@ const preview: Preview = { date: /Date$/i, }, }, + viewport: { + options: { ...dsViewports, ...INITIAL_VIEWPORTS }, + }, + }, + initialGlobals: { + viewport: { value: 'responsive' }, }, tags: ['autodocs'], globalTypes: { diff --git a/libs/react-ui/src/components/box/Box.stories.tsx b/libs/react-ui/src/components/box/Box.stories.tsx index bb04761..cbdd0ef 100644 --- a/libs/react-ui/src/components/box/Box.stories.tsx +++ b/libs/react-ui/src/components/box/Box.stories.tsx @@ -8,8 +8,6 @@ const meta = { tags: ['autodocs'], parameters: { layout: 'centered', - // TODO(a11y): 위반 수정 후 disable 제거 - a11y: { disable: true }, }, args: { children: '박스 내용', @@ -107,19 +105,19 @@ export const WithBackground: Story = {
{( [ - 'background.primary', - 'background.secondary', - 'background.success', - 'background.error', - 'background.warning', - 'background.grey', + { token: 'background.primary', fg: '#fff' }, + { token: 'background.secondary', fg: '#fff' }, + { token: 'background.success', fg: '#fff' }, + { token: 'background.error', fg: '#fff' }, + { token: 'background.warning', fg: '#fff' }, + { token: 'background.grey', fg: 'var(--ds-text-default)' }, ] as const - ).map((token) => ( + ).map(({ token, fg }) => ( {token} diff --git a/libs/react-ui/src/components/button-base/button-base.scss b/libs/react-ui/src/components/button-base/button-base.scss index e846e86..1a47940 100644 --- a/libs/react-ui/src/components/button-base/button-base.scss +++ b/libs/react-ui/src/components/button-base/button-base.scss @@ -5,8 +5,8 @@ ========================= */ $sizes: ( sm: ( - px: var(--ds-spacing-sml), - py: var(--ds-spacing-xsm), + px: var(--ds-spacing-md), + py: var(--ds-spacing-sm), radius: var(--ds-radius-sm), ff: var(--ds-body-small-strong-font-family), fs: var(--ds-body-small-strong-font-size), @@ -15,7 +15,7 @@ $sizes: ( lh: var(--ds-body-small-strong-line-height), ), md: ( - px: var(--ds-spacing-xlg), + px: var(--ds-spacing-xl), py: var(--ds-spacing-sm), radius: var(--ds-radius-md), ff: var(--ds-body-medium-strong-font-family), @@ -25,8 +25,8 @@ $sizes: ( lh: var(--ds-body-medium-strong-line-height), ), lg: ( - px: var(--ds-spacing-xxl), - py: var(--ds-spacing-sml), + px: var(--ds-spacing-2xl), + py: var(--ds-spacing-md), radius: var(--ds-radius-lg), ff: var(--ds-body-large-strong-font-family), fs: var(--ds-body-large-strong-font-size), @@ -96,7 +96,7 @@ $colors: ( --ui-btn-font-size: #{map.get($s, fs)}; --ui-btn-font-weight: #{map.get($s, fw)}; --ui-btn-letter-spacing: #{map.get($s, ls)}; - --ui-btn-line-height: calc(#{map.get($s, lh)} * 1px); + --ui-btn-line-height: #{map.get($s, lh)}; } @mixin define-color-palette($color-key) { @@ -125,17 +125,17 @@ $colors: ( .ui-button { /* shape / spacing */ - --ui-btn-px: var(--ds-spacing-xlg); + --ui-btn-px: var(--ds-spacing-xl); --ui-btn-py: var(--ds-spacing-sm); --ui-btn-radius: var(--ds-radius-md); - --ui-btn-gap: var(--ds-spacing-xsm); + --ui-btn-gap: var(--ds-spacing-sm); /* typography (default = md strong) */ --ui-btn-font-family: var(--ds-body-medium-strong-font-family); --ui-btn-font-size: var(--ds-body-medium-strong-font-size); --ui-btn-font-weight: var(--ds-body-medium-strong-font-weight); --ui-btn-letter-spacing: var(--ds-body-medium-strong-letter-spacing); - --ui-btn-line-height: calc(var(--ds-body-medium-strong-line-height) * 1px); + --ui-btn-line-height: var(--ds-body-medium-strong-line-height); /* interactive */ --ui-btn-bg: transparent; @@ -154,7 +154,7 @@ $colors: ( --ui-btn-focus-halo-color: var(--ds-primary-btn-focus-ripple); --ui-btn-focus-width: var(--ds-semantic-border-focus); --ui-btn-focus-outline-width: var(--ds-border-primary-width); - --ui-btn-focus-offset: var(--ds-spacing-xxsm); + --ui-btn-focus-offset: var(--ds-spacing-xs); display: inline-flex; align-items: center; diff --git a/libs/react-ui/src/components/button/Button.stories.tsx b/libs/react-ui/src/components/button/Button.stories.tsx index 2e5866e..75e2b18 100644 --- a/libs/react-ui/src/components/button/Button.stories.tsx +++ b/libs/react-ui/src/components/button/Button.stories.tsx @@ -8,8 +8,6 @@ const meta = { tags: ['autodocs'], parameters: { layout: 'centered', - // TODO(a11y): 위반 수정 후 disable 제거 - a11y: { disable: true }, }, args: { children: 'Button', diff --git a/libs/react-ui/src/components/fab/Fab.stories.tsx b/libs/react-ui/src/components/fab/Fab.stories.tsx index 4109055..4b4569c 100644 --- a/libs/react-ui/src/components/fab/Fab.stories.tsx +++ b/libs/react-ui/src/components/fab/Fab.stories.tsx @@ -8,10 +8,9 @@ const meta = { tags: ['autodocs'], parameters: { layout: 'centered', - // TODO(a11y): 위반 수정 후 disable 제거 - a11y: { disable: true }, }, args: { + 'aria-label': 'Action', color: 'primary', size: 'lg', shape: 'circular', diff --git a/libs/react-ui/src/components/fab/fab.scss b/libs/react-ui/src/components/fab/fab.scss index f845d1c..022fb85 100644 --- a/libs/react-ui/src/components/fab/fab.scss +++ b/libs/react-ui/src/components/fab/fab.scss @@ -2,7 +2,7 @@ --ui-fab-size: var(--ds-font-size-7xl); --ui-fab-padding-inline: 0; --ui-fab-min-inline-size: var(--ui-fab-size); - --ui-fab-gap: var(--ds-spacing-xsm); + --ui-fab-gap: var(--ds-spacing-sm); --ui-fab-icon-size: var(--ds-font-size-xl); --ui-fab-shadow: @@ -93,8 +93,8 @@ } .ui-fab--extended { - --ui-fab-padding-inline: var(--ds-spacing-xlg); - --ui-fab-min-inline-size: var(--ds-spacing-5xl); + --ui-fab-padding-inline: var(--ds-spacing-xl); + --ui-fab-min-inline-size: var(--ds-spacing-4xl); inline-size: auto; } @@ -102,28 +102,28 @@ .ui-fab--extended.ui-fab--size-sm { --ui-fab-padding-inline: var(--ds-spacing-sm); --ui-fab-min-inline-size: calc( - var(--ds-spacing-xxl) + var(--ds-spacing-xxsm) - var(--ds-primitive-border-sm) + var(--ds-spacing-2xl) + var(--ds-spacing-xs) - var(--ds-primitive-border-sm) ); } .ui-fab--extended.ui-fab--size-md { - --ui-fab-padding-inline: var(--ds-spacing-xlg); - --ui-fab-min-inline-size: var(--ds-spacing-4xl); + --ui-fab-padding-inline: var(--ds-spacing-xl); + --ui-fab-min-inline-size: var(--ds-spacing-3xl); } .ui-fab--extended.ui-fab--size-lg { - --ui-fab-padding-inline: var(--ds-spacing-xlg); - --ui-fab-min-inline-size: var(--ds-spacing-5xl); + --ui-fab-padding-inline: var(--ds-spacing-xl); + --ui-fab-min-inline-size: var(--ds-spacing-4xl); } /* size */ .ui-fab--size-sm { - --ui-fab-size: var(--ds-spacing-4xl); + --ui-fab-size: var(--ds-spacing-3xl); --ui-fab-icon-size: var(--ds-font-size-md); } .ui-fab--size-md { - --ui-fab-size: var(--ds-spacing-5xl); + --ui-fab-size: var(--ds-spacing-4xl); --ui-fab-icon-size: var(--ds-font-size-lg); } @@ -143,8 +143,8 @@ } .ui-fab--color-secondary { - --ui-btn-bg: var(--ds-secondary-se500); - --ui-btn-bg-hover: var(--ds-secondary-se700); + --ui-btn-bg: var(--ds-secondary-se700); + --ui-btn-bg-hover: var(--ds-secondary-se800); --ui-btn-bg-disabled: var(--ds-neutral-ne300); --ui-btn-fg: var(--ds-text-contrast-text); --ui-btn-fg-disabled: var(--ds-text-disable); diff --git a/libs/react-ui/src/components/form-control/FormControl.stories.tsx b/libs/react-ui/src/components/form-control/FormControl.stories.tsx index 440f0ef..96f30da 100644 --- a/libs/react-ui/src/components/form-control/FormControl.stories.tsx +++ b/libs/react-ui/src/components/form-control/FormControl.stories.tsx @@ -12,8 +12,6 @@ const meta = { tags: ['autodocs'], parameters: { layout: 'centered', - // TODO(a11y): 위반 수정 후 disable 제거 - a11y: { disable: true }, }, args: { variant: 'boxed', @@ -267,7 +265,7 @@ export const A11y: Story = {
Full name - + Enter your legal full name. @@ -290,8 +288,4 @@ export const A11y: Story = {
), - parameters: { - // TODO(a11y): A11y smoke-test 위반 수정 후 disable 제거 - a11y: { disable: true }, - }, }; diff --git a/libs/react-ui/src/components/form-control/form-control.scss b/libs/react-ui/src/components/form-control/form-control.scss index 0ac24c0..4b74838 100644 --- a/libs/react-ui/src/components/form-control/form-control.scss +++ b/libs/react-ui/src/components/form-control/form-control.scss @@ -16,11 +16,11 @@ .ui-form-control--margin-dense { margin-top: var(--ds-spacing-sm); - margin-bottom: var(--ds-spacing-xxsm); + margin-bottom: var(--ds-spacing-xs); } .ui-form-control--margin-normal { - margin-top: var(--ds-spacing-mdl); + margin-top: var(--ds-spacing-lg); margin-bottom: var(--ds-spacing-sm); } diff --git a/libs/react-ui/src/components/form-helper-text/FormHelperText.stories.tsx b/libs/react-ui/src/components/form-helper-text/FormHelperText.stories.tsx index e998fc9..ceed942 100644 --- a/libs/react-ui/src/components/form-helper-text/FormHelperText.stories.tsx +++ b/libs/react-ui/src/components/form-helper-text/FormHelperText.stories.tsx @@ -8,8 +8,6 @@ const meta = { tags: ['autodocs'], parameters: { layout: 'centered', - // TODO(a11y): 위반 수정 후 disable 제거 - a11y: { disable: true }, }, args: { children: 'Helper text', @@ -90,11 +88,11 @@ export const DisabledWithError: Story = { export const Empty: Story = { render: () => (
-

+

Space character renders a zero-width space (preserves layout height):

-

+

Normal helper text for comparison:

Normal helper text @@ -122,6 +120,9 @@ export const A11y: Story = {
{/* id로 연결해 aria-describedby에서 참조 */}
+
+
), - parameters: { - // TODO(a11y): A11y smoke-test 위반 수정 후 disable 제거 - a11y: { disable: true }, - }, }; diff --git a/libs/react-ui/src/components/form-helper-text/form-helper-text.scss b/libs/react-ui/src/components/form-helper-text/form-helper-text.scss index b914f03..650afa8 100644 --- a/libs/react-ui/src/components/form-helper-text/form-helper-text.scss +++ b/libs/react-ui/src/components/form-helper-text/form-helper-text.scss @@ -1,5 +1,5 @@ .ui-form-helper-text { - margin: var(--ds-spacing-xxsm) 0 0; + margin: var(--ds-spacing-xs) 0 0; color: var(--ds-text-light); text-align: left; @@ -7,15 +7,15 @@ font-size: var(--ds-caption-small-font-size); font-weight: var(--ds-caption-small-font-weight); letter-spacing: var(--ds-caption-small-letter-spacing); - line-height: calc(var(--ds-caption-small-line-height) * 1px); + line-height: var(--ds-caption-small-line-height); } .ui-form-helper-text--size-sm { - margin-top: var(--ds-spacing-xxsm); + margin-top: var(--ds-spacing-xs); } .ui-form-helper-text--size-md { - margin-top: var(--ds-spacing-xxsm); + margin-top: var(--ds-spacing-xs); } .ui-form-helper-text--disabled { diff --git a/libs/react-ui/src/components/icon-button/IconButton.stories.tsx b/libs/react-ui/src/components/icon-button/IconButton.stories.tsx index 8053dbe..c268661 100644 --- a/libs/react-ui/src/components/icon-button/IconButton.stories.tsx +++ b/libs/react-ui/src/components/icon-button/IconButton.stories.tsx @@ -8,8 +8,6 @@ const meta = { tags: ['autodocs'], parameters: { layout: 'centered', - // TODO(a11y): 위반 수정 후 disable 제거 - a11y: { disable: true }, }, args: { size: 'md', @@ -154,19 +152,19 @@ export const Loading: Story = { render: () => (
- loading: null + loading: null
- loading: false + loading: false
- loading: true + loading: true diff --git a/libs/react-ui/src/components/icon-button/icon-button.scss b/libs/react-ui/src/components/icon-button/icon-button.scss index 05663c6..486df1d 100644 --- a/libs/react-ui/src/components/icon-button/icon-button.scss +++ b/libs/react-ui/src/components/icon-button/icon-button.scss @@ -85,7 +85,7 @@ .ui-icon-button--size-sm { --ui-icon-button-font-size: var(--ds-font-size-md); - --ui-icon-button-padding: var(--ds-spacing-xsm); + --ui-icon-button-padding: var(--ds-spacing-sm); } .ui-icon-button--size-md { @@ -95,23 +95,23 @@ .ui-icon-button--size-lg { --ui-icon-button-font-size: var(--ds-font-size-xxl); - --ui-icon-button-padding: var(--ds-spacing-sml); + --ui-icon-button-padding: var(--ds-spacing-md); } .ui-icon-button--edge-start { - margin-inline-start: calc(var(--ds-spacing-sml) * -1); + margin-inline-start: calc(var(--ds-spacing-md) * -1); } .ui-icon-button--size-sm.ui-icon-button--edge-start { - margin-inline-start: calc(var(--ds-spacing-xxsm) * -1); + margin-inline-start: calc(var(--ds-spacing-xs) * -1); } .ui-icon-button--edge-end { - margin-inline-end: calc(var(--ds-spacing-sml) * -1); + margin-inline-end: calc(var(--ds-spacing-md) * -1); } .ui-icon-button--size-sm.ui-icon-button--edge-end { - margin-inline-end: calc(var(--ds-spacing-xxsm) * -1); + margin-inline-end: calc(var(--ds-spacing-xs) * -1); } .ui-icon-button--color-primary { diff --git a/libs/react-ui/src/components/index.ts b/libs/react-ui/src/components/index.ts index 7a45cc7..c55e63e 100644 --- a/libs/react-ui/src/components/index.ts +++ b/libs/react-ui/src/components/index.ts @@ -11,6 +11,9 @@ export * from './input-base'; export * from './input-label'; export * from './menu-item'; export * from './plain-input'; +export * from './popover'; export * from './search-field'; +export * from './segment-control'; export * from './select'; +export * from './skip-link'; export * from './text-field'; diff --git a/libs/react-ui/src/components/input-base/input-base.scss b/libs/react-ui/src/components/input-base/input-base.scss index bcff269..7ce7f51 100644 --- a/libs/react-ui/src/components/input-base/input-base.scss +++ b/libs/react-ui/src/components/input-base/input-base.scss @@ -1,7 +1,7 @@ .ui-input-base { --ui-input-radius: var(--ds-radius-md); - --ui-input-padding-inline: var(--ds-spacing-mdl); - --ui-input-padding-block: var(--ds-spacing-sm); + --ui-input-padding-inline: var(--ds-spacing-lg); + --ui-input-padding-block: var(--ds-spacing-md); --ui-input-bg: transparent; --ui-input-fg: var(--ds-text-default); --ui-input-placeholder: var(--ds-text-placeholder); @@ -42,14 +42,14 @@ } .ui-input-base--size-sm { - --ui-input-padding-inline: var(--ds-spacing-sml); - --ui-input-padding-block: var(--ds-spacing-xsm); + --ui-input-padding-inline: var(--ds-spacing-md); + --ui-input-padding-block: var(--ds-spacing-sm); --ui-input-min-height: 40px; } .ui-input-base--size-md { - --ui-input-padding-inline: var(--ds-spacing-mdl); - --ui-input-padding-block: var(--ds-spacing-sm); + --ui-input-padding-inline: var(--ds-spacing-lg); + --ui-input-padding-block: var(--ds-spacing-md); --ui-input-min-height: 52px; } @@ -75,7 +75,7 @@ font-size: var(--ds-body-medium-font-size); font-weight: var(--ds-body-medium-font-weight); letter-spacing: var(--ds-body-medium-letter-spacing); - line-height: calc(var(--ds-body-medium-line-height) * 1px); + line-height: var(--ds-body-medium-line-height); } .ui-input-base__input--size-sm { @@ -83,12 +83,12 @@ font-size: var(--ds-body-small-font-size); font-weight: var(--ds-body-small-font-weight); letter-spacing: var(--ds-body-small-letter-spacing); - line-height: calc(var(--ds-body-small-line-height) * 1px); + line-height: var(--ds-body-small-line-height); } .ui-input-base__input--hidden-label { - padding-block-start: var(--ds-spacing-xsm); - padding-block-end: var(--ds-spacing-xsm); + padding-block-start: var(--ds-spacing-md); + padding-block-end: var(--ds-spacing-md); } .ui-input-base__input::placeholder { @@ -109,19 +109,19 @@ } .ui-input-base__start-adornment { - margin-inline-start: var(--ds-spacing-mdl); + margin-inline-start: var(--ds-spacing-lg); } .ui-input-base__end-adornment { - margin-inline-end: var(--ds-spacing-mdl); + margin-inline-end: var(--ds-spacing-lg); } .ui-input-base--adorned-start .ui-input-base__input { - padding-inline-start: var(--ds-spacing-sm); + padding-inline-start: var(--ds-spacing-md); } .ui-input-base--adorned-end .ui-input-base__input { - padding-inline-end: var(--ds-spacing-sm); + padding-inline-end: var(--ds-spacing-md); } .ui-input-base--multiline { diff --git a/libs/react-ui/src/components/input-label/InputLabel.stories.tsx b/libs/react-ui/src/components/input-label/InputLabel.stories.tsx index ff01ff7..1d4b3e1 100644 --- a/libs/react-ui/src/components/input-label/InputLabel.stories.tsx +++ b/libs/react-ui/src/components/input-label/InputLabel.stories.tsx @@ -8,8 +8,6 @@ const meta = { tags: ['autodocs'], parameters: { layout: 'centered', - // TODO(a11y): 위반 수정 후 disable 제거 - a11y: { disable: true }, }, args: { children: 'Email address', @@ -191,14 +189,10 @@ export const A11y: Story = { aria-describedby="a11y-password-error" style={{ display: 'block', marginTop: '4px' }} /> -

+

Password must be at least 8 characters.

), - parameters: { - // TODO(a11y): A11y smoke-test 위반 수정 후 disable 제거 - a11y: { disable: true }, - }, }; diff --git a/libs/react-ui/src/components/input-label/input-label.scss b/libs/react-ui/src/components/input-label/input-label.scss index 4135673..ae42e1d 100644 --- a/libs/react-ui/src/components/input-label/input-label.scss +++ b/libs/react-ui/src/components/input-label/input-label.scss @@ -3,7 +3,7 @@ align-items: center; align-self: flex-start; max-width: 100%; - margin: 0 0 var(--ds-spacing-xxsm); + margin: 0 0 var(--ds-spacing-xs); color: var(--ds-text-default); pointer-events: auto; cursor: pointer; @@ -13,7 +13,7 @@ font-size: var(--ds-body-small-strong-font-size); font-weight: var(--ds-body-small-strong-font-weight); letter-spacing: var(--ds-body-small-strong-letter-spacing); - line-height: calc(var(--ds-body-small-strong-line-height) * 1px); + line-height: var(--ds-body-small-strong-line-height); } .ui-input-label--form-control { @@ -25,7 +25,7 @@ font-size: var(--ds-body-tiny-strong-font-size); font-weight: var(--ds-body-tiny-strong-font-weight); letter-spacing: var(--ds-body-tiny-strong-letter-spacing); - line-height: calc(var(--ds-body-tiny-strong-line-height) * 1px); + line-height: var(--ds-body-tiny-strong-line-height); } .ui-input-label--size-md { @@ -33,7 +33,7 @@ font-size: var(--ds-body-small-strong-font-size); font-weight: var(--ds-body-small-strong-font-weight); letter-spacing: var(--ds-body-small-strong-letter-spacing); - line-height: calc(var(--ds-body-small-strong-line-height) * 1px); + line-height: var(--ds-body-small-strong-line-height); } .ui-input-label--focused.ui-input-label--color-primary { diff --git a/libs/react-ui/src/components/popover/Popover.constants.ts b/libs/react-ui/src/components/popover/Popover.constants.ts new file mode 100644 index 0000000..8541126 --- /dev/null +++ b/libs/react-ui/src/components/popover/Popover.constants.ts @@ -0,0 +1,3 @@ +export const popoverClasses = { + panel: 'ui-popover-panel', +} as const; diff --git a/libs/react-ui/src/components/popover/Popover.stories.tsx b/libs/react-ui/src/components/popover/Popover.stories.tsx new file mode 100644 index 0000000..db97c2b --- /dev/null +++ b/libs/react-ui/src/components/popover/Popover.stories.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Popover } from './Popover'; +import { PopoverPanel } from './PopoverPanel'; +import { PopoverTrigger } from './PopoverTrigger'; + +const meta = { + title: 'Components/Popover', + component: Popover, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, + args: { + defaultOpen: false, + children: null, + }, + argTypes: { + defaultOpen: { control: 'boolean' }, + open: { control: false }, + onOpenChange: { action: 'open-changed' }, + children: { control: false }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const wrapperStyle = { + position: 'relative' as const, + display: 'inline-block', +}; + +const panelStyle = { + position: 'absolute' as const, + insetBlockStart: '100%', + insetInlineStart: 0, + marginBlockStart: '4px', + minInlineSize: '240px', +}; + +export const Playground: Story = { + render: (args) => ( +
+ + + + + + Popover content. Tab으로 진입하거나 바깥을 클릭하거나 Escape로 닫힙니다. + + +
+ ), +}; + +export const Default: Story = { + render: () => ( +
+ + + + + +
+ Popover title + 설명을 노출하는 surface입니다. +
+
+
+
+ ), +}; + +export const Controlled: Story = { + render: () => { + const [open, setOpen] = useState(false); + return ( +
+
+ + + + + + controlled mode (open={String(open)}) + + +
+ +
+ ); + }, +}; + +export const WithMenuRole: Story = { + render: () => ( +
+ + + + + + + + + +
+ ), +}; + +export const LongContent: Story = { + render: () => ( +
+ + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse pretium tempor sapien + eget volutpat. Phasellus tincidunt eros eu erat fermentum, sed scelerisque purus + sollicitudin. + + +
+ ), +}; diff --git a/libs/react-ui/src/components/popover/Popover.test.tsx b/libs/react-ui/src/components/popover/Popover.test.tsx new file mode 100644 index 0000000..6553f60 --- /dev/null +++ b/libs/react-ui/src/components/popover/Popover.test.tsx @@ -0,0 +1,249 @@ +import { useRef } from 'react'; + +import { screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { createRenderer } from '../../../test'; + +import { Popover } from './Popover'; +import { popoverClasses } from './Popover.constants'; +import { PopoverPanel } from './PopoverPanel'; +import { PopoverTrigger } from './PopoverTrigger'; + +describe('', () => { + const { render } = createRenderer(); + + describe('trigger', () => { + it('자식 element에 aria-haspopup="dialog"가 기본으로 주입되어야 한다', () => { + render( + + + + + Content + , + ); + + const trigger = screen.getByRole('button', { name: 'Open' }); + + expect(trigger).toHaveAttribute('aria-haspopup', 'dialog'); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(trigger).not.toHaveAttribute('aria-controls'); + }); + + it('자식이 단일 element가 아니면 throw해야 한다', () => { + const originalError = console.error; + console.error = vi.fn(); + expect(() => + render( + + {'text' as unknown as React.ReactElement} + , + ), + ).toThrow(/single React element child/); + console.error = originalError; + }); + + it('컨슈머의 ref와 트리거 ref가 함께 채워져야 한다', () => { + const consumerRef = { current: null as HTMLButtonElement | null }; + const Harness = () => ( + + + + + + ); + + render(); + + expect(consumerRef.current).toBeInstanceOf(HTMLButtonElement); + }); + }); + + describe('open state', () => { + it('uncontrolled: defaultOpen이면 panel을 즉시 렌더링해야 한다', () => { + render( + + + + + Content + , + ); + + expect(screen.getByRole('dialog')).toHaveClass(popoverClasses.panel); + expect(screen.getByRole('button', { name: 'Open' })).toHaveAttribute('aria-expanded', 'true'); + }); + + it('uncontrolled: 트리거 클릭으로 토글되어야 한다', async () => { + const { user } = render( + + + + + Content + , + ); + + const trigger = screen.getByRole('button', { name: 'Open' }); + + await user.click(trigger); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + + await user.click(trigger); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('controlled: open prop으로 열림 상태를 강제해야 한다', () => { + const { setProps } = render( + + + + + Content + , + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + setProps({ open: true }); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('controlled: 클릭 시 onOpenChange가 호출되어야 한다', async () => { + const onOpenChange = vi.fn(); + const { user } = render( + + + + + Content + , + ); + + await user.click(screen.getByRole('button', { name: 'Open' })); + + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + }); + + describe('panel', () => { + it('open=false면 렌더링되지 않아야 한다', () => { + render( + + + + + Content + , + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('asDialog=false면 role="dialog"를 적용하지 않아야 한다', () => { + render( + + + + + + Content + + , + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByTestId('panel')).toHaveClass(popoverClasses.panel); + }); + + it('panel 바깥 클릭 시 닫혀야 한다', async () => { + const { user } = render( + + + + + Content + + , + ); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Outside' })); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('Escape 키로 닫혀야 한다', async () => { + const { user } = render( + + + + + Content + , + ); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('panel 내부 클릭으로는 닫히지 않아야 한다', async () => { + const { user } = render( + + + + + + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Inside' })); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('className을 root에 병합해야 한다', () => { + render( + + + + + Content + , + ); + + const panel = screen.getByRole('dialog'); + + expect(panel).toHaveClass(popoverClasses.panel); + expect(panel).toHaveClass('custom-panel'); + }); + }); + + describe('context guard', () => { + it('Popover 바깥의 PopoverTrigger는 throw해야 한다', () => { + const originalError = console.error; + console.error = vi.fn(); + const Standalone = () => { + useRef(null); + return ( + + + + ); + }; + expect(() => render()).toThrow(/within /); + console.error = originalError; + }); + }); +}); diff --git a/libs/react-ui/src/components/popover/Popover.tsx b/libs/react-ui/src/components/popover/Popover.tsx new file mode 100644 index 0000000..ca85eae --- /dev/null +++ b/libs/react-ui/src/components/popover/Popover.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useCallback, useId, useMemo, useRef, useState } from 'react'; + +import type { PopoverProps } from './Popover.types'; +import { PopoverContext } from './PopoverContext'; + +export const Popover = ({ + children, + open: controlledOpen, + defaultOpen = false, + onOpenChange, +}: PopoverProps) => { + const [internalOpen, setInternalOpen] = useState(defaultOpen); + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + + const setOpen = useCallback( + (next: boolean) => { + if (!isControlled) setInternalOpen(next); + onOpenChange?.(next); + }, + [isControlled, onOpenChange], + ); + + const triggerRef = useRef(null); + const panelRef = useRef(null); + const panelId = useId(); + const triggerId = useId(); + + const value = useMemo( + () => ({ open, setOpen, triggerRef, panelRef, panelId, triggerId }), + [open, setOpen, panelId, triggerId], + ); + + return {children}; +}; + +Popover.displayName = 'Popover'; diff --git a/libs/react-ui/src/components/popover/Popover.types.ts b/libs/react-ui/src/components/popover/Popover.types.ts new file mode 100644 index 0000000..ca3a5ec --- /dev/null +++ b/libs/react-ui/src/components/popover/Popover.types.ts @@ -0,0 +1,21 @@ +import type { HTMLAttributes, ReactElement, ReactNode } from 'react'; + +export type PopoverProps = { + children: ReactNode; + /** controlled open state */ + open?: boolean; + /** uncontrolled 초기값 */ + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +export type PopoverTriggerProps = { + /** 단일 React element. 클릭 핸들러와 aria 속성이 자동 주입된다. */ + children: ReactElement; +}; + +export type PopoverPanelProps = HTMLAttributes & { + children: ReactNode; + /** role="dialog"를 지정할지. false면 컨슈머가 role을 직접 설정. 기본 true. */ + asDialog?: boolean; +}; diff --git a/libs/react-ui/src/components/popover/PopoverContext.ts b/libs/react-ui/src/components/popover/PopoverContext.ts new file mode 100644 index 0000000..5c3052c --- /dev/null +++ b/libs/react-ui/src/components/popover/PopoverContext.ts @@ -0,0 +1,20 @@ +import { createContext, type RefObject, useContext } from 'react'; + +export type PopoverContextValue = { + open: boolean; + setOpen: (open: boolean) => void; + triggerRef: RefObject; + panelRef: RefObject; + panelId: string; + triggerId: string; +}; + +export const PopoverContext = createContext(null); + +export const usePopoverContext = (component: string): PopoverContextValue => { + const ctx = useContext(PopoverContext); + if (!ctx) { + throw new Error(`${component} must be used within `); + } + return ctx; +}; diff --git a/libs/react-ui/src/components/popover/PopoverPanel.tsx b/libs/react-ui/src/components/popover/PopoverPanel.tsx new file mode 100644 index 0000000..23f4ff9 --- /dev/null +++ b/libs/react-ui/src/components/popover/PopoverPanel.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useEffect } from 'react'; + +import { cx } from '@berrypjh/ui-core'; + +import { popoverClasses } from './Popover.constants'; +import type { PopoverPanelProps } from './Popover.types'; +import { usePopoverContext } from './PopoverContext'; + +export const PopoverPanel = ({ + children, + asDialog = true, + className, + ...rest +}: PopoverPanelProps) => { + const { open, setOpen, triggerRef, panelRef, panelId } = usePopoverContext('PopoverPanel'); + + useEffect(() => { + if (!open) return undefined; + + const onMouseDown = (event: MouseEvent) => { + const target = event.target as Node | null; + if (!target) return; + if (panelRef.current?.contains(target)) return; + if (triggerRef.current?.contains(target)) return; + setOpen(false); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + + document.addEventListener('mousedown', onMouseDown); + document.addEventListener('keydown', onKeyDown); + + return () => { + document.removeEventListener('mousedown', onMouseDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open, setOpen, triggerRef, panelRef]); + + if (!open) return null; + + return ( +
+ {children} +
+ ); +}; + +PopoverPanel.displayName = 'PopoverPanel'; diff --git a/libs/react-ui/src/components/popover/PopoverTrigger.tsx b/libs/react-ui/src/components/popover/PopoverTrigger.tsx new file mode 100644 index 0000000..06346ed --- /dev/null +++ b/libs/react-ui/src/components/popover/PopoverTrigger.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { cloneElement, isValidElement, type MouseEvent, type ReactElement, type Ref } from 'react'; + +import { assignRef } from '../../utils'; + +import type { PopoverTriggerProps } from './Popover.types'; +import { usePopoverContext } from './PopoverContext'; + +type TriggerChildProps = { + id?: string; + ref?: Ref; + onClick?: (event: MouseEvent) => void; + 'aria-haspopup'?: 'dialog' | 'menu' | 'listbox' | 'tree' | 'grid' | true | false; +}; + +export const PopoverTrigger = ({ children }: PopoverTriggerProps) => { + const { open, setOpen, triggerRef, panelId, triggerId } = usePopoverContext('PopoverTrigger'); + + if (!isValidElement(children)) { + throw new Error('PopoverTrigger requires a single React element child'); + } + + const child = children as ReactElement; + const childProps = child.props; + const childRef = childProps.ref; + + return cloneElement(child, { + id: childProps.id ?? triggerId, + ref: (node: HTMLElement | null) => { + triggerRef.current = node; + assignRef(childRef, node); + }, + onClick: (event: MouseEvent) => { + childProps.onClick?.(event); + if (!event.defaultPrevented) setOpen(!open); + }, + 'aria-expanded': open, + 'aria-controls': open ? panelId : undefined, + 'aria-haspopup': childProps['aria-haspopup'] ?? 'dialog', + } as Partial & Record); +}; + +PopoverTrigger.displayName = 'PopoverTrigger'; diff --git a/libs/react-ui/src/components/popover/index.ts b/libs/react-ui/src/components/popover/index.ts new file mode 100644 index 0000000..dff378f --- /dev/null +++ b/libs/react-ui/src/components/popover/index.ts @@ -0,0 +1,5 @@ +export { Popover } from './Popover'; +export { popoverClasses } from './Popover.constants'; +export type { PopoverPanelProps, PopoverProps, PopoverTriggerProps } from './Popover.types'; +export { PopoverPanel } from './PopoverPanel'; +export { PopoverTrigger } from './PopoverTrigger'; diff --git a/libs/react-ui/src/components/popover/popover.scss b/libs/react-ui/src/components/popover/popover.scss new file mode 100644 index 0000000..ed09704 --- /dev/null +++ b/libs/react-ui/src/components/popover/popover.scss @@ -0,0 +1,16 @@ +.ui-popover-panel { + background-color: var(--ds-background-surface); + border: var(--ds-border-primary-width) solid var(--ds-stroke-default); + border-radius: var(--ds-radius-md); + padding: var(--ds-spacing-md); + color: var(--ds-text-default); + box-shadow: + var(--ds-shadow-lg-1-offset-x) var(--ds-shadow-lg-1-offset-y) var(--ds-shadow-lg-1-blur) + var(--ds-shadow-lg-1-spread) var(--ds-shadow-lg-1-color), + var(--ds-shadow-lg-2-offset-x) var(--ds-shadow-lg-2-offset-y) var(--ds-shadow-lg-2-blur) + var(--ds-shadow-lg-2-spread) var(--ds-shadow-lg-2-color); +} + +.ui-popover-panel:focus-visible { + outline: none; +} diff --git a/libs/react-ui/src/components/search-field/search-field.scss b/libs/react-ui/src/components/search-field/search-field.scss index aee6ff8..53cd960 100644 --- a/libs/react-ui/src/components/search-field/search-field.scss +++ b/libs/react-ui/src/components/search-field/search-field.scss @@ -43,7 +43,7 @@ right: 0; z-index: 10; margin: 0; - padding: var(--ds-spacing-xxsm) 0; + padding: var(--ds-spacing-xs) 0; list-style: none; border: var(--ds-border-primary-width) solid var(--ds-neutral-ne300); border-radius: var(--ds-radius-xl); @@ -61,7 +61,7 @@ align-items: center; gap: var(--ds-spacing-sm); width: 100%; - padding: var(--ds-spacing-sm) var(--ds-spacing-mdl); + padding: var(--ds-spacing-sm) var(--ds-spacing-lg); border: 0; background: transparent; color: var(--ds-text-default); diff --git a/libs/react-ui/src/components/segment-control/SegmentControl.constants.ts b/libs/react-ui/src/components/segment-control/SegmentControl.constants.ts new file mode 100644 index 0000000..3deb3da --- /dev/null +++ b/libs/react-ui/src/components/segment-control/SegmentControl.constants.ts @@ -0,0 +1,5 @@ +export const segmentControlClasses = { + root: 'ui-segment-control', + option: 'ui-segment-control__option', + active: 'is-active', +} as const; diff --git a/libs/react-ui/src/components/segment-control/SegmentControl.stories.tsx b/libs/react-ui/src/components/segment-control/SegmentControl.stories.tsx new file mode 100644 index 0000000..608290b --- /dev/null +++ b/libs/react-ui/src/components/segment-control/SegmentControl.stories.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { SegmentControl } from './SegmentControl'; +import type { SegmentControlProps, SegmentOption } from './SegmentControl.types'; + +type View = 'list' | 'grid' | 'gallery'; + +const viewOptions: readonly SegmentOption[] = [ + { value: 'list', label: 'List' }, + { value: 'grid', label: 'Grid' }, + { value: 'gallery', label: 'Gallery' }, +]; + +const meta = { + title: 'Components/SegmentControl', + component: SegmentControl, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, + args: { + 'aria-label': 'View mode', + options: viewOptions, + value: 'list' as View, + onChange: () => undefined, + }, + argTypes: { + value: { control: 'select', options: ['list', 'grid', 'gallery'] }, + options: { control: false }, + onChange: { action: 'changed' }, + className: { control: false }, + ref: { control: false }, + }, +} satisfies Meta>; + +export default meta; + +type Story = StoryObj; + +const columnStyle = { + display: 'grid', + gap: '16px', + minWidth: '320px', +}; + +export const Playground: Story = { + render: (rawArgs) => { + const args = rawArgs as SegmentControlProps; + const [value, setValue] = useState(args.value); + return ( + + {...args} + value={value} + onChange={(next) => { + setValue(next); + args.onChange?.(next); + }} + /> + ); + }, +}; + +export const Default: Story = { + render: () => { + const [value, setValue] = useState('list'); + return ( + + aria-label="View mode" + options={viewOptions} + value={value} + onChange={setValue} + /> + ); + }, +}; + +export const TwoOptions: Story = { + render: () => { + type Mode = 'on' | 'off'; + const [value, setValue] = useState('on'); + return ( + + aria-label="Toggle" + options={[ + { value: 'on', label: 'On' }, + { value: 'off', label: 'Off' }, + ]} + value={value} + onChange={setValue} + /> + ); + }, +}; + +export const Disabled: Story = { + render: () => { + const [value, setValue] = useState('list'); + return ( + + aria-label="View mode with disabled option" + options={[ + { value: 'list', label: 'List' }, + { value: 'grid', label: 'Grid', disabled: true }, + { value: 'gallery', label: 'Gallery' }, + ]} + value={value} + onChange={setValue} + /> + ); + }, +}; + +export const WithIcons: Story = { + render: () => { + type Align = 'left' | 'center' | 'right'; + const [value, setValue] = useState('left'); + return ( + + aria-label="Alignment" + options={[ + { value: 'left', label: , ariaLabel: 'Align left' }, + { value: 'center', label: , ariaLabel: 'Align center' }, + { value: 'right', label: , ariaLabel: 'Align right' }, + ]} + value={value} + onChange={setValue} + /> + ); + }, +}; + +export const Stacked: Story = { + render: () => { + const [view, setView] = useState('list'); + type Sort = 'name' | 'date'; + const [sort, setSort] = useState('name'); + return ( +
+ + aria-label="View mode" + options={viewOptions} + value={view} + onChange={setView} + /> + + aria-label="Sort" + options={[ + { value: 'name', label: 'Name' }, + { value: 'date', label: 'Date' }, + ]} + value={sort} + onChange={setSort} + /> +
+ ); + }, +}; diff --git a/libs/react-ui/src/components/segment-control/SegmentControl.test.tsx b/libs/react-ui/src/components/segment-control/SegmentControl.test.tsx new file mode 100644 index 0000000..67eb727 --- /dev/null +++ b/libs/react-ui/src/components/segment-control/SegmentControl.test.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; + +import { screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { createRenderer, describeConformance } from '../../../test'; + +import { SegmentControl } from './SegmentControl'; +import { segmentControlClasses } from './SegmentControl.constants'; +import type { SegmentOption } from './SegmentControl.types'; + +type View = 'list' | 'grid'; + +const baseOptions: readonly SegmentOption[] = [ + { value: 'list', label: 'List' }, + { value: 'grid', label: 'Grid' }, +]; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance( + value="list" onChange={() => undefined} options={baseOptions} />, + () => ({ + render, + classes: segmentControlClasses, + refInstanceof: HTMLDivElement, + skip: ['polymorphicProp'], + }), + ); + + describe('root', () => { + it('role="group"으로 렌더링하고 옵션을 button으로 표시해야 한다', () => { + render( + + value="list" + onChange={() => undefined} + options={baseOptions} + aria-label="View mode" + />, + ); + + const group = screen.getByRole('group', { name: 'View mode' }); + + expect(group).toHaveClass(segmentControlClasses.root); + expect(screen.getAllByRole('button')).toHaveLength(2); + }); + + it('선택된 옵션에 aria-pressed=true와 active 클래스를 적용해야 한다', () => { + render( + value="grid" onChange={() => undefined} options={baseOptions} />, + ); + + const list = screen.getByRole('button', { name: 'List' }); + const grid = screen.getByRole('button', { name: 'Grid' }); + + expect(list).toHaveAttribute('aria-pressed', 'false'); + expect(grid).toHaveAttribute('aria-pressed', 'true'); + expect(grid).toHaveClass(segmentControlClasses.active); + expect(list).not.toHaveClass(segmentControlClasses.active); + }); + }); + + describe('interaction', () => { + it('옵션 클릭 시 onChange가 호출되어야 한다', async () => { + const onChange = vi.fn(); + const { user } = render( + value="list" onChange={onChange} options={baseOptions} />, + ); + + await user.click(screen.getByRole('button', { name: 'Grid' })); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith('grid'); + }); + + it('controlled value 변경에 따라 active 옵션이 갱신되어야 한다', () => { + const Controlled = () => { + const [value, setValue] = useState('list'); + return ( + <> + value={value} onChange={setValue} options={baseOptions} /> + + + ); + }; + + const { user } = render(); + + expect(screen.getByRole('button', { name: 'List' })).toHaveAttribute('aria-pressed', 'true'); + + return user.click(screen.getByRole('button', { name: 'set-grid' })).then(() => { + expect(screen.getByRole('button', { name: 'Grid' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + }); + }); + }); + + describe('prop: disabled', () => { + it('disabled 옵션을 비활성화해야 한다', () => { + const options: readonly SegmentOption[] = [ + { value: 'list', label: 'List' }, + { value: 'grid', label: 'Grid', disabled: true }, + ]; + render( value="list" onChange={() => undefined} options={options} />); + + expect(screen.getByRole('button', { name: 'Grid' })).toBeDisabled(); + }); + }); + + describe('prop: ariaLabel', () => { + it('아이콘 전용 옵션의 aria-label을 button에 전달해야 한다', () => { + const options: readonly SegmentOption[] = [ + { value: 'list', label: , ariaLabel: 'List view' }, + { value: 'grid', label: , ariaLabel: 'Grid view' }, + ]; + render( value="list" onChange={() => undefined} options={options} />); + + expect(screen.getByRole('button', { name: 'List view' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Grid view' })).toBeInTheDocument(); + }); + }); +}); diff --git a/libs/react-ui/src/components/segment-control/SegmentControl.tsx b/libs/react-ui/src/components/segment-control/SegmentControl.tsx new file mode 100644 index 0000000..631f835 --- /dev/null +++ b/libs/react-ui/src/components/segment-control/SegmentControl.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { cx } from '@berrypjh/ui-core'; + +import { segmentControlClasses } from './SegmentControl.constants'; +import type { SegmentControlProps } from './SegmentControl.types'; + +export const SegmentControl = ({ + value, + onChange, + options, + className, + ref, + ...rest +}: SegmentControlProps) => ( +
+ {options.map((opt) => { + const isActive = opt.value === value; + return ( + + ); + })} +
+); + +SegmentControl.displayName = 'SegmentControl'; diff --git a/libs/react-ui/src/components/segment-control/SegmentControl.types.ts b/libs/react-ui/src/components/segment-control/SegmentControl.types.ts new file mode 100644 index 0000000..157cdb9 --- /dev/null +++ b/libs/react-ui/src/components/segment-control/SegmentControl.types.ts @@ -0,0 +1,23 @@ +import type { ComponentPropsWithRef, ReactNode } from 'react'; + +export type SegmentOption = { + value: T; + label: ReactNode; + /** 아이콘만 표시할 때 사용하는 aria-label. */ + ariaLabel?: string; + /** 옵션별 추가 클래스. 폰트 크기 등 옵션 고유 표시에 사용. */ + className?: string; + disabled?: boolean; +}; + +export type SegmentControlOwnProps = { + value: T; + onChange: (value: T) => void; + options: readonly SegmentOption[]; +}; + +export type SegmentControlProps = Omit< + ComponentPropsWithRef<'div'>, + keyof SegmentControlOwnProps | 'children' +> & + SegmentControlOwnProps; diff --git a/libs/react-ui/src/components/segment-control/index.ts b/libs/react-ui/src/components/segment-control/index.ts new file mode 100644 index 0000000..11a3621 --- /dev/null +++ b/libs/react-ui/src/components/segment-control/index.ts @@ -0,0 +1,3 @@ +export { SegmentControl } from './SegmentControl'; +export { segmentControlClasses } from './SegmentControl.constants'; +export type { SegmentControlProps, SegmentOption } from './SegmentControl.types'; diff --git a/libs/react-ui/src/components/segment-control/segment-control.scss b/libs/react-ui/src/components/segment-control/segment-control.scss new file mode 100644 index 0000000..886be31 --- /dev/null +++ b/libs/react-ui/src/components/segment-control/segment-control.scss @@ -0,0 +1,49 @@ +.ui-segment-control { + display: inline-flex; + inline-size: 100%; + gap: var(--ds-spacing-xs); + padding: var(--ds-spacing-2xs); + border-radius: var(--ds-radius-xs); + background-color: rgb(var(--ds-background-grey-rgb) / 0.15); + font-family: var(--ds-body-small-strong-font-family); + font-size: var(--ds-body-small-strong-font-size); + font-weight: var(--ds-body-small-strong-font-weight); + letter-spacing: var(--ds-body-small-strong-letter-spacing); +} + +.ui-segment-control__option { + flex: 1 1 0; + min-inline-size: 0; + padding-block: var(--ds-spacing-xs); + padding-inline: var(--ds-spacing-sm); + border: 0; + border-radius: var(--ds-radius-xs); + background: transparent; + color: var(--ds-text-light); + cursor: pointer; + text-align: center; + font: inherit; + transition: + background-color 140ms ease, + color 140ms ease; +} + +.ui-segment-control__option:hover:not(.is-active):not(:disabled) { + background-color: rgb(var(--ds-background-grey-rgb) / 0.15); + color: var(--ds-text-default); +} + +.ui-segment-control__option:focus-visible { + outline: none; + box-shadow: 0 0 0 var(--ds-semantic-border-focus) var(--ds-stroke-primary); +} + +.ui-segment-control__option:disabled { + color: var(--ds-text-disable); + cursor: not-allowed; +} + +.ui-segment-control__option.is-active { + background-color: var(--ds-background-primary); + color: var(--ds-text-contrast-text); +} diff --git a/libs/react-ui/src/components/select/Select.stories.tsx b/libs/react-ui/src/components/select/Select.stories.tsx index c50733a..6f0d1cd 100644 --- a/libs/react-ui/src/components/select/Select.stories.tsx +++ b/libs/react-ui/src/components/select/Select.stories.tsx @@ -10,10 +10,9 @@ const meta = { tags: ['autodocs'], parameters: { layout: 'centered', - // TODO(a11y): 위반 수정 후 disable 제거 - a11y: { disable: true }, }, args: { + 'aria-label': 'Select an option', variant: 'boxed', size: 'md', color: 'primary', @@ -80,7 +79,7 @@ export const Playground: Story = { export const Default: Story = { render: () => ( - Daily Weekly Monthly @@ -92,15 +91,15 @@ export const Default: Story = { export const AllVariants: Story = { render: () => (
- Plain variant Option 2 - Filled variant Option 2 - Boxed variant Option 2 @@ -111,11 +110,11 @@ export const AllVariants: Story = { export const AllSizes: Story = { render: () => (
- Small (sm) Option 2 - Medium (md) Option 2 @@ -126,11 +125,11 @@ export const AllSizes: Story = { export const AllColors: Story = { render: () => (
- Primary color Option 2 - Secondary color Option 2 @@ -140,7 +139,7 @@ export const AllColors: Story = { export const Disabled: Story = { render: () => ( - Daily Weekly Monthly @@ -151,12 +150,12 @@ export const Disabled: Story = { export const Error: Story = { render: () => (
- Select a role Admin Member - Active Inactive @@ -166,7 +165,7 @@ export const Error: Story = { export const Required: Story = { render: () => ( - Select a timezone UTC Eastern (EST) @@ -178,10 +177,10 @@ export const Required: Story = { export const Multiple: Story = { render: () => (
-

+

Multiple selection — click items to toggle.

- React TypeScript GraphQL @@ -195,13 +194,13 @@ export const Multiple: Story = { export const WithPlaceholder: Story = { render: () => (
- South Korea United States Japan Germany - All categories Frontend Backend @@ -213,7 +212,7 @@ export const WithPlaceholder: Story = { export const WithDisabledOptions: Story = { render: () => ( - Active Pending @@ -233,6 +232,7 @@ export const WithRenderValue: Story = { render: () => (
+ - Frontend Backend Design @@ -281,7 +282,7 @@ export const FullWidth: Story = { export const WithLongText: Story = { render: () => (
- Short This is a very long option label that tests overflow handling @@ -289,6 +290,7 @@ export const WithLongText: Story = { Another option -

+

Please select a valid status.

), - parameters: { - // TODO(a11y): A11y smoke-test 위반 수정 후 disable 제거 - a11y: { disable: true }, - }, }; diff --git a/libs/react-ui/src/components/select/Select.tsx b/libs/react-ui/src/components/select/Select.tsx index 1f0048a..e5479b2 100644 --- a/libs/react-ui/src/components/select/Select.tsx +++ b/libs/react-ui/src/components/select/Select.tsx @@ -31,6 +31,8 @@ import { export const Select = ({ 'aria-describedby': ariaDescribedby, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, autoFocus = false, children, className, @@ -468,7 +470,8 @@ export const Select = ({ aria-expanded={open ? 'true' : 'false'} aria-haspopup="listbox" aria-invalid={resolvedError ? 'true' : undefined} - aria-labelledby={labelId} + aria-label={ariaLabel} + aria-labelledby={ariaLabelledby ?? labelId} aria-required={resolvedRequired ? 'true' : undefined} className={selectClasses.trigger} disabled={resolvedDisabled} diff --git a/libs/react-ui/src/components/select/select.scss b/libs/react-ui/src/components/select/select.scss index a405d6d..e081567 100644 --- a/libs/react-ui/src/components/select/select.scss +++ b/libs/react-ui/src/components/select/select.scss @@ -1,6 +1,6 @@ .ui-select { --ui-select-radius: var(--ds-radius-md); - --ui-select-padding-inline: var(--ds-spacing-mdl); + --ui-select-padding-inline: var(--ds-spacing-lg); --ui-select-padding-block: var(--ds-spacing-sm); --ui-select-min-height: 52px; --ui-select-fg: var(--ds-text-default); @@ -30,13 +30,13 @@ } .ui-select--size-sm { - --ui-select-padding-inline: var(--ds-spacing-sml); - --ui-select-padding-block: var(--ds-spacing-xsm); + --ui-select-padding-inline: var(--ds-spacing-md); + --ui-select-padding-block: var(--ds-spacing-sm); --ui-select-min-height: 40px; } .ui-select--size-md { - --ui-select-padding-inline: var(--ds-spacing-mdl); + --ui-select-padding-inline: var(--ds-spacing-lg); --ui-select-padding-block: var(--ds-spacing-sm); --ui-select-min-height: 52px; } @@ -74,7 +74,7 @@ font-size: var(--ds-body-medium-font-size); font-weight: var(--ds-body-medium-font-weight); letter-spacing: var(--ds-body-medium-letter-spacing); - line-height: calc(var(--ds-body-medium-line-height) * 1px); + line-height: var(--ds-body-medium-line-height); } .ui-select--size-sm .ui-select__trigger { @@ -82,7 +82,7 @@ font-size: var(--ds-body-small-font-size); font-weight: var(--ds-body-small-font-weight); letter-spacing: var(--ds-body-small-letter-spacing); - line-height: calc(var(--ds-body-small-line-height) * 1px); + line-height: var(--ds-body-small-line-height); } /* plain */ @@ -233,10 +233,10 @@ z-index: 10; inset-inline-start: 0; inset-inline-end: 0; - top: calc(100% + var(--ds-spacing-xxsm)); + top: calc(100% + var(--ds-spacing-xs)); max-height: 280px; overflow-y: auto; - padding: var(--ds-spacing-xxsm); + padding: var(--ds-spacing-xs); border: var(--ds-border-primary-width) solid var(--ui-select-panel-border); border-radius: var(--ds-radius-md); background-color: var(--ui-select-panel-bg); @@ -251,7 +251,7 @@ width: 100%; min-height: 40px; padding-block: var(--ds-spacing-sm); - padding-inline: var(--ds-spacing-mdl); + padding-inline: var(--ds-spacing-lg); border: 0; border-radius: var(--ds-radius-sm); background: transparent; @@ -263,7 +263,7 @@ font-size: var(--ds-body-small-font-size); font-weight: var(--ds-body-small-font-weight); letter-spacing: var(--ds-body-small-letter-spacing); - line-height: calc(var(--ds-body-small-line-height) * 1px); + line-height: var(--ds-body-small-line-height); } .ui-select__option:hover:not(:disabled), @@ -277,7 +277,7 @@ font-size: var(--ds-body-small-strong-font-size); font-weight: var(--ds-body-small-strong-font-weight); letter-spacing: var(--ds-body-small-strong-letter-spacing); - line-height: calc(var(--ds-body-small-strong-line-height) * 1px); + line-height: var(--ds-body-small-strong-line-height); } .ui-select__option:disabled { diff --git a/libs/react-ui/src/components/skip-link/SkipLink.constants.ts b/libs/react-ui/src/components/skip-link/SkipLink.constants.ts new file mode 100644 index 0000000..033700d --- /dev/null +++ b/libs/react-ui/src/components/skip-link/SkipLink.constants.ts @@ -0,0 +1,3 @@ +export const skipLinkClasses = { + root: 'ui-skip-link', +} as const; diff --git a/libs/react-ui/src/components/skip-link/SkipLink.stories.tsx b/libs/react-ui/src/components/skip-link/SkipLink.stories.tsx new file mode 100644 index 0000000..c5fbd58 --- /dev/null +++ b/libs/react-ui/src/components/skip-link/SkipLink.stories.tsx @@ -0,0 +1,100 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { SkipLink } from './SkipLink'; + +const meta = { + title: 'Components/SkipLink', + component: SkipLink, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, + args: { + targetId: 'main-content', + children: '본문으로 건너뛰기', + }, + argTypes: { + targetId: { control: 'text' }, + children: { control: 'text' }, + className: { control: false }, + style: { control: false }, + ref: { control: false }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const layoutStyle = { + display: 'grid', + gap: '12px', +}; + +const dummyMain = { + border: '1px dashed #ccc', + padding: '24px', + borderRadius: '4px', +} as const; + +export const Playground: Story = { + render: (args) => ( +
+
+ + Header (반복 영역) +
+
+ Main content. Tab 키로 페이지에 진입해 SkipLink가 노출되는지 확인합니다. +
+
+ ), +}; + +export const Default: Story = { + render: () => ( +
+
+ 본문으로 건너뛰기 + Header +
+
+ Main content +
+
+ ), +}; + +export const MultipleTargets: Story = { + render: () => ( +
+
+ 본문으로 건너뛰기 + 내비게이션으로 건너뛰기 + Header +
+ +
+ Main content +
+
+ ), +}; + +export const A11y: Story = { + render: () => ( +
+
+ + 본문으로 건너뛰기 + + Header +
+
+ Main content +
+
+ ), +}; diff --git a/libs/react-ui/src/components/skip-link/SkipLink.test.tsx b/libs/react-ui/src/components/skip-link/SkipLink.test.tsx new file mode 100644 index 0000000..fc643bb --- /dev/null +++ b/libs/react-ui/src/components/skip-link/SkipLink.test.tsx @@ -0,0 +1,47 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { createRenderer, describeConformance } from '../../../test'; + +import { SkipLink } from './SkipLink'; +import { skipLinkClasses } from './SkipLink.constants'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(본문으로 건너뛰기, () => ({ + render, + classes: skipLinkClasses, + refInstanceof: HTMLAnchorElement, + skip: ['polymorphicProp'], + })); + + describe('root', () => { + it('a 요소로 children을 렌더링해야 한다', () => { + render(본문으로 건너뛰기); + + const link = screen.getByRole('link', { name: '본문으로 건너뛰기' }); + + expect(link.tagName.toLowerCase()).toBe('a'); + expect(link).toHaveClass(skipLinkClasses.root); + }); + + it('targetId로 href를 구성해야 한다', () => { + render(Skip); + + expect(screen.getByRole('link', { name: 'Skip' })).toHaveAttribute('href', '#main-content'); + }); + + it('추가 props가 root에 전달되어야 한다', () => { + render( + + Skip + , + ); + + const link = screen.getByTestId('skip'); + + expect(link).toHaveAttribute('aria-label', '본문 이동'); + }); + }); +}); diff --git a/libs/react-ui/src/components/skip-link/SkipLink.tsx b/libs/react-ui/src/components/skip-link/SkipLink.tsx new file mode 100644 index 0000000..c96d91c --- /dev/null +++ b/libs/react-ui/src/components/skip-link/SkipLink.tsx @@ -0,0 +1,12 @@ +import { cx } from '@berrypjh/ui-core'; + +import { skipLinkClasses } from './SkipLink.constants'; +import type { SkipLinkProps } from './SkipLink.types'; + +export const SkipLink = ({ targetId, className, children, ref, ...rest }: SkipLinkProps) => ( + + {children} + +); + +SkipLink.displayName = 'SkipLink'; diff --git a/libs/react-ui/src/components/skip-link/SkipLink.types.ts b/libs/react-ui/src/components/skip-link/SkipLink.types.ts new file mode 100644 index 0000000..0daf433 --- /dev/null +++ b/libs/react-ui/src/components/skip-link/SkipLink.types.ts @@ -0,0 +1,10 @@ +import type { ComponentPropsWithRef, ReactNode } from 'react'; + +export type SkipLinkOwnProps = { + /** 포커스 이동 대상 element의 id (href는 `#{targetId}`로 구성). */ + targetId: string; + children: ReactNode; +}; + +export type SkipLinkProps = Omit, 'href' | 'children'> & + SkipLinkOwnProps; diff --git a/libs/react-ui/src/components/skip-link/index.ts b/libs/react-ui/src/components/skip-link/index.ts new file mode 100644 index 0000000..7c0343a --- /dev/null +++ b/libs/react-ui/src/components/skip-link/index.ts @@ -0,0 +1,3 @@ +export { SkipLink } from './SkipLink'; +export { skipLinkClasses } from './SkipLink.constants'; +export type { SkipLinkOwnProps, SkipLinkProps } from './SkipLink.types'; diff --git a/libs/react-ui/src/components/skip-link/skip-link.scss b/libs/react-ui/src/components/skip-link/skip-link.scss new file mode 100644 index 0000000..80d8b47 --- /dev/null +++ b/libs/react-ui/src/components/skip-link/skip-link.scss @@ -0,0 +1,50 @@ +.ui-skip-link { + --ui-skip-link-bg: var(--ds-background-primary); + --ui-skip-link-fg: var(--ds-text-contrast-text); + --ui-skip-link-offset: var(--ds-spacing-lg); + --ui-skip-link-radius: var(--ds-radius-md); + --ui-skip-link-padding-block: var(--ds-spacing-sm); + --ui-skip-link-padding-inline: var(--ds-spacing-lg); + + position: absolute; + inline-size: 1px; + block-size: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; + color: inherit; + text-decoration: none; +} + +.ui-skip-link:focus, +.ui-skip-link:focus-visible { + position: absolute; + z-index: 50; + inset-inline-start: var(--ui-skip-link-offset); + inset-block-start: var(--ui-skip-link-offset); + inline-size: auto; + block-size: auto; + padding: var(--ui-skip-link-padding-block) var(--ui-skip-link-padding-inline); + margin: 0; + overflow: visible; + clip: auto; + border-radius: var(--ui-skip-link-radius); + background-color: var(--ui-skip-link-bg); + color: var(--ui-skip-link-fg); + font-family: var(--ds-body-small-strong-font-family); + font-size: var(--ds-body-small-strong-font-size); + font-weight: var(--ds-body-small-strong-font-weight); + line-height: var(--ds-body-small-strong-line-height); + letter-spacing: var(--ds-body-small-strong-letter-spacing); + white-space: nowrap; + text-decoration: none; + box-shadow: + var(--ds-shadow-lg-1-offset-x) var(--ds-shadow-lg-1-offset-y) var(--ds-shadow-lg-1-blur) + var(--ds-shadow-lg-1-spread) var(--ds-shadow-lg-1-color), + var(--ds-shadow-lg-2-offset-x) var(--ds-shadow-lg-2-offset-y) var(--ds-shadow-lg-2-blur) + var(--ds-shadow-lg-2-spread) var(--ds-shadow-lg-2-color); + outline: none; +} diff --git a/libs/react-ui/src/styles.ts b/libs/react-ui/src/styles.ts index 12340ab..c37991c 100644 --- a/libs/react-ui/src/styles.ts +++ b/libs/react-ui/src/styles.ts @@ -17,6 +17,9 @@ import './components/form-helper-text/form-helper-text.scss'; import './components/icon-button/icon-button.scss'; import './components/input-label/input-label.scss'; import './components/plain-input/plain-input.scss'; +import './components/popover/popover.scss'; import './components/search-field/search-field.scss'; +import './components/segment-control/segment-control.scss'; import './components/select/select.scss'; +import './components/skip-link/skip-link.scss'; import './components/text-field/text-field.scss'; diff --git a/libs/ui-core/project.json b/libs/ui-core/project.json index dd6e609..4411e60 100644 --- a/libs/ui-core/project.json +++ b/libs/ui-core/project.json @@ -6,6 +6,7 @@ "build": { "executor": "nx:run-commands", "outputs": ["{projectRoot}/dist"], + "dependsOn": ["@berrypjh/design-tokens:build"], "options": { "commands": [ "nx run @berrypjh/ui-core:build-js",