components/ui/steel/heading.tsx /** * @title Heading * @description 페이지 제목과 내용을 여는 소제목을 정해진 크기와 스타일로 보여줘요. */ import * as React from 'react'; import { cn } from '@/lib/utils'; import { UNSAFE_inverseColorMap, UNSAFE_useSurface } from '@/components/ui/primitives'; // Atlassian @atlaskit/heading — heading.js 스펙 (font.heading.* 토큰, margin·letter-spacing·text-transform 리셋) const headingSizeMap = { xxlarge: 'ds-font-heading-xxlarge', xlarge: 'ds-font-heading-xlarge', large: 'ds-font-heading-large', medium: 'ds-font-heading-medium', small: 'ds-font-heading-small', xsmall: 'ds-font-heading-xsmall', xxsmall: 'ds-font-heading-xxsmall', } as const; /** size 만 줬을 때 쓰는 기본 태그 */ const sizeTagMap = { xxlarge: 'h1', xlarge: 'h1', large: 'h2', medium: 'h3', small: 'h4', xsmall: 'h5', xxsmall: 'h6', } as const; const headingColorMap = { 'color.text': 'text-ds-text', 'color.text.inverse': 'text-ds-text-inverse', 'color.text.warning.inverse': 'text-ds-text-warning-inverse', } as const; export type HeadingSize = keyof typeof headingSizeMap; export type HeadingColor = keyof typeof headingColorMap; export type HeadingLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; // --------------------------------------------------------------------------- // HeadingContextProvider — 중첩 깊이로 h1~h6 수준을 자동으로 정한다 // --------------------------------------------------------------------------- const HeadingLevelContext = React.createContext<number>(0); function useHeadingLevel() { return React.useContext(HeadingLevelContext); } export interface HeadingContextProviderProps { children: React.ReactNode; /** 수준을 직접 지정해요. 없으면 부모 수준 + 1 이에요. */ value?: HeadingLevel; } /** 안에 있는 Heading 의 수준(h1~h6)을 중첩 깊이에 맞춰 정해 줘요. */ function HeadingContextProvider({ children, value }: HeadingContextProviderProps) { const parent = useHeadingLevel(); return <HeadingLevelContext.Provider value={value || parent + 1}>{children}</HeadingLevelContext.Provider>; } // --------------------------------------------------------------------------- // Heading // --------------------------------------------------------------------------- export interface HeadingProps extends Omit<React.HTMLAttributes<HTMLHeadingElement>, 'color'> { /** 글자 스타일. 크기에 맞는 h1~h6 태그가 자동으로 붙어요. */ size: HeadingSize; /** 크기와 상관없이 렌더링할 태그 */ as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'div' | 'span'; /** 색 토큰. 굵은 배경의 Box 안에서는 자동으로 inverse 가 적용돼요. */ color?: HeadingColor; children: React.ReactNode; testId?: string; ref?: React.Ref<HTMLHeadingElement>; } function Heading({ size, as, color: colorProp, testId, className, children, ...props }: HeadingProps) { const level = useHeadingLevel(); const surface = UNSAFE_useSurface(); const inferred = level ? (level > 6 ? 'div' : (`h${level}` as const)) : sizeTagMap[size]; const Comp = (as || inferred) as React.ElementType; const needsAriaRole = (as || inferred) === 'div' && level > 0; const color: HeadingColor = colorProp ?? UNSAFE_inverseColorMap[surface] ?? 'color.text'; return ( <Comp data-slot="heading" data-size={size} data-testid={testId} role={needsAriaRole ? 'heading' : undefined} aria-level={needsAriaRole ? level : undefined} className={cn('my-0 tracking-normal normal-case', headingSizeMap[size], headingColorMap[color], className)} {...props} > {children} </Comp> ); } export { Heading, HeadingContextProvider, useHeadingLevel };