133 lines
9.5 KiB
TypeScript
133 lines
9.5 KiB
TypeScript
import { useEffect, useRef, useState } from "react"
|
|
import type { Meta, StoryObj } from "@storybook/react-vite"
|
|
import { typographyProperties, typographyReferences } from "./reference-data"
|
|
import { ReferencePage, ReferenceSection, useResolvedProperties } from "./reference"
|
|
|
|
type TypographyReference = (typeof typographyReferences)[number]
|
|
|
|
const weights = [
|
|
{ property: "--hm-input-font-weight", label: "Regular" },
|
|
{ property: "--hm-font-weight-medium", label: "Medium" },
|
|
{ property: "--hm-font-weight-bold", label: "Bold" },
|
|
]
|
|
// Left table: text styles. Right table: form controls. Together they cover every entry in typographyReferences.
|
|
const textLabels = ["Page Header · title", "Card · title", "Page Header · description", "Label"]
|
|
const textRows = typographyReferences.filter(item => textLabels.includes(item.label))
|
|
const controlRows = typographyReferences.filter(item => !textLabels.includes(item.label))
|
|
|
|
// Admin text colors (Web / Chart / OrgChart; not SW). Swatches, samples and HEX read the current tokens; no color is hardcoded here.
|
|
const textColorRoles = [
|
|
{ role: "Primary", name: "기본 텍스트", token: "--hm-sem-fg-primary", alias: "--foreground", surface: "--hm-sem-bg-surface", surfaceLabel: "흰 면", sample: "제목과 본문 한맥 Hanmac 0123", usage: "페이지·카드·Dialog 제목, 본문, 입력값, 테이블 셀, 차트 값 · OrgChart 기본 글자(--hm-orgchart-fg-primary 기본값)" },
|
|
{ role: "Secondary", name: "보조 텍스트", token: "--hm-sem-fg-secondary", alias: "--muted-foreground", surface: "--hm-sem-bg-surface", surfaceLabel: "흰 면", sample: "설명과 캡션 한맥 Hanmac 0123", usage: "설명, Label, 캡션·요약·빈 상태, 차트 축·범례 · OrgChart 직위·보조 정보(--hm-orgchart-fg-secondary 기본값)" },
|
|
{ role: "Placeholder", name: "입력 안내", token: "--hm-sem-fg-placeholder", surface: "--hm-sem-bg-surface", surfaceLabel: "흰 면", sample: "검색어를 입력하세요", usage: "Input·Textarea·Select·Command·Combobox·Date Picker의 흰 입력 면 전용" },
|
|
{ role: "대비 예외", name: "베이지 면 Placeholder", token: "--hm-sem-fg-secondary", surface: "--hm-sem-bg-content", surfaceLabel: "베이지 면", sample: "읽기 전용 · 값 없음", usage: "투명 면 readonly Input의 Placeholder. 베이지 면에서는 Placeholder 색이 4.5:1 미만이라 Secondary를 사용" },
|
|
] as const
|
|
// Palette steps: only some are wired to a text role. Strong Secondary / Tertiary Semantic tokens do not exist.
|
|
const grayPalette = [
|
|
{ step: "900", link: "Primary", linked: true },
|
|
{ step: "800", link: "팔레트만 · Semantic 없음(Strong Secondary 미정의)", linked: false },
|
|
{ step: "700", link: "Secondary · 대비 예외", linked: true },
|
|
{ step: "600", link: "Placeholder", linked: true },
|
|
{ step: "500", link: "팔레트만 · Disabled 텍스트 색 아님(Disabled는 opacity 방식)", linked: false },
|
|
] as const
|
|
const grayToken = (step: string) => `--hm-sem-palette-gray-${step}`
|
|
const textColorProperties = [...new Set([...textColorRoles.flatMap(item => [item.token, item.surface]), ...grayPalette.map(item => grayToken(item.step))])]
|
|
|
|
function toHex(value: string | undefined) {
|
|
if (!value) return ""
|
|
if (/^#[\da-f]{6}$/i.test(value)) return value.toUpperCase()
|
|
const rgb = value.match(/^rgb\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)\s*\)$/)
|
|
return rgb ? `#${rgb.slice(1).map(channel => Number(channel).toString(16).padStart(2, "0")).join("").toUpperCase()}` : value
|
|
}
|
|
// WCAG 2 contrast ratio of two #RRGGBB values; empty while a value is not resolved yet.
|
|
function contrast(foreground: string, background: string) {
|
|
if (!/^#[\da-f]{6}$/i.test(foreground) || !/^#[\da-f]{6}$/i.test(background)) return ""
|
|
const luminance = (hex: string) => [1, 3, 5].map(index => parseInt(hex.slice(index, index + 2), 16) / 255).map(channel => channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4).reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0)
|
|
const [light, dark] = [luminance(foreground), luminance(background)].sort((a, b) => b - a)
|
|
return `${((light + 0.05) / (dark + 0.05)).toFixed(2)}:1`
|
|
}
|
|
|
|
function TextColorTable({ values }: { values: Record<string, string> }) {
|
|
return <table className="hm-typo-table hm-text-color-table">
|
|
<thead><tr><th scope="col">Role</th><th scope="col">Sample</th><th scope="col">HEX · Contrast</th><th scope="col">Token</th><th scope="col">Usage</th></tr></thead>
|
|
<tbody>{textColorRoles.map(item => {
|
|
const hex = toHex(values[item.token])
|
|
return <tr key={item.role} data-text-color-role={item.role}>
|
|
<th scope="row"><span className="hm-text-color-role"><span className="hm-text-color-chip" style={{ background: `var(${item.token})` }} aria-hidden="true" /><span><span>{item.role}</span><small className="hm-foundation-note">{item.name}</small></span></span></th>
|
|
<td><span className="hm-text-color-sample" lang="ko" style={{ color: `var(${item.token})`, background: `var(${item.surface})` }}>{item.sample}</span></td>
|
|
<td><span data-text-color-hex>{hex}</span><small className="hm-foundation-note">{item.surfaceLabel} {contrast(hex, toHex(values[item.surface]))}</small></td>
|
|
<td><code>{item.token}</code>{"alias" in item ? <small className="hm-foundation-note"><code>{item.alias}</code></small> : null}</td>
|
|
<td className="hm-foundation-note">{item.usage}</td>
|
|
</tr>
|
|
})}</tbody>
|
|
</table>
|
|
}
|
|
|
|
function GrayPaletteTable({ values }: { values: Record<string, string> }) {
|
|
return <table className="hm-typo-table hm-text-color-table">
|
|
<thead><tr><th scope="col">Step</th><th scope="col">HEX</th><th scope="col">Token</th><th scope="col">Text Role</th></tr></thead>
|
|
<tbody>{grayPalette.map(item => <tr key={item.step} data-gray-step={item.step} data-linked={item.linked || undefined}>
|
|
<th scope="row"><span className="hm-text-color-role"><span className="hm-text-color-chip" style={{ background: `var(${grayToken(item.step)})` }} aria-hidden="true" /><span>Gray {item.step}</span></span></th>
|
|
<td><span data-text-color-hex>{toHex(values[grayToken(item.step)])}</span></td>
|
|
<td><code>{grayToken(item.step)}</code></td>
|
|
<td className={item.linked ? undefined : "hm-foundation-note"}>{item.link}</td>
|
|
</tr>)}</tbody>
|
|
</table>
|
|
}
|
|
|
|
function SpecimenRow({ label, size, line, weight }: TypographyReference & { weight?: string }) {
|
|
const ref = useRef<HTMLDivElement>(null)
|
|
const [metrics, setMetrics] = useState({ main: "", spacing: "" })
|
|
useEffect(() => {
|
|
let mounted = true
|
|
void document.fonts.ready.then(() => {
|
|
if (!mounted || !ref.current) return
|
|
const style = getComputedStyle(ref.current)
|
|
setMetrics({ main: `${style.fontSize} / ${style.lineHeight} / ${style.fontWeight}`, spacing: `letter-spacing: ${style.letterSpacing}` })
|
|
})
|
|
return () => { mounted = false }
|
|
}, [])
|
|
return <tr>
|
|
<th scope="row">{label}</th>
|
|
<td><div ref={ref} className="hm-typo-sample" data-typography-sample={label} lang="ko" style={{ fontSize: `var(${size})`, lineHeight: `var(${line})`, fontWeight: weight ? `var(${weight})` : "normal" }}>한맥 Hanmac 0123</div></td>
|
|
<td className="hm-typo-metrics" data-typography-metrics><span>{metrics.main}</span><small className="hm-foundation-note">{metrics.spacing}</small></td>
|
|
</tr>
|
|
}
|
|
|
|
function SpecimenTable({ rows }: { rows: readonly TypographyReference[] }) {
|
|
return <table className="hm-typo-table">
|
|
<thead><tr><th scope="col">Component</th><th scope="col">Sample</th><th scope="col">Size / Line / Weight</th></tr></thead>
|
|
<tbody>{rows.map(item => <SpecimenRow key={item.label} {...item} />)}</tbody>
|
|
</table>
|
|
}
|
|
|
|
function TypographyReferencePage() {
|
|
const values = useResolvedProperties(typographyProperties)
|
|
const colorValues = useResolvedProperties(textColorProperties)
|
|
return <ReferencePage title="폰트">
|
|
<ReferenceSection title="Font Family">
|
|
<p className="hm-typo-family"><span>Noto Sans KR → 맑은 고딕 → 시스템 sans-serif</span><code className="hm-foundation-note">--hm-font-family-base: {values["--hm-font-family-base"]}</code></p>
|
|
</ReferenceSection>
|
|
<ReferenceSection title="Font Weight">
|
|
<div className="hm-typo-weights">{weights.map(({ property, label }) => <article className="hm-typo-weight" key={property}>
|
|
<p className="hm-typo-weight-meta"><strong>{label}</strong> <span className="hm-foundation-note">{values[property]}</span></p>
|
|
<p className="hm-typo-weight-preview" style={{ fontWeight: `var(${property})` }}>한맥 Hanmac 0123</p>
|
|
<code className="hm-foundation-note">{property}</code>
|
|
</article>)}</div>
|
|
</ReferenceSection>
|
|
<ReferenceSection title="Typography Specimens">
|
|
<div className="hm-typo-tables"><SpecimenTable rows={textRows} /><SpecimenTable rows={controlRows} /></div>
|
|
</ReferenceSection>
|
|
<ReferenceSection title="Text Colors" note="Web · Chart · OrgChart 공통 텍스트 색입니다(SW 제외). 칩·샘플·HEX는 현재 토큰 값을 읽습니다.">
|
|
<TextColorTable values={colorValues} />
|
|
</ReferenceSection>
|
|
<ReferenceSection title="Foundation Gray" note="텍스트 역할에 연결된 단계와 팔레트에만 정의된 단계를 구분합니다.">
|
|
<GrayPaletteTable values={colorValues} />
|
|
</ReferenceSection>
|
|
</ReferencePage>
|
|
}
|
|
// The sidebar name is "폰트"; `id` keeps the existing Story ID (foundation-typography--all) stable.
|
|
const meta = { title: "Foundation/폰트", id: "foundation-typography", parameters: { layout: "fullscreen" } } satisfies Meta
|
|
export default meta
|
|
export const All: StoryObj<typeof meta> = { name: "폰트", render: () => <TypographyReferencePage /> }
|