diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index fd2bc0b..7b1992e 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -14,9 +14,11 @@ on: - docs/site/** - docs/data/** - apps/playground/** + - apps/ui-showcase/** - packages/ce/src/** - packages/ce/package.json - packages/ce/tsconfig.json + - packages/ui/** workflow_dispatch: permissions: @@ -54,7 +56,7 @@ jobs: - name: Generate docs site run: bun run docs:build - - name: Prepare Pages artifact (docs/site/* + docs/data + dist) + - name: Prepare Pages artifact (docs/site/* + docs/data + dist + UI showcase) run: | set -euo pipefail mkdir -p _pages_artifact @@ -77,6 +79,15 @@ jobs: echo "dist not found; skipping copy" fi + if [[ -f apps/ui-showcase/index.html && -f apps/ui-showcase/dist/app.js ]]; then + mkdir -p _pages_artifact/ui/dist + cp apps/ui-showcase/index.html _pages_artifact/ui/index.html + cp -a apps/ui-showcase/dist/. _pages_artifact/ui/dist/ + else + echo "apps/ui-showcase build output not found; cannot publish /ui" + exit 1 + fi + - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 with: @@ -85,7 +96,7 @@ jobs: - name: Deployment failure guidance if: failure() run: | - echo "[Ops] If deployment fails, check docs/site and docs/data path configuration first." + echo "[Ops] If deployment fails, check docs/site, docs/data, and apps/ui-showcase/dist first." deploy: needs: build @@ -132,4 +143,4 @@ jobs: if: failure() run: | echo "[Ops] First check that Settings > Pages is enabled and Source is set to GitHub Actions." - echo "[Ops] Then check docs/site and docs/data path configuration." + echo "[Ops] Then check docs/site, docs/data, and apps/ui-showcase/dist path configuration." diff --git a/apps/ui-showcase/App.ts b/apps/ui-showcase/App.ts new file mode 100644 index 0000000..87f0b61 --- /dev/null +++ b/apps/ui-showcase/App.ts @@ -0,0 +1,463 @@ +import { config, define, html, signal } from "@ga-ut/ce/web"; +import { gaUiStyles } from "@ga-ut/ui"; +import { icon } from "./icons"; +import { showcaseStyles } from "./styles"; + +type Task = { + id: number; + title: string; + done: boolean; +}; + +type InputDetail = CustomEvent<{ value: string }>; + +const navItems = [ + { id: "intro", label: "소개", icon: "home" }, + { id: "foundations", label: "Foundations", icon: "layers" }, + { id: "components", label: "Components", icon: "cube" }, + { id: "patterns", label: "Patterns", icon: "grid" }, +]; + +const utilityItems = [ + { label: "토큰 가이드", icon: "list" }, + { label: "아이콘", icon: "smile" }, + { label: "변경 로그", icon: "history" }, +]; + +const colors = [ + { name: "Brand 500", value: "#E84D3D", className: "brand-500" }, + { name: "Sand 50", value: "#F1EEE8", className: "sand" }, + { name: "Sage 100", value: "#DDE4DA", className: "sage-100" }, + { name: "Sage 300", value: "#B6C9BD", className: "sage-300" }, + { name: "Forest 700", value: "#205A3F", className: "forest" }, + { name: "Ink 950", value: "#1B1D1C", className: "ink" }, + { name: "Neutral 500", value: "#979797", className: "neutral-500" }, + { name: "Neutral 300", value: "#B6B6B6", className: "neutral-300" }, + { name: "White", value: "#FFFFFF", className: "white" }, +]; + +const componentExamples: Record< + string, + { tag: string; code: string; display: string } +> = { + switch: { + tag: "ga-switch", + code: "활성", + display: "<ga-switch checked>활성</ga-switch>", + }, + actions: { + tag: "ga-button", + code: '저장하기', + display: + "<ga-button variant="primary">저장하기</ga-button>", + }, + forms: { + tag: "ga-text-field", + code: '', + display: + "<ga-text-field label="이메일"></ga-text-field>", + }, + feedback: { + tag: "ga-feedback", + code: '완료되었습니다.', + display: + "<ga-feedback tone="success" title="활성">완료되었습니다.</ga-feedback>", + }, +}; + +function GaUtShowcase({ host }: { host: HTMLElement }) { + const isDark = signal(false); + const menuOpen = signal(false); + const activeSection = signal("intro"); + const inspectorTab = signal<"preview" | "code">("preview"); + const selectedComponent = signal("switch"); + const tasks = signal([ + { id: 1, title: "새 제품 흐름 검토", done: false }, + ]); + let emailValue = "team@ga-ut.com"; + const marketing = signal(true); + const inspectorSwitch = signal(true); + const inspectorFilter = signal("all"); + const inspectorChoice = signal("option-a"); + const inspectorToastVisible = signal(true); + const dismissedFeedback = signal([]); + const toast = signal(""); + let nextTaskId = 2; + let toastTimer: ReturnType | undefined; + + const showToast = (value: string) => { + toast.set(value); + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(() => toast.set(""), 2400); + }; + + const scrollToSection = (sectionId: string) => { + activeSection.set(sectionId); + menuOpen.set(false); + host.shadowRoot + ?.querySelector(`#${sectionId}`) + ?.scrollIntoView({ behavior: "smooth", block: "start" }); + }; + + const handleUtility = (label: string) => { + if (label === "토큰 가이드") { + scrollToSection("foundations"); + showToast("GA-UT의 색상, 타이포그래피, 공간 토큰으로 이동했습니다."); + return; + } + + if (label === "아이콘") { + scrollToSection("patterns"); + showToast("아이콘을 포함한 조합 패턴으로 이동했습니다."); + return; + } + + showToast("v0.1 · Foundations, Components, Patterns를 처음 공개했습니다."); + }; + + const copyTokens = async () => { + const tokenText = "--ga-color-accent: #E84D3D;"; + try { + await navigator.clipboard.writeText(tokenText); + showToast("토큰이 클립보드에 복사되었습니다."); + } catch { + showToast("토큰: --ga-color-accent · #E84D3D"); + } + }; + + const copyComponentCode = async () => { + const example = componentExamples[selectedComponent()] ?? componentExamples.switch; + try { + await navigator.clipboard.writeText(example.code); + showToast(`${example.tag} 코드를 복사했습니다.`); + } catch { + showToast(example.code); + } + }; + + const addTask = () => { + tasks.update((current) => [ + ...current, + { id: nextTaskId++, title: "디자인 토큰 문서화", done: false }, + ]); + showToast("오늘 할 일을 추가했습니다."); + }; + + const toggleTask = (id: number) => { + tasks.update((current) => + current.map((task) => + task.id === id ? { ...task, done: !task.done } : task + ) + ); + }; + + const dismissFeedback = (tone: string) => { + dismissedFeedback.update((current) => + current.includes(tone) ? current : [...current, tone] + ); + }; + + const selectComponent = (component: string) => { + selectedComponent.set(component); + inspectorTab.set("preview"); + }; + + const handleEmail = (event: Event) => { + emailValue = (event as InputDetail).detail?.value ?? ""; + }; + + const updateMessageCount = (event: Event) => { + const value = (event.target as HTMLTextAreaElement).value; + const counter = host.shadowRoot?.querySelector("[data-message-count]"); + if (counter) counter.textContent = `${value.length} / 200`; + }; + + return () => { + const activeToast = toast(); + const selected = selectedComponent(); + const selectedExample = componentExamples[selected] ?? componentExamples.switch; + const taskList = tasks(); + const hiddenFeedback = dismissedFeedback(); + const darkClass = isDark() ? "ga-dark" : ""; + const marketingSwitch = marketing() + ? html` marketing.set(false)}>활성` + : html` marketing.set(true)}>비활성`; + const previewSwitch = inspectorSwitch() + ? html` inspectorSwitch.set(false)}>활성` + : html` inspectorSwitch.set(true)}>비활성`; + const renderTab = ( + label: string, + value: string, + current: string, + onSelect: () => void + ) => + value === current + ? html`${label}` + : html`${label}`; + const renderActionState = (state: string, index: number) => + index === 3 + ? html` +
+ ${state} +
저장하기
+
초대하기
+
취소
+
+ ` + : html` +
+ ${state} +
저장하기
+
초대하기
+
취소
+
+ `; + + return html` +
+ + +
+ + +
+ + + + +
+
+ +
+ + +
+ +
+
+
+
+

하나의 언어로,
모든 제품을.

+

CE 위에 세운 GA-UT의 인터페이스 시스템.
빠르게 조합하고, 오래 일관되게.

+
+ scrollToSection("components")}>컴포넌트 보기 ${icon("arrow")} + ${icon("copy")} 토큰 복사 +
+
+ +
+
+

Today

+
+
+
+ 오늘 +
+ ${taskList.map( + (task) => html` +
+ + ${task.title} + ${task.done ? "완료" : "활성"} + +
+ ` + )} +
+
+
+
+ +
+
FOUNDATIONS
+
+
+

Color

+
+ ${colors.map( + (color) => html`` + )} +
+
--ga-color-accent#E84D3D
+
+
+

Typography

+
Aa
GA-UT SerifPretendardSystem UI
+

Display / Heading / Body / Caption

+
+
+

Space & shape

+

4  /  8  /  12  /  16
20  /  24  /  32
40  /  48  /  64

Radius

6  /  8  /  12  /  16

+
+
+
+ +
+
COMPONENTS
+
+
+

Actions

+
+ ${["Default", "Hover", "Focus", "Disabled"].map(renderActionState)} +
+ +
+ +
+

Forms

+ + +
옵션
${marketingSwitch}
+ +
+ + +
+
+ +
+
PATTERNS
+
+
+ 01 · FLOW +

행동에서 피드백까지

+

명확한 명령, 즉시 보이는 상태, 다음 행동을 알려주는 피드백을 한 흐름으로 연결합니다.

+ action → state → feedback +
+
+ 02 · COMPOSITION +

작게 조합하고 크게 확장

+

CE 컴포넌트의 독립성을 유지하면서 제품 화면에서는 같은 토큰과 간격으로 조합합니다.

+ primitive → pattern → product +
+
+ 03 · ACCESS +

키보드와 상태를 기본값으로

+

포커스, 비활성, 오류, 완료 상태를 시각 표현과 의미 구조에 함께 담습니다.

+ focus · disabled · status +
+
+
+
+ + +
+ + ${activeToast + ? html`
${icon("check")}${activeToast}
` + : ""} +
+ `; + }; +} + +const appTag = define(GaUtShowcase, { styles: [showcaseStyles] }); + +const mountApp = () => { + const existingRoot = document.querySelector("[data-ga-ut-ui-root]"); + const root = existingRoot ?? document.createElement("div"); + root.dataset.gaUtUiRoot = "true"; + + if (!existingRoot) document.body.append(root); + if (!window.location.hash) window.location.hash = "#/"; + + config({ + globalStyles: gaUiStyles, + entryPoint: { + selector: "ga-ut-ui-root", + rootElement: root, + hydrate: false, + }, + routes: [{ path: "/", tag: appTag }], + }); +}; + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", mountApp, { once: true }); +} else { + mountApp(); +} diff --git a/apps/ui-showcase/dev-server.mjs b/apps/ui-showcase/dev-server.mjs new file mode 100644 index 0000000..c5568d6 --- /dev/null +++ b/apps/ui-showcase/dev-server.mjs @@ -0,0 +1,68 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const port = Number(process.env.PORT || 4174); +const host = process.env.HOST || "127.0.0.1"; + +const indexHtmlUrl = new URL("./index.html", import.meta.url); +const appEntryPath = fileURLToPath(new URL("./App.ts", import.meta.url)); +const indexHtml = await readFile(indexHtmlUrl, "utf8"); + +const buildAppBundle = async () => { + const result = await Bun.build({ + entrypoints: [appEntryPath], + format: "esm", + target: "browser", + sourcemap: "inline", + minify: false, + }); + + if (!result.success) { + const details = result.logs.map((log) => log.message).join("\n"); + throw new Error(details || "GA-UT UI showcase bundle failed to build."); + } + + return result.outputs[0].text(); +}; + +const server = Bun.serve({ + hostname: host, + port, + routes: { + "/": async () => + new Response(indexHtml, { + headers: { + "cache-control": "no-store", + "content-type": "text/html; charset=utf-8", + }, + }), + "/index.html": async () => + new Response(indexHtml, { + headers: { + "cache-control": "no-store", + "content-type": "text/html; charset=utf-8", + }, + }), + "/dist/app.js": async () => { + try { + const bundle = await buildAppBundle(); + return new Response(bundle, { + headers: { + "cache-control": "no-store", + "content-type": "text/javascript; charset=utf-8", + }, + }); + } catch (error) { + return new Response(String(error), { + status: 500, + headers: { "content-type": "text/plain; charset=utf-8" }, + }); + } + }, + }, + fetch() { + return new Response("Not found", { status: 404 }); + }, +}); + +console.log(`GA-UT UI showcase: http://${server.hostname}:${server.port}`); diff --git a/apps/ui-showcase/icons.ts b/apps/ui-showcase/icons.ts new file mode 100644 index 0000000..67d5489 --- /dev/null +++ b/apps/ui-showcase/icons.ts @@ -0,0 +1,27 @@ +export const icon = (name: string, className = "icon") => { + const paths: Record = { + home: '', + layers: + '', + cube: '', + grid: '', + list: '', + smile: '', + history: '', + copy: '', + sun: '', + moon: '', + menu: '', + close: '', + arrow: '', + plus: '', + more: '', + check: '', + mail: '', + chevron: '', + warning: '', + info: '', + }; + + return ``; +}; diff --git a/apps/ui-showcase/index.html b/apps/ui-showcase/index.html new file mode 100644 index 0000000..2d73471 --- /dev/null +++ b/apps/ui-showcase/index.html @@ -0,0 +1,33 @@ + + + + + + + + GA-UT UI + + + + + diff --git a/apps/ui-showcase/package.json b/apps/ui-showcase/package.json new file mode 100644 index 0000000..8eb15bf --- /dev/null +++ b/apps/ui-showcase/package.json @@ -0,0 +1,12 @@ +{ + "name": "@ga-ut/ui-showcase", + "private": true, + "scripts": { + "build": "bun build ./App.ts --outfile ./dist/app.js --target browser", + "preview": "bun run ./dev-server.mjs" + }, + "dependencies": { + "@ga-ut/ce": "workspace:*", + "@ga-ut/ui": "workspace:*" + } +} diff --git a/apps/ui-showcase/styles.ts b/apps/ui-showcase/styles.ts new file mode 100644 index 0000000..8b62176 --- /dev/null +++ b/apps/ui-showcase/styles.ts @@ -0,0 +1,1985 @@ +export const showcaseStyles = ` + :host(ga-ut-showcase) { + display: block; + min-height: 100vh; + } + + .showcase-app, + .showcase-app *, + .showcase-app *::before, + .showcase-app *::after { + box-sizing: border-box; + } + + .showcase-app { + --chrome-height: 70px; + --page: var(--ga-surface-canvas); + --surface: var(--ga-surface-raised); + --surface-subtle: var(--ga-surface-subtle); + --ink: var(--ga-text-primary); + --muted: var(--ga-text-secondary); + --quiet: var(--ga-text-tertiary); + --line: var(--ga-border-subtle); + --line-strong: var(--ga-border-strong); + --brand: #e84d3d; + --brand-deep: #cf392c; + --brand-soft: #fff0ed; + --forest: #216445; + --success-soft: #edf6f0; + --warning: #de880d; + --warning-soft: #fff7e8; + --danger-soft: #fff2f0; + --focus: #1677ff; + min-height: 100vh; + color: var(--ink); + background: var(--page); + font-family: var(--ga-font-sans); + font-size: 14px; + line-height: 1.45; + transition: color 180ms ease, background 180ms ease; + } + + .showcase-app.ga-dark { + --page: #151716; + --surface: #1c1e1d; + --surface-subtle: #202321; + --ink: #f6f5f2; + --muted: #b7bab5; + --quiet: #8d918c; + --line: #393c39; + --line-strong: #565a56; + --brand-soft: #3b211e; + --success-soft: #183528; + --warning-soft: #392d18; + --danger-soft: #3b211e; + color-scheme: dark; + } + + .showcase-app button, + .showcase-app select, + .showcase-app textarea { + color: inherit; + font: inherit; + } + + .showcase-app button { + border: 0; + background: none; + cursor: pointer; + } + + .showcase-app button:focus-visible, + .showcase-app select:focus-visible, + .showcase-app textarea:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; + } + + .showcase-app .icon { + width: 20px; + height: 20px; + flex: 0 0 auto; + vertical-align: middle; + } + + .sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: 30; + display: flex; + width: 205px; + flex-direction: column; + border-right: 1px solid var(--line); + background: var(--surface); + } + + .brand, + .mobile-brand { + display: flex; + align-items: center; + color: var(--ink); + font-size: 16px !important; + font-weight: 720 !important; + letter-spacing: 0.01em; + } + + .brand { + height: 131px; + padding: 23px 22px 16px; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 6px; + } + + .brand ga-mark { + width: 52px; + height: 52px; + } + + .brand ga-mark::part(mark), + .mobile-brand ga-mark::part(mark) { + width: 100%; + height: 100%; + color: var(--brand); + } + + .side-nav { + display: grid; + gap: 3px; + padding: 8px 7px; + } + + .side-nav-item { + position: relative; + display: flex; + width: 100%; + height: 43px; + align-items: center; + gap: 13px; + border-radius: 7px; + padding: 0 17px; + color: var(--ink); + text-align: left; + font-size: 14px !important; + } + + .side-nav-item .icon { + width: 19px; + height: 19px; + } + + .side-nav-item:hover { + background: var(--surface-subtle); + } + + .side-nav-item.is-active { + background: var(--brand-soft); + } + + .side-nav-item.is-active::before { + position: absolute; + inset: 0 auto 0 0; + width: 3px; + border-radius: 3px; + background: var(--brand); + content: ""; + } + + .side-divider { + height: 1px; + margin: 5px 17px 4px; + background: var(--line); + } + + .utility-nav { + padding-top: 4px; + } + + .sidebar-meta { + margin-top: auto; + border-top: 1px solid var(--line); + } + + .meta-row { + display: flex; + min-height: 58px; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--line); + padding: 0 18px 0 22px; + color: var(--muted); + font-size: 11px; + } + + .theme-pair { + display: flex; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 7px; + } + + .theme-pair button { + display: grid; + width: 37px; + height: 30px; + place-items: center; + color: var(--quiet); + } + + .theme-pair button.is-active { + background: var(--brand-soft); + color: var(--brand); + } + + .theme-pair .icon, + .language-row .icon, + .company-row .icon { + width: 15px; + height: 15px; + } + + .language-row button { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px !important; + } + + .company-row { + position: relative; + display: grid; + width: 100%; + min-height: 78px; + align-content: center; + padding: 10px 37px 10px 22px; + text-align: left; + } + + .company-row strong { + font-size: 12px; + font-weight: 600; + } + + .company-row span { + color: var(--quiet); + font-size: 10px; + } + + .company-row .icon { + position: absolute; + top: 25px; + right: 18px; + } + + .topbar { + position: fixed; + inset: 0 0 auto 205px; + z-index: 25; + display: flex; + height: 70px; + align-items: stretch; + justify-content: space-between; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--surface) 94%, transparent); + backdrop-filter: blur(16px); + } + + .mobile-brand, + .menu-button { + display: none !important; + } + + .top-tabs { + display: flex; + height: 100%; + align-items: stretch; + gap: 17px; + padding-left: 27px; + } + + .top-tabs button { + position: relative; + min-width: 112px; + padding: 1px 12px 0; + font-size: 14px !important; + } + + .top-tabs button.is-active { + color: var(--brand); + } + + .top-tabs button.is-active::after { + position: absolute; + inset: auto 0 -1px; + height: 2px; + background: var(--brand); + content: ""; + } + + .top-actions { + display: flex; + align-items: center; + gap: 15px; + padding-right: 29px; + } + + .version-button, + .icon-button, + .add-button { + display: inline-flex; + height: 34px; + align-items: center; + justify-content: center; + border: 1px solid var(--line) !important; + border-radius: 6px; + background: var(--surface) !important; + } + + .version-button { + min-width: 75px; + gap: 9px; + padding: 0 12px; + font-size: 13px !important; + } + + .version-button .icon { + width: 14px; + } + + .icon-button { + width: 36px; + } + + .icon-button .icon { + width: 19px; + height: 19px; + } + + .mobile-menu { + display: none; + } + + .workspace { + min-height: 100vh; + margin-left: 205px; + padding-top: 70px; + background: var(--page); + } + + .primary-content { + min-width: 0; + } + + .hero { + display: grid; + min-height: 390px; + grid-template-columns: minmax(0, 0.9fr) minmax(430px, 1.1fr); + align-items: center; + gap: 28px; + border-bottom: 1px solid var(--line); + padding: 49px 46px 24px 52px; + scroll-margin-top: 70px; + } + + .hero-copy { + align-self: center; + } + + .hero h1 { + max-width: 610px; + margin: -2px 0 16px; + font-family: "AppleMyungjo", "Iowan Old Style", "Noto Serif KR", Georgia, serif; + font-size: clamp(48px, 4vw, 61px); + font-weight: 500; + letter-spacing: -0.058em; + line-height: 1.18; + } + + .hero h1 span { + white-space: nowrap; + } + + .hero-copy p { + margin: 0; + color: var(--muted); + font-size: 15px; + line-height: 1.6; + } + + .hero-actions { + display: flex; + align-items: center; + gap: 18px; + margin-top: 26px; + } + + .hero-actions ga-button .icon { + margin-left: 8px; + } + + .today-panel { + align-self: center; + min-height: 313px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface); + } + + .panel-heading { + display: flex; + height: 57px; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--line); + padding: 0 17px 0 25px; + } + + .panel-heading h2 { + margin: 0; + font-size: 16px; + font-weight: 680; + } + + .panel-heading > div { + display: flex; + align-items: center; + gap: 9px; + } + + .add-button { + gap: 7px; + padding: 0 12px; + font-size: 13px !important; + } + + .add-button .icon, + .more-button .icon { + width: 17px; + height: 17px; + } + + .more-button { + display: grid; + width: 31px; + height: 34px; + place-items: center; + } + + .task-body { + padding: 27px 23px 22px; + } + + .today-label { + display: block; + margin: 0 0 11px 3px; + color: var(--quiet); + font-size: 12px; + } + + .task-list { + display: grid; + gap: 8px; + } + + .task-row { + display: grid; + min-height: 61px; + grid-template-columns: 24px 1fr auto 29px; + align-items: center; + gap: 12px; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0 11px 0 13px; + transition: border 160ms ease, background 160ms ease; + } + + .task-row:hover { + border-color: var(--line-strong); + } + + .task-row.is-done { + background: var(--success-soft); + } + + .task-row.is-done .task-title { + color: var(--quiet); + text-decoration: line-through; + } + + .task-check { + display: grid; + width: 20px; + height: 20px; + place-items: center; + border: 1px solid var(--line-strong) !important; + border-radius: 5px; + background: var(--surface) !important; + color: var(--forest) !important; + } + + .task-check .icon { + width: 14px; + height: 14px; + } + + .task-title { + color: var(--muted); + font-size: 13px; + } + + .library-section { + margin-right: 352px; + border-bottom: 1px solid var(--line); + padding: 20px 44px 18px; + scroll-margin-top: 70px; + } + + .foundations-section { + min-height: 222px; + } + + .components-section { + min-height: 374px; + padding-top: 17px; + } + + .section-title { + display: flex; + align-items: center; + gap: 20px; + margin-bottom: 15px; + } + + .section-title span { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + } + + .section-title i { + height: 1px; + flex: 1; + background: var(--line); + } + + .foundation-grid, + .component-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .foundation-card, + .component-card { + min-width: 0; + padding: 0 34px; + border-left: 1px solid var(--line); + } + + .foundation-card:first-child, + .component-card:first-child { + padding-left: 0; + border-left: 0; + } + + .foundation-card:last-child, + .component-card:last-child { + padding-right: 0; + } + + .foundation-card h2, + .component-card h2 { + margin: 0 0 12px; + font-family: "Iowan Old Style", "AppleMyungjo", Georgia, serif; + font-size: 16px; + font-weight: 500; + line-height: 1.2; + } + + .swatches { + display: flex; + gap: 7px; + } + + .swatch { + width: 28px; + height: 32px; + flex: 1 1 28px; + max-width: 31px; + border: 1px solid transparent !important; + border-radius: 4px; + transition: transform 150ms ease; + } + + .swatch:hover { + transform: translateY(-3px); + } + + .swatch.brand-500 { background: #e84d3d !important; } + .swatch.sand { background: #f1eee8 !important; } + .swatch.sage-100 { background: #dde4da !important; } + .swatch.sage-300 { background: #b6c9bd !important; } + .swatch.forest { background: #205a3f !important; } + .swatch.ink { background: #1b1d1c !important; } + .swatch.neutral-500 { background: #8e8f8d !important; } + .swatch.neutral-300 { background: #b3b4b2 !important; } + .swatch.white { border-color: #aaa !important; background: #fff !important; } + + .token-caption { + display: flex; + justify-content: space-between; + margin-top: 27px; + color: var(--muted); + } + + .token-caption code { + font-family: inherit; + font-size: 10px; + } + + .type-demo { + display: flex; + align-items: center; + gap: 35px; + margin-top: -4px; + } + + .type-demo > strong { + font-family: "Iowan Old Style", Georgia, serif; + font-size: 67px; + font-weight: 400; + letter-spacing: -0.07em; + line-height: 0.95; + } + + .type-demo > div { + display: grid; + gap: 3px; + } + + .type-demo b { + font-family: "Iowan Old Style", Georgia, serif; + font-size: 16px; + font-weight: 400; + } + + .type-demo span { + font-size: 13px; + } + + .type-demo small { + color: var(--muted); + font-size: 9px; + } + + .typography-foundation > p { + margin: 22px 0 0; + color: var(--muted); + font-size: 9px; + } + + .typography-foundation > p i { + margin: 0 8px; + color: var(--quiet); + font-style: normal; + } + + .shape-layout { + display: grid; + grid-template-columns: minmax(78px, 98px) minmax(0, 1fr); + gap: clamp(16px, 2vw, 31px); + } + + .shape-stage { + position: relative; + display: grid; + width: 100%; + max-width: 98px; + aspect-ratio: 1; + height: auto; + place-items: center; + border: 1px dashed var(--brand); + } + + .shape-stage::before, + .shape-stage::after { + position: absolute; + background: var(--line); + content: ""; + } + + .shape-stage::before { inset: 50% 0 auto; height: 1px; } + .shape-stage::after { inset: 0 auto 0 50%; width: 1px; } + + .shape-stage span { + z-index: 1; + width: 72%; + aspect-ratio: 1; + height: auto; + border: 1px solid var(--line-strong); + border-radius: 16px; + background: color-mix(in srgb, var(--surface) 70%, var(--line)); + } + + .shape-values p { + margin: 0 0 14px; + font-size: 10px; + line-height: 1.85; + } + + .shape-values small { + color: var(--muted); + font-size: 9px; + } + + .component-card { + position: relative; + min-height: 273px; + padding-bottom: 33px; + } + + .component-card:hover h2 { + color: var(--brand); + } + + .action-state-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + } + + .demo-cell { + display: grid; + justify-items: stretch; + gap: 9px; + min-width: 0; + } + + .demo-cell > span { + color: var(--quiet); + text-align: center; + font-size: 8px; + } + + .button-frame { + display: flex; + justify-content: center; + border-radius: 6px; + } + + .button-frame ga-button { + display: block; + width: 100%; + min-width: 0; + } + + .actions-card ga-button::part(button) { + width: 100%; + min-width: 0; + padding-inline: 5px; + font-size: 10px; + } + + .state-hover .button-frame:first-of-type { + filter: saturate(1.25) brightness(0.88); + } + + .state-focus .button-frame { + outline: 2px solid var(--focus); + outline-offset: 1px; + } + + .text-link { + position: absolute; + inset: auto auto 0 0; + display: inline-flex; + align-items: center; + gap: 9px; + color: var(--brand) !important; + font-size: 10px !important; + } + + .text-link .icon { + width: 14px; + height: 14px; + } + + .forms-card ga-text-field { + display: block; + margin-bottom: 10px; + } + + .forms-card ga-text-field::part(control) { + border-color: var(--focus); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--focus) 45%, transparent); + } + + .message-field { + position: relative; + display: grid; + gap: 4px; + color: var(--muted); + font-size: 9px; + } + + .message-field textarea { + width: 100%; + min-height: 48px; + resize: none; + border: 1px solid var(--line-strong); + border-radius: 5px; + padding: 7px 9px 18px; + background: var(--surface); + font-size: 9px; + } + + .message-field small { + position: absolute; + right: 8px; + bottom: 5px; + color: var(--quiet); + font-size: 8px; + } + + .form-options { + display: grid; + grid-template-columns: 44px 1fr; + margin-top: 9px; + color: var(--muted); + font-size: 9px; + } + + .form-options > div { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 7px; + } + + .form-options ga-switch { + white-space: nowrap; + } + + .form-options > div > div { + grid-row: auto; + } + + .check-label, + .radio-label { + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; + } + + .check-label input, + .radio-label input { + position: absolute; + opacity: 0; + } + + .check-label > span, + .radio-label > span { + width: 12px; + height: 12px; + border: 1px solid var(--line-strong); + background: var(--surface); + } + + .check-label > span { border-radius: 3px; } + .radio-label > span { border-radius: 50%; } + .check-label input:checked + span { border-color: var(--brand); background: var(--brand); } + .check-label input:checked + span::after { + display: block; + width: 6px; + height: 3px; + margin: 3px 0 0 2px; + transform: rotate(-45deg); + border-bottom: 1.5px solid #fff; + border-left: 1.5px solid #fff; + content: ""; + } + + .feedback-card ga-feedback { + display: block; + margin-bottom: 7px; + } + + .feedback-card ga-feedback::part(feedback) { + min-height: 55px; + gap: 8px; + border-radius: 6px; + padding: 9px 11px; + } + + .feedback-card ga-feedback::part(title) { + margin-bottom: 1px; + font-size: 11px; + } + + .feedback-card ga-feedback::part(content) { + font-size: 9px; + line-height: 1.35; + } + + .feedback-card ga-feedback::part(dismiss) { + width: 20px; + height: 20px; + } + + .restore-feedback { + min-height: 34px; + border: 1px dashed var(--line-strong) !important; + border-radius: 5px; + padding: 0 12px; + color: var(--brand) !important; + font-size: 10px !important; + } + + .showcase-app ga-switch[checked]::part(track) { + background: var(--forest); + } + + .inspector { + position: fixed; + inset: 460px 0 0 auto; + z-index: 10; + width: 352px; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + border-left: 1px solid var(--line); + background: var(--surface); + } + + .inspector-tabs { + display: flex; + height: 50px; + align-items: stretch; + gap: 8px; + border-bottom: 1px solid var(--line); + padding: 0 13px; + } + + .inspector-tabs > span { + display: flex; + } + + .inspector-mobile-label { + display: none !important; + } + + .inspector-content { + padding: 28px 26px; + } + + .inspector-group { + margin-bottom: 25px; + } + + .inspector-group > strong { + display: block; + margin-bottom: 11px; + font-size: 11px; + font-weight: 650; + } + + .inline-preview { + display: flex; + align-items: center; + gap: 15px; + color: var(--muted); + font-size: 10px; + } + + .tab-preview { + display: grid; + grid-template-columns: repeat(3, 1fr); + overflow: hidden; + border: 1px solid var(--line); + border-radius: 5px; + } + + .tab-preview > span { + display: contents; + } + + .select-wrap { + position: relative; + display: block; + } + + .select-wrap select { + width: 100%; + height: 31px; + appearance: none; + border: 1px solid var(--line); + border-radius: 5px; + padding: 0 34px 0 12px; + background: var(--surface); + font-size: 10px; + } + + .select-wrap .icon { + position: absolute; + top: 8px; + right: 10px; + width: 14px; + height: 14px; + pointer-events: none; + } + + .segmented { + display: grid; + grid-template-columns: repeat(3, 1fr); + overflow: hidden; + border: 1px solid var(--line); + border-radius: 5px; + } + + .segmented button { + height: 34px; + border-left: 1px solid var(--line); + font-size: 10px !important; + } + + .segmented button:first-child { + border-left: 0; + } + + .segmented button.is-active { + box-shadow: inset 0 0 0 1px var(--brand); + color: var(--brand); + } + + .inline-toast { + display: grid; + min-height: 46px; + grid-template-columns: 20px 1fr 20px; + align-items: center; + gap: 10px; + border: 1px solid var(--forest); + border-radius: 4px; + padding: 0 10px; + box-shadow: 0 5px 16px rgb(16 40 28 / 14%); + color: var(--forest); + font-size: 9px; + } + + .inline-toast .icon { + width: 18px; + height: 18px; + } + + .inline-toast button { + display: grid; + width: 20px; + height: 20px; + place-items: center; + color: var(--ink); + } + + .restore-toast { + min-height: 42px; + border: 1px dashed var(--line-strong) !important; + border-radius: 5px; + color: var(--brand) !important; + font-size: 10px !important; + } + + .selection-note { + margin: -9px 0 0; + color: var(--quiet); + font-size: 9px; + } + + .code-panel { + display: grid; + gap: 12px; + padding: 29px 26px; + } + + .code-panel > span { + color: var(--quiet); + font-size: 10px; + } + + .code-panel > strong { + color: var(--brand); + } + + .code-panel pre { + overflow-x: auto; + margin: 5px 0; + border: 1px solid var(--line); + border-radius: 7px; + padding: 15px; + background: var(--surface-subtle); + color: var(--muted); + font-size: 10px; + line-height: 1.7; + } + + .code-panel button { + display: inline-flex; + width: max-content; + align-items: center; + gap: 7px; + border: 1px solid var(--line) !important; + border-radius: 5px; + padding: 8px 10px; + background: var(--surface) !important; + font-size: 10px !important; + } + + .code-panel .icon { + width: 15px; + height: 15px; + } + + .patterns-section { + min-height: 282px; + padding-top: 22px; + padding-bottom: 32px; + } + + .pattern-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + border-top: 1px solid var(--line); + } + + .pattern-list article { + min-width: 0; + padding: 20px 28px 0; + border-left: 1px solid var(--line); + } + + .pattern-list article:first-child { + padding-left: 0; + border-left: 0; + } + + .pattern-list article:last-child { + padding-right: 0; + } + + .pattern-list article > span { + color: var(--brand); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.11em; + } + + .pattern-list h2 { + margin: 10px 0 8px; + font-family: var(--ga-font-display); + font-size: 17px; + font-weight: 500; + } + + .pattern-list p { + min-height: 58px; + margin: 0 0 14px; + color: var(--muted); + font-size: 10px; + line-height: 1.65; + } + + .pattern-list code { + color: var(--quiet); + font-family: var(--ga-font-mono); + font-size: 9px; + } + + .live-toast { + position: fixed; + right: 26px; + bottom: 24px; + z-index: 60; + display: grid; + min-width: 310px; + min-height: 52px; + grid-template-columns: 22px 1fr 22px; + align-items: center; + gap: 10px; + transform: translateY(16px); + border: 1px solid var(--forest); + border-radius: 6px; + padding: 0 12px; + background: var(--surface); + box-shadow: 0 14px 38px rgb(18 30 22 / 20%); + color: var(--forest); + font-size: 11px; + opacity: 0; + pointer-events: none; + transition: opacity 180ms ease, transform 180ms ease; + } + + .live-toast.is-visible { + transform: translateY(0); + opacity: 1; + pointer-events: auto; + } + + .live-toast button { + display: grid; + place-items: center; + color: var(--ink); + } + + .live-toast .icon { + width: 19px; + height: 19px; + } + + @media (max-width: 1200px) and (min-width: 901px) { + .hero { + grid-template-columns: minmax(390px, 0.8fr) minmax(480px, 1.2fr); + padding-left: 38px; + padding-right: 32px; + } + + .hero h1 { + font-size: 48px; + } + + .library-section { + padding-right: 26px; + padding-left: 32px; + } + + .foundation-card, + .component-card { + padding-right: 20px; + padding-left: 20px; + } + } + + @media (max-width: 1360px) { + .showcase-app { + --chrome-height: 88px; + } + + .sidebar, + .top-tabs, + .desktop-only { + display: none; + } + + .topbar { + inset: 0 0 auto; + height: var(--chrome-height); + padding: 0 24px; + } + + .mobile-brand { + display: flex !important; + gap: 13px; + padding: 0; + font-size: 20px !important; + } + + .mobile-brand ga-mark { + width: 42px; + height: 50px; + } + + .top-actions { + gap: 18px; + padding: 0; + } + + .version-button { + width: 96px; + height: 44px; + border-radius: 8px; + font-size: 16px !important; + } + + .icon-button { + width: 48px; + height: 48px; + border-radius: 8px; + } + + .icon-button .icon { + width: 28px; + height: 28px; + stroke-width: 1.4; + } + + .menu-button { + display: inline-flex !important; + } + + .mobile-menu { + position: fixed; + inset: var(--chrome-height) 0 auto; + z-index: 24; + display: grid; + gap: 18px; + transform: translateY(-120%); + border-bottom: 1px solid var(--line); + padding: 22px 31px 28px; + background: var(--surface); + box-shadow: 0 20px 30px rgb(0 0 0 / 10%); + opacity: 0; + pointer-events: none; + transition: transform 200ms ease, opacity 180ms ease; + } + + .mobile-menu:not(.is-open) { + visibility: hidden; + } + + .mobile-menu.is-open { + transform: translateY(0); + opacity: 1; + pointer-events: auto; + } + + .mobile-menu nav { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; + } + + .mobile-menu nav button, + .mobile-copy { + display: flex; + height: 50px; + align-items: center; + gap: 12px; + border: 1px solid var(--line) !important; + border-radius: 7px; + padding: 0 15px; + background: var(--surface) !important; + font-size: 14px !important; + } + + .mobile-copy { + justify-content: center; + color: var(--brand) !important; + } + + .workspace { + margin-left: 0; + padding-top: var(--chrome-height); + } + + .hero { + display: flex; + min-height: 0; + flex-direction: column; + align-items: stretch; + gap: 0; + border-bottom: 0; + padding: 40px 30px 0; + scroll-margin-top: var(--chrome-height); + } + + .hero h1 { + max-width: none; + margin: 0 0 20px; + font-size: clamp(52px, 8vw, 70px); + letter-spacing: -0.064em; + line-height: 1.23; + } + + .hero-copy { + align-self: stretch; + margin: 0 27px; + } + + .hero-copy p { + font-size: 18px; + line-height: 1.65; + } + + .hero-actions { + flex-wrap: wrap; + gap: 27px; + margin-top: 27px; + } + + .hero-actions ga-button { + min-width: 194px; + } + + .hero-actions ga-button::part(button) { + height: 64px; + padding-right: 28px; + padding-left: 28px; + font-size: 18px; + } + + .today-panel { + width: 100%; + min-height: 213px; + margin-top: 29px; + border-radius: 6px; + } + + .panel-heading { + height: 68px; + padding: 0 21px 0 24px; + } + + .panel-heading h2 { + font-size: 22px; + } + + .add-button { + height: 36px; + padding: 0 14px; + font-size: 15px !important; + } + + .task-body { + padding: 22px 21px 20px; + } + + .today-label { + margin-bottom: 14px; + font-size: 15px; + } + + .task-row { + min-height: 61px; + grid-template-columns: 27px 1fr auto 30px; + gap: 14px; + padding-left: 15px; + } + + .task-check { + width: 23px; + height: 23px; + } + + .task-title { + font-size: 16px; + } + + .library-section { + margin-right: 0; + padding-right: 34px; + padding-left: 34px; + scroll-margin-top: var(--chrome-height); + } + + .foundations-section { + height: auto; + min-height: 438px; + overflow: visible; + padding-top: 28px; + padding-bottom: 21px; + } + + .components-section { + height: auto; + min-height: 0; + overflow: visible; + padding-top: 8px; + padding-bottom: 25px; + } + + .section-title { + gap: 24px; + margin-bottom: 17px; + } + + .section-title span { + font-size: 17px; + letter-spacing: 0.09em; + } + + .foundation-grid { + grid-template-columns: 1fr 1fr; + } + + .foundation-card { + padding: 0; + } + + .foundation-card h2, + .component-card h2 { + font-size: 23px; + } + + .color-foundation { + grid-column: 1 / -1; + padding-bottom: 28px; + border-bottom: 1px solid var(--line); + } + + .color-foundation h2 { + margin-bottom: 16px; + } + + .swatches { + justify-content: space-between; + gap: 26px; + } + + .swatch { + width: auto; + height: 52px; + max-width: none; + flex: 1 1 0; + border-radius: 5px; + } + + .token-caption { + margin-top: 19px; + } + + .token-caption code { + font-size: 14px; + } + + .typography-foundation, + .shape-foundation { + min-height: 210px; + margin-top: 26px; + overflow: visible; + } + + .typography-foundation { + padding-right: 35px; + border-left: 0; + } + + .shape-foundation { + padding-left: 34px; + } + + .type-demo { + gap: 49px; + margin-top: 21px; + } + + .type-demo > strong { + font-size: 80px; + } + + .type-demo b { + font-size: 22px; + } + + .type-demo span { + font-size: 18px; + } + + .type-demo small { + font-size: 13px; + } + + .typography-foundation > p { + margin-top: 23px; + font-size: 15px; + white-space: nowrap; + } + + .shape-layout { + grid-template-columns: clamp(110px, 12vw, 134px) minmax(0, 1fr); + gap: clamp(24px, 3.5vw, 48px); + margin-top: 22px; + } + + .shape-stage { + width: 100%; + max-width: 134px; + aspect-ratio: 1; + height: auto; + } + + .shape-stage span { + width: 72%; + height: auto; + border-radius: 20px; + } + + .shape-values p { + margin-bottom: 20px; + font-size: 14px; + line-height: 1.9; + } + + .shape-values small { + font-size: 13px; + } + + .component-grid { + grid-template-columns: 1fr 1fr; + } + + .component-card { + padding-top: 25px; + padding-bottom: 50px; + } + + .actions-card { + grid-column: 1 / -1; + min-height: 234px; + padding: 0 8px 23px; + border-bottom: 1px solid var(--line); + border-left: 0; + } + + .actions-card h2 { + margin-bottom: 22px; + } + + .action-state-grid { + gap: 34px; + } + + .demo-cell { + gap: 13px; + } + + .demo-cell > span { + font-size: 12px; + } + + .button-frame ga-button::part(button) { + width: 100%; + min-width: 0; + height: 37px; + padding-inline: 10px; + font-size: 15px; + } + + .actions-card .text-link { + display: none; + } + + .forms-card { + min-height: 340px; + padding-right: 31px; + padding-left: 0; + border-left: 0; + } + + .feedback-card { + min-height: 340px; + padding-right: 0; + padding-left: 31px; + } + + .forms-card ga-text-field { + margin-top: 16px; + } + + .message-field { + font-size: 12px; + } + + .message-field textarea { + min-height: 66px; + font-size: 12px; + } + + .message-field small { + font-size: 11px; + } + + .form-options { + grid-template-columns: 60px 1fr; + font-size: 12px; + } + + .text-link { + font-size: 11px !important; + } + + .feedback-card ga-feedback { + margin-bottom: 10px; + } + + .feedback-card ga-feedback::part(feedback) { + min-height: 72px; + padding: 13px 16px; + } + + .feedback-card ga-feedback::part(title) { + margin-bottom: 3px; + font-size: 15px; + } + + .feedback-card ga-feedback::part(content) { + font-size: 12px; + line-height: 1.45; + } + + .forms-card .text-link, + .feedback-card .text-link { + display: none; + } + + .inspector { + position: relative; + inset: auto; + z-index: auto; + width: 100%; + min-height: 69px; + overflow: visible; + scrollbar-gutter: auto; + border-top: 1px solid var(--line); + border-left: 0; + } + + .inspector-tabs { + height: 69px; + justify-content: flex-end; + gap: 25px; + padding: 0 32px; + } + + .inspector-mobile-label { + display: flex !important; + margin-right: auto; + align-items: center; + font-size: 17px; + font-weight: 700; + letter-spacing: 0.09em; + } + + .inspector-content, + .code-panel { + padding: 30px 34px 48px; + } + + .patterns-section { + min-height: 0; + margin-right: 0; + padding-top: 24px; + padding-bottom: 36px; + scroll-margin-top: var(--chrome-height); + } + + .pattern-list article { + padding: 22px 24px 0; + } + + .pattern-list h2 { + font-size: 20px; + } + + .pattern-list p { + min-height: 76px; + font-size: 12px; + } + + .pattern-list code { + font-size: 10px; + } + + .live-toast { + right: 30px; + bottom: 24px; + left: 30px; + min-width: 0; + font-size: 13px; + } + } + + @media (max-width: 700px) { + .showcase-app { + --chrome-height: 74px; + } + + .topbar { + height: var(--chrome-height); + padding: 0 18px; + } + + .mobile-brand { + gap: 9px; + font-size: 17px !important; + } + + .mobile-brand ga-mark { + width: 35px; + height: 42px; + } + + .top-actions { + gap: 8px; + } + + .version-button { + width: 72px; + height: 40px; + font-size: 14px !important; + } + + .icon-button { + width: 42px; + height: 42px; + } + + .mobile-menu { + inset: var(--chrome-height) 0 auto; + } + + .workspace { + padding-top: var(--chrome-height); + } + + .hero { + padding: 30px 20px 0; + } + + .hero h1 { + font-size: clamp(38px, 11.8vw, 50px); + line-height: 1.15; + } + + .hero-copy { + margin: 0; + } + + .hero-copy p { + font-size: 15px; + } + + .hero-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .hero-actions ga-button { + display: block; + width: 100%; + min-width: 0; + } + + .hero-actions ga-button::part(button) { + width: 100%; + min-width: 0; + height: 54px; + padding-inline: 12px; + font-size: 14px; + } + + .library-section { + padding-right: 20px; + padding-left: 20px; + } + + .foundations-section, + .components-section { + min-height: 0; + } + + .swatches { + gap: 8px; + } + + .foundation-grid, + .component-grid { + display: block; + } + + .typography-foundation, + .shape-foundation, + .forms-card, + .feedback-card { + height: auto; + min-height: 0; + margin-top: 0; + border-top: 1px solid var(--line); + border-left: 0; + padding: 25px 0; + } + + .shape-layout { + grid-template-columns: minmax(108px, 124px) minmax(0, 1fr); + gap: 25px; + } + + .action-state-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px 10px; + } + + .action-state-grid ga-button::part(button) { + width: 100%; + min-width: 0; + } + + .pattern-list { + display: block; + } + + .pattern-list article, + .pattern-list article:first-child, + .pattern-list article:last-child { + padding: 22px 0; + border-top: 1px solid var(--line); + border-left: 0; + } + + .pattern-list article:first-child { + border-top: 0; + } + + .pattern-list p { + min-height: 0; + } + + .inspector-tabs { + display: grid; + height: auto; + min-height: 116px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + padding: 16px 20px; + } + + .inspector-mobile-label { + grid-column: 1 / -1; + margin: 0 0 2px; + } + + .inspector-tabs ga-tab, + .inspector-tabs ga-tab::part(tab) { + width: 100%; + } + + .task-row { + grid-template-columns: 24px 1fr auto; + } + + .task-row > .more-button { + display: none; + } + } + + @media (max-width: 360px) { + .version-button { + display: none; + } + + .hero-actions { + grid-template-columns: 1fr; + } + + .mobile-menu { + padding-right: 18px; + padding-left: 18px; + } + + .mobile-menu nav { + grid-template-columns: 1fr; + } + + .task-row { + grid-template-columns: 24px minmax(0, 1fr); + gap: 10px; + padding-right: 12px; + padding-left: 12px; + } + + .task-row ga-badge { + display: none; + } + + .task-title { + font-size: 14px; + white-space: nowrap; + } + } + + @media (prefers-reduced-motion: reduce) { + .showcase-app *, + .showcase-app *::before, + .showcase-app *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } + } +`; diff --git a/apps/ui-showcase/tsconfig.json b/apps/ui-showcase/tsconfig.json new file mode 100644 index 0000000..6e44c3c --- /dev/null +++ b/apps/ui-showcase/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@ga-ut/ce": ["../../packages/ce/src/index.ts"], + "@ga-ut/ce/web": ["../../packages/ce/src/web/index.ts"], + "@ga-ut/ce/core": ["../../packages/ce/src/core/index.ts"], + "@ga-ut/ui": ["../../packages/ui/src/index.ts"] + } + }, + "include": ["App.ts", "icons.ts", "styles.ts"] +} diff --git a/bun.lock b/bun.lock index abdb762..ffbbb05 100644 --- a/bun.lock +++ b/bun.lock @@ -18,9 +18,26 @@ "@ga-ut/ce": "workspace:*", }, }, + "apps/ui-showcase": { + "name": "@ga-ut/ui-showcase", + "dependencies": { + "@ga-ut/ce": "workspace:*", + "@ga-ut/ui": "workspace:*", + }, + }, "packages/ce": { "name": "@ga-ut/ce", + "version": "0.1.1", + "bin": { + "ce-cli": "dist/cli/index.mjs", + }, + }, + "packages/ui": { + "name": "@ga-ut/ui", "version": "0.1.0", + "dependencies": { + "@ga-ut/ce": "workspace:*", + }, }, }, "packages": { @@ -98,6 +115,10 @@ "@ga-ut/ce-playground": ["@ga-ut/ce-playground@workspace:apps/playground"], + "@ga-ut/ui": ["@ga-ut/ui@workspace:packages/ui"], + + "@ga-ut/ui-showcase": ["@ga-ut/ui-showcase@workspace:apps/ui-showcase"], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], diff --git a/package.json b/package.json index d58afc8..84d4845 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,13 @@ "apps/*" ], "scripts": { - "lint": "tsc --noEmit -p packages/ce/tsconfig.json && tsc --noEmit -p apps/playground/tsconfig.json && tsc --noEmit -p tsconfig.json", + "lint": "tsc --noEmit -p packages/ce/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/playground/tsconfig.json && tsc --noEmit -p apps/ui-showcase/tsconfig.json && tsc --noEmit -p tsconfig.json", "test": "vitest run", - "build": "bun run --filter @ga-ut/ce build && bun run build:compat && bun run --filter @ga-ut/ce-playground build", + "build": "bun run --filter @ga-ut/ce build && bun run --filter @ga-ut/ui build && bun run build:compat && bun run --filter @ga-ut/ce-playground build && bun run --filter @ga-ut/ui-showcase build", "build:compat": "rm -rf dist && mkdir -p dist && cp -R packages/ce/dist/. dist/", "preview:playground": "bun run --filter @ga-ut/ce-playground preview", - "pack:check": "bun run --filter @ga-ut/ce pack:check", + "preview:ui": "bun run --filter @ga-ut/ui-showcase preview", + "pack:check": "bun run --filter @ga-ut/ce pack:check && bun run --filter @ga-ut/ui pack:check", "test:events": "node --test tests/*.test.mjs", "docs:build": "bun run build && node docs/site/build.mjs", "docs:validate": "node scripts/validate-docs.mjs", diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 0000000..6c1a1f2 --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,83 @@ +# @ga-ut/ui + +GA-UT 제품의 공통 시각 언어와 인터랙션을 담은 비공개 Web Components 패키지입니다. `@ga-ut/ce/web` 위에 구축되며 Tailwind, 외부 UI 프레임워크, 런타임 스타일 의존성이 없습니다. + +## 사용 + +패키지를 한 번 import하면 모든 GA-UT 요소가 자동 등록됩니다. + +```ts +import "@ga-ut/ui"; +``` + +```html + +저장하기 +업데이트 알림 + +``` + +태그 이름이 필요한 라우터나 테스트에서는 등록 결과를 import할 수 있습니다. + +```ts +import { + gaButtonTag, + gaSwitchTag, + gaTextFieldTag, +} from "@ga-ut/ui"; + +// "ga-button", "ga-switch", "ga-text-field" +``` + +## 토큰과 기반 스타일 + +`gaUiTokens`와 `gaUiFoundations`는 각각 CSS 문자열입니다. `gaUiStyles`는 두 문자열을 순서대로 담은 `string[]`이며 CE 앱 전체의 전역 Shadow DOM 스타일로도 사용할 수 있습니다. + +```ts +import { config } from "@ga-ut/ce/web"; +import { gaUiStyles } from "@ga-ut/ui"; + +config({ globalStyles: gaUiStyles }); +``` + +각 GA-UT 컴포넌트에는 이 기반 스타일이 이미 포함되어 있습니다. 주요 의미 토큰은 surface, text, border, accent, success, warning, danger, focus, typography, spacing, radius, shadow, motion으로 나뉩니다. + +밝은 모드는 porcelain `#F7F5F1`, ink `#171816`, accent `#E84D3D`, celadon과 blue focus를 사용합니다. 상위 요소에 `data-theme="dark"` 또는 `.ga-dark`를 지정하면 어두운 의미 토큰으로 전환됩니다. + +```html +
+ 어두운 화면의 버튼 +
+``` + +## 컴포넌트 + +| 태그 | 속성 | 내용 | +| --- | --- | --- | +| `ga-button` | `variant="primary\|outline\|ghost"`, `size="sm\|md\|lg"`, `disabled`, `loading` | 기본 슬롯 | +| `ga-switch` | `checked`, `disabled` | 기본 슬롯 라벨 | +| `ga-text-field` | `label`, `value`, `placeholder`, `disabled`, `invalid` | 없음 | +| `ga-badge` | `tone="success\|warning\|danger\|neutral"` | 기본 슬롯 | +| `ga-feedback` | `tone`, `title`, `dismiss` | 기본 슬롯 메시지 | +| `ga-tab` | `selected`, `disabled` | 기본 슬롯 | +| `ga-mark` | `size="sm\|md\|lg"` | 없음 | + +기본값은 button `primary/md`, 상태 tone `neutral`, mark `md`입니다. 알 수 없는 문자열 값은 해당 기본값으로 안전하게 돌아갑니다. + +## 이벤트 + +상호작용 이벤트는 Shadow DOM 밖에서 사용할 수 있도록 모두 `bubbles: true`, `composed: true`로 전달됩니다. + +```ts +const field = document.querySelector("ga-text-field"); + +field?.addEventListener("ga-input", (event) => { + console.log((event as CustomEvent<{ value: string }>).detail.value); +}); +``` + +- `ga-switch`: `ga-change`, detail `{ checked: boolean }` +- `ga-text-field`: `ga-input`, `ga-change`, detail `{ value: string }` +- `ga-feedback`: `ga-dismiss`; 취소되지 않으면 host에 `hidden`을 설정합니다. + +모든 컨트롤은 네이티브 `button` 또는 `input`을 내부에 사용하며 키보드 포커스, disabled 상태, ARIA 상태를 함께 동기화합니다. 세부 스타일 확장이 필요할 때는 공개된 `part`를 사용할 수 있지만, GA-UT 내부 일관성을 위해 의미 토큰을 우선합니다. diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..b1f4a3c --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,35 @@ +{ + "name": "@ga-ut/ui", + "version": "0.1.0", + "description": "Private GA-UT design tokens and CE-powered web components", + "private": true, + "sideEffects": true, + "license": "UNLICENSED", + "repository": { + "type": "git", + "url": "git+https://github.com/ga-ut/ce.git", + "directory": "packages/ui" + }, + "files": [ + "dist", + "README.md" + ], + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup src/index.ts --format esm,cjs --dts --clean", + "typecheck": "tsc --noEmit -p tsconfig.json", + "pack:check": "bun pm pack --dry-run" + }, + "dependencies": { + "@ga-ut/ce": "workspace:*" + } +} diff --git a/packages/ui/src/components.ts b/packages/ui/src/components.ts new file mode 100644 index 0000000..503307e --- /dev/null +++ b/packages/ui/src/components.ts @@ -0,0 +1,975 @@ +import { define, effect, html } from "@ga-ut/ce/web"; + +import { gaUiStyles } from "./styles"; + +type Lifecycle = { + connected: (callback: () => void) => void; + cleanup: (callback: () => void) => void; +}; + +const enumValue = ( + value: string | null | undefined, + allowed: readonly T[], + fallback: T +): T => (allowed.includes(value as T) ? (value as T) : fallback); + +const hostHas = (host: HTMLElement, name: string) => host.hasAttribute(name); + +const hostValue = (host: HTMLElement, name: string) => + host.getAttribute(name) ?? undefined; + +const observeAttributes = ( + host: HTMLElement, + lifecycle: Lifecycle, + attributeFilter: string[], + sync: () => void +) => { + let observer: MutationObserver | undefined; + + lifecycle.connected(() => { + sync(); + const Observer = host.ownerDocument?.defaultView?.MutationObserver; + if (!Observer) return; + + observer?.disconnect(); + observer = new Observer(sync); + observer.observe(host, { attributes: true, attributeFilter }); + }); + + lifecycle.cleanup(() => { + observer?.disconnect(); + observer = undefined; + }); +}; + +const listenInShadow = ( + host: HTMLElement, + lifecycle: Lifecycle, + eventName: string, + listener: EventListener +) => { + let root: ShadowRoot | null = null; + + lifecycle.connected(() => { + root = host.shadowRoot; + root?.addEventListener(eventName, listener); + }); + + lifecycle.cleanup(() => { + root?.removeEventListener(eventName, listener); + root = null; + }); +}; + +const dispatchGaEvent = ( + host: HTMLElement, + name: string, + detail: T, + options: { cancelable?: boolean } = {} +) => { + const EventConstructor = host.ownerDocument?.defaultView?.CustomEvent; + if (!EventConstructor) return true; + + return host.dispatchEvent( + new EventConstructor(name, { + bubbles: true, + composed: true, + cancelable: options.cancelable ?? false, + detail, + }) + ); +}; + +const gaButtonStyles = ` +:host { + display: inline-flex; + vertical-align: middle; +} + +.button { + align-items: center; + border: 1px solid transparent; + border-radius: var(--ga-radius-sm); + cursor: pointer; + display: inline-flex; + font-weight: var(--ga-font-weight-semibold); + gap: var(--ga-space-2); + justify-content: center; + letter-spacing: -0.012em; + outline: none; + position: relative; + transition: background-color var(--ga-duration-base) var(--ga-ease-out), + border-color var(--ga-duration-base) var(--ga-ease-out), + box-shadow var(--ga-duration-base) var(--ga-ease-out), + color var(--ga-duration-base) var(--ga-ease-out), + transform var(--ga-duration-fast) var(--ga-ease-out); + user-select: none; + white-space: nowrap; +} + +.button[data-size="sm"] { + height: 2rem; + padding: 0 var(--ga-space-3); + font-size: var(--ga-font-size-xs); +} + +.button[data-size="md"] { + height: 2.5rem; + padding: 0 0.95rem; + font-size: var(--ga-font-size-sm); +} + +.button[data-size="lg"] { + height: 3rem; + padding: 0 var(--ga-space-5); + font-size: var(--ga-font-size-md); +} + +.button[data-variant="primary"] { + background: var(--ga-surface-accent); + box-shadow: var(--ga-shadow-xs); + color: var(--ga-text-inverse); +} + +.button[data-variant="primary"]:not(:disabled):hover { + background: var(--ga-surface-accent-hover); + box-shadow: var(--ga-shadow-sm); + transform: translateY(-1px); +} + +.button[data-variant="outline"] { + background: var(--ga-surface-raised); + border-color: color-mix(in srgb, var(--ga-color-accent) 58%, var(--ga-border-subtle)); + box-shadow: var(--ga-shadow-xs); + color: var(--ga-text-accent); +} + +.button[data-variant="outline"]:not(:disabled):hover { + background: color-mix(in srgb, var(--ga-color-accent) 8%, var(--ga-surface-raised)); + border-color: var(--ga-color-accent); +} + +.button[data-variant="ghost"] { + background: transparent; + color: var(--ga-text-primary); +} + +.button[data-variant="ghost"]:not(:disabled):hover { + background: var(--ga-surface-subtle); +} + +.button:focus-visible { + box-shadow: var(--ga-shadow-focus); +} + +.button:not(:disabled):active { + transform: translateY(0) scale(0.985); +} + +.button:disabled { + cursor: not-allowed; + opacity: 0.52; +} + +.spinner { + animation: ga-button-spin 700ms linear infinite; + border: 1.5px solid currentColor; + border-right-color: transparent; + border-radius: var(--ga-radius-round); + display: none; + height: 0.9em; + opacity: 0.82; + width: 0.9em; +} + +.button[data-loading="true"] .spinner { + display: inline-block; +} + +@keyframes ga-button-spin { + to { transform: rotate(360deg); } +} +`; + +export const gaButtonTag = define( + function GaButton({ props, host, lifecycle }) { + const sync = (variant?: string, size?: string, disabled?: boolean, loading?: boolean) => { + const button = host.shadowRoot?.querySelector("button"); + if (!button) return; + + const isLoading = loading ?? hostHas(host, "loading"); + const isDisabled = disabled ?? hostHas(host, "disabled"); + button.dataset.variant = enumValue( + variant ?? hostValue(host, "variant"), + ["primary", "outline", "ghost"] as const, + "primary" + ); + button.dataset.size = enumValue( + size ?? hostValue(host, "size"), + ["sm", "md", "lg"] as const, + "md" + ); + button.dataset.loading = String(isLoading); + button.disabled = isDisabled || isLoading; + button.setAttribute("aria-busy", String(isLoading)); + }; + + effect(() => sync(props.variant, props.size, props.disabled, props.loading)); + observeAttributes(host, lifecycle, ["variant", "size", "disabled", "loading"], () => + sync() + ); + + return ` + + `; + }, + { + props: { + variant: String, + size: String, + disabled: Boolean, + loading: Boolean, + }, + styles: [...gaUiStyles, gaButtonStyles], + } +); + +const gaSwitchStyles = ` +:host { + display: inline-flex; + vertical-align: middle; +} + +.switch { + align-items: center; + background: transparent; + border: 0; + cursor: pointer; + display: inline-flex; + gap: 0.625rem; + margin: 0; + outline: none; + padding: 0; + text-align: left; +} + +.track { + background: var(--ga-border-strong); + border: 1px solid color-mix(in srgb, var(--ga-text-primary) 10%, transparent); + border-radius: var(--ga-radius-round); + display: inline-flex; + flex: 0 0 auto; + height: 1.25rem; + padding: 0.125rem; + transition: background-color var(--ga-duration-base) var(--ga-ease-out), + box-shadow var(--ga-duration-base) var(--ga-ease-out); + width: 2.25rem; +} + +.thumb { + background: var(--ga-surface-raised); + border-radius: var(--ga-radius-round); + box-shadow: 0 1px 3px rgba(23, 24, 22, 0.28); + display: block; + height: 0.875rem; + transform: translateX(0); + transition: transform var(--ga-duration-base) var(--ga-ease-out); + width: 0.875rem; +} + +.switch[data-checked="true"] .track { + background: var(--ga-surface-accent); +} + +.switch[data-checked="true"] .thumb { + transform: translateX(1rem); +} + +.switch:focus-visible .track { + box-shadow: var(--ga-shadow-focus); +} + +.switch:not(:disabled):hover .track { + filter: brightness(0.96); +} + +.switch:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.label { + color: var(--ga-text-primary); + font-size: var(--ga-font-size-sm); + font-weight: var(--ga-font-weight-medium); + line-height: var(--ga-line-tight); +} +`; + +export const gaSwitchTag = define( + function GaSwitch({ props, host, lifecycle }) { + const sync = (checked?: boolean, disabled?: boolean) => { + const button = host.shadowRoot?.querySelector("button"); + if (!button) return; + + const isChecked = checked ?? hostHas(host, "checked"); + const isDisabled = disabled ?? hostHas(host, "disabled"); + button.dataset.checked = String(isChecked); + button.setAttribute("aria-checked", String(isChecked)); + button.disabled = isDisabled; + }; + + effect(() => sync(props.checked, props.disabled)); + observeAttributes(host, lifecycle, ["checked", "disabled"], () => sync()); + listenInShadow(host, lifecycle, "click", () => { + if (hostHas(host, "disabled")) return; + + const checked = !hostHas(host, "checked"); + host.toggleAttribute("checked", checked); + sync(checked, false); + dispatchGaEvent(host, "ga-change", { checked }); + }); + + return ` + + `; + }, + { + props: { + checked: Boolean, + disabled: Boolean, + }, + styles: [...gaUiStyles, gaSwitchStyles], + } +); + +const gaTextFieldStyles = ` +:host { + display: inline-block; + width: 15rem; + max-width: 100%; + vertical-align: middle; +} + +.field { + display: grid; + gap: 0.4375rem; +} + +.label { + color: var(--ga-text-primary); + font-size: var(--ga-font-size-xs); + font-weight: var(--ga-font-weight-semibold); + letter-spacing: -0.006em; + line-height: var(--ga-line-tight); +} + +.label[data-empty="true"] { + display: none; +} + +.control { + align-items: center; + background: var(--ga-surface-raised); + border: 1px solid var(--ga-border-subtle); + border-radius: var(--ga-radius-sm); + box-shadow: var(--ga-shadow-xs); + display: flex; + min-height: 2.625rem; + padding: 0 0.75rem; + transition: background-color var(--ga-duration-base) var(--ga-ease-out), + border-color var(--ga-duration-base) var(--ga-ease-out), + box-shadow var(--ga-duration-base) var(--ga-ease-out); +} + +.control:focus-within { + border-color: var(--ga-color-focus); + box-shadow: var(--ga-shadow-focus); +} + +.control[data-invalid="true"] { + border-color: var(--ga-danger); +} + +.control[data-invalid="true"]:focus-within { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--ga-danger) 28%, transparent); +} + +.input { + background: transparent; + border: 0; + color: var(--ga-text-primary); + flex: 1; + font-size: var(--ga-font-size-sm); + line-height: 1; + min-width: 0; + outline: none; + padding: 0; + width: 100%; +} + +.input::placeholder { + color: var(--ga-text-tertiary); + opacity: 1; +} + +.invalid-mark { + align-items: center; + background: var(--ga-danger-surface); + border-radius: var(--ga-radius-round); + color: var(--ga-danger); + display: none; + flex: 0 0 auto; + font-size: 0.6875rem; + font-weight: var(--ga-font-weight-bold); + height: 1rem; + justify-content: center; + margin-left: var(--ga-space-2); + width: 1rem; +} + +.control[data-invalid="true"] .invalid-mark { + display: inline-flex; +} + +.control[data-disabled="true"] { + background: var(--ga-surface-subtle); + opacity: 0.58; +} +`; + +export const gaTextFieldTag = define( + function GaTextField({ props, host, lifecycle }) { + const sync = (state?: { + label?: string; + value?: string; + placeholder?: string; + disabled?: boolean; + invalid?: boolean; + }) => { + const input = host.shadowRoot?.querySelector("input"); + const label = host.shadowRoot?.querySelector("[part='label']"); + const control = host.shadowRoot?.querySelector(".control"); + if (!input || !label || !control) return; + + const labelText = state?.label ?? hostValue(host, "label") ?? ""; + const placeholder = state?.placeholder ?? hostValue(host, "placeholder") ?? ""; + const disabled = state?.disabled ?? hostHas(host, "disabled"); + const invalid = state?.invalid ?? hostHas(host, "invalid"); + const value = state?.value ?? hostValue(host, "value") ?? ""; + + label.textContent = labelText; + label.dataset.empty = String(!labelText); + if (input.value !== value) input.value = value; + input.placeholder = placeholder; + input.disabled = disabled; + input.setAttribute("aria-invalid", String(invalid)); + input.setAttribute("aria-label", labelText || placeholder || "Text field"); + control.dataset.disabled = String(disabled); + control.dataset.invalid = String(invalid); + }; + + effect(() => + sync({ + label: props.label, + value: props.value, + placeholder: props.placeholder, + disabled: props.disabled, + invalid: props.invalid, + }) + ); + observeAttributes( + host, + lifecycle, + ["label", "value", "placeholder", "disabled", "invalid"], + () => sync() + ); + listenInShadow(host, lifecycle, "input", (event) => { + const input = event.target as HTMLInputElement; + if (!input.matches("input")) return; + + host.setAttribute("value", input.value); + dispatchGaEvent(host, "ga-input", { value: input.value }); + }); + listenInShadow(host, lifecycle, "change", (event) => { + const input = event.target as HTMLInputElement; + if (!input.matches("input")) return; + + host.setAttribute("value", input.value); + dispatchGaEvent(host, "ga-change", { value: input.value }); + }); + + return ` + + `; + }, + { + props: { + label: String, + value: String, + placeholder: String, + disabled: Boolean, + invalid: Boolean, + }, + styles: [...gaUiStyles, gaTextFieldStyles], + } +); + +const gaBadgeStyles = ` +:host { + display: inline-flex; + vertical-align: middle; +} + +.badge { + align-items: center; + background: var(--ga-neutral-surface); + border: 1px solid color-mix(in srgb, currentColor 14%, transparent); + border-radius: var(--ga-radius-round); + color: var(--ga-neutral); + display: inline-flex; + font-size: 0.6875rem; + font-weight: var(--ga-font-weight-semibold); + gap: 0.375rem; + letter-spacing: 0.01em; + line-height: 1; + min-height: 1.5rem; + padding: 0 0.5625rem; + white-space: nowrap; +} + +.dot { + background: currentColor; + border-radius: var(--ga-radius-round); + height: 0.375rem; + opacity: 0.85; + width: 0.375rem; +} + +.badge[data-tone="success"] { background: var(--ga-success-surface); color: var(--ga-success); } +.badge[data-tone="warning"] { background: var(--ga-warning-surface); color: var(--ga-warning); } +.badge[data-tone="danger"] { background: var(--ga-danger-surface); color: var(--ga-danger); } +`; + +export const gaBadgeTag = define( + function GaBadge({ props, host, lifecycle }) { + const sync = (tone?: string) => { + const badge = host.shadowRoot?.querySelector("[part='badge']"); + if (!badge) return; + badge.dataset.tone = enumValue( + tone ?? hostValue(host, "tone"), + ["success", "warning", "danger", "neutral"] as const, + "neutral" + ); + }; + + effect(() => sync(props.tone)); + observeAttributes(host, lifecycle, ["tone"], () => sync()); + + return ` + + + + `; + }, + { + props: { tone: String }, + styles: [...gaUiStyles, gaBadgeStyles], + } +); + +const gaFeedbackStyles = ` +:host { + display: block; +} + +.feedback { + align-items: start; + background: var(--ga-neutral-surface); + border: 1px solid color-mix(in srgb, var(--ga-neutral) 20%, var(--ga-border-subtle)); + border-radius: var(--ga-radius-sm); + color: var(--ga-text-primary); + display: grid; + gap: var(--ga-space-3); + grid-template-columns: auto minmax(0, 1fr) auto; + padding: 0.875rem var(--ga-space-4); +} + +.signal { + color: var(--ga-neutral); + display: block; + height: 1.25rem; + margin-top: 0.0625rem; + width: 1.25rem; +} + +.tone-icon { + display: none; + height: 100%; + overflow: visible; + width: 100%; +} + +.feedback[data-tone="neutral"] .tone-neutral, +.feedback[data-tone="success"] .tone-success, +.feedback[data-tone="warning"] .tone-warning, +.feedback[data-tone="danger"] .tone-danger { + display: block; +} + +.copy { + min-width: 0; +} + +.title { + display: block; + font-size: var(--ga-font-size-sm); + font-weight: var(--ga-font-weight-semibold); + letter-spacing: -0.01em; + line-height: var(--ga-line-tight); + margin-bottom: 0.25rem; +} + +.title[data-empty="true"] { + display: none; +} + +.content { + color: var(--ga-text-secondary); + font-size: var(--ga-font-size-sm); + line-height: var(--ga-line-normal); +} + +.dismiss { + align-items: center; + background: transparent; + border: 0; + border-radius: var(--ga-radius-sm); + color: var(--ga-text-secondary); + cursor: pointer; + display: inline-flex; + height: 1.75rem; + justify-content: center; + margin: -0.25rem -0.375rem 0 0; + outline: none; + padding: 0; + transition: background-color var(--ga-duration-fast) var(--ga-ease-out), + color var(--ga-duration-fast) var(--ga-ease-out); + width: 1.75rem; +} + +.dismiss:hover { + background: color-mix(in srgb, currentColor 10%, transparent); + color: var(--ga-text-primary); +} + +.dismiss:focus-visible { + box-shadow: var(--ga-shadow-focus); +} + +.dismiss svg { + height: 0.875rem; + pointer-events: none; + width: 0.875rem; +} + +.feedback[data-tone="success"] { background: var(--ga-success-surface); border-color: color-mix(in srgb, var(--ga-success) 24%, var(--ga-border-subtle)); } +.feedback[data-tone="success"] .signal { color: var(--ga-success); } +.feedback[data-tone="warning"] { background: var(--ga-warning-surface); border-color: color-mix(in srgb, var(--ga-warning) 24%, var(--ga-border-subtle)); } +.feedback[data-tone="warning"] .signal { color: var(--ga-warning); } +.feedback[data-tone="danger"] { background: var(--ga-danger-surface); border-color: color-mix(in srgb, var(--ga-danger) 24%, var(--ga-border-subtle)); } +.feedback[data-tone="danger"] .signal { color: var(--ga-danger); } +`; + +export const gaFeedbackTag = define( + function GaFeedback({ props, host, lifecycle }) { + const sync = (tone?: string, title?: string, dismiss?: boolean) => { + const feedback = host.shadowRoot?.querySelector("[part='feedback']"); + const titleElement = host.shadowRoot?.querySelector("[part='title']"); + const dismissButton = host.shadowRoot?.querySelector("[part='dismiss']"); + if (!feedback || !titleElement || !dismissButton) return; + + const resolvedTone = enumValue( + tone ?? hostValue(host, "tone"), + ["success", "warning", "danger", "neutral"] as const, + "neutral" + ); + const titleText = title ?? hostValue(host, "title") ?? ""; + feedback.dataset.tone = resolvedTone; + feedback.setAttribute("role", resolvedTone === "danger" ? "alert" : "status"); + titleElement.textContent = titleText; + titleElement.dataset.empty = String(!titleText); + dismissButton.hidden = !(dismiss ?? hostHas(host, "dismiss")); + }; + + effect(() => sync(props.tone, props.title, props.dismiss)); + observeAttributes(host, lifecycle, ["tone", "title", "dismiss"], () => sync()); + const dismissFeedback = (event: Event) => { + event.stopPropagation(); + const shouldDismiss = dispatchGaEvent(host, "ga-dismiss", {}, { cancelable: true }); + if (shouldDismiss) host.setAttribute("hidden", ""); + }; + + return html` + + `; + }, + { + props: { + tone: String, + title: String, + dismiss: Boolean, + }, + styles: [...gaUiStyles, gaFeedbackStyles], + } +); + +const gaTabStyles = ` +:host { + display: inline-flex; + vertical-align: middle; +} + +.tab { + align-items: center; + background: transparent; + border: 1px solid transparent; + border-radius: var(--ga-radius-sm); + color: var(--ga-text-secondary); + cursor: pointer; + display: inline-flex; + font-size: var(--ga-font-size-sm); + font-weight: var(--ga-font-weight-medium); + height: 2.25rem; + justify-content: center; + letter-spacing: -0.01em; + outline: none; + padding: 0 var(--ga-space-3); + transition: background-color var(--ga-duration-base) var(--ga-ease-out), + border-color var(--ga-duration-base) var(--ga-ease-out), + box-shadow var(--ga-duration-base) var(--ga-ease-out), + color var(--ga-duration-base) var(--ga-ease-out); + white-space: nowrap; +} + +.tab:not(:disabled):hover { + background: var(--ga-surface-subtle); + color: var(--ga-text-primary); +} + +.tab[data-selected="true"] { + background: var(--ga-surface-raised); + border-color: var(--ga-border-subtle); + box-shadow: var(--ga-shadow-xs); + color: var(--ga-text-primary); + font-weight: var(--ga-font-weight-semibold); +} + +.tab:focus-visible { + box-shadow: var(--ga-shadow-focus); +} + +.tab:disabled { + cursor: not-allowed; + opacity: 0.45; +} +`; + +export const gaTabTag = define( + function GaTab({ props, host, lifecycle }) { + const syncTabStops = (current: { selected: boolean; disabled: boolean }) => { + const tablist = host.closest('[role~="tablist"]'); + if (!tablist) { + const control = host.shadowRoot?.querySelector("button"); + if (control) control.tabIndex = current.disabled ? -1 : 0; + return; + } + + const tabs = Array.from(tablist.querySelectorAll("ga-tab")).filter( + (element) => element.closest('[role~="tablist"]') === tablist + ); + const isDisabled = (element: HTMLElement) => + element === host ? current.disabled : element.hasAttribute("disabled"); + const isSelected = (element: HTMLElement) => + element === host ? current.selected : element.hasAttribute("selected"); + const activeTab = + tabs.find((element) => !isDisabled(element) && isSelected(element)) ?? + tabs.find((element) => !isDisabled(element)); + + for (const element of tabs) { + const control = element.shadowRoot?.querySelector("button"); + if (control) control.tabIndex = element === activeTab ? 0 : -1; + } + }; + + const sync = (selected?: boolean, disabled?: boolean) => { + const tab = host.shadowRoot?.querySelector("button"); + if (!tab) return; + + const isSelected = selected ?? hostHas(host, "selected"); + const isDisabled = disabled ?? hostHas(host, "disabled"); + tab.dataset.selected = String(isSelected); + tab.setAttribute("aria-selected", String(isSelected)); + tab.disabled = isDisabled; + syncTabStops({ selected: isSelected, disabled: isDisabled }); + }; + + effect(() => sync(props.selected, props.disabled)); + observeAttributes(host, lifecycle, ["selected", "disabled"], () => sync()); + listenInShadow(host, lifecycle, "keydown", (event) => { + const keyboardEvent = event as KeyboardEvent; + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(keyboardEvent.key)) return; + + const tablist = host.closest('[role~="tablist"]'); + const roles = tablist?.getAttribute("role")?.trim().split(/\s+/) ?? []; + if (!tablist || !roles.includes("tablist")) return; + + const enabledTabs = Array.from(tablist.querySelectorAll("ga-tab")).filter( + (element): element is HTMLElement => + element.closest('[role~="tablist"]') === tablist && + !element.hasAttribute("disabled") + ); + const currentIndex = enabledTabs.indexOf(host); + if (currentIndex < 0 || enabledTabs.length === 0) return; + + let nextIndex = currentIndex; + if (keyboardEvent.key === "Home") nextIndex = 0; + if (keyboardEvent.key === "End") nextIndex = enabledTabs.length - 1; + if (keyboardEvent.key === "ArrowLeft") { + nextIndex = (currentIndex - 1 + enabledTabs.length) % enabledTabs.length; + } + if (keyboardEvent.key === "ArrowRight") { + nextIndex = (currentIndex + 1) % enabledTabs.length; + } + + const nextControl = enabledTabs[nextIndex]?.shadowRoot?.querySelector( + 'button[part="tab"]' + ); + if (!nextControl) return; + + keyboardEvent.preventDefault(); + nextControl.focus(); + nextControl.click(); + }); + + return ` + + `; + }, + { + props: { + selected: Boolean, + disabled: Boolean, + }, + styles: [...gaUiStyles, gaTabStyles], + } +); + +const gaMarkStyles = ` +:host { + display: inline-flex; + vertical-align: middle; +} + +.mark { + color: var(--ga-color-accent); + display: block; + height: 1.5rem; + user-select: none; + width: 1.5rem; +} + +.mark[data-size="sm"] { height: 1.125rem; width: 1.125rem; } +.mark[data-size="md"] { height: 1.5rem; width: 1.5rem; } +.mark[data-size="lg"] { height: 2rem; width: 2rem; } + +.mark svg { + display: block; + height: 100%; + overflow: visible; + width: 100%; +} + +.mark-contour, +.mark-u, +.mark-t { + fill: none; + stroke-linecap: square; + stroke-linejoin: miter; + vector-effect: non-scaling-stroke; +} + +.mark-contour { + stroke: currentColor; + stroke-width: 2.4; +} + +.mark-u { + stroke: currentColor; + stroke-width: 2.15; +} + +.mark-t { + stroke: var(--ga-color-accent); + stroke-width: 2.6; +} +`; + +export const gaMarkTag = define( + function GaMark({ props, host, lifecycle }) { + const sync = (size?: string) => { + const mark = host.shadowRoot?.querySelector("[part='mark']"); + if (!mark) return; + mark.dataset.size = enumValue( + size ?? hostValue(host, "size"), + ["sm", "md", "lg"] as const, + "md" + ); + }; + + effect(() => sync(props.size)); + observeAttributes(host, lifecycle, ["size"], () => sync()); + + return ` + + + + `; + }, + { + props: { size: String }, + styles: [...gaUiStyles, gaMarkStyles], + } +); diff --git a/packages/ui/src/foundations.ts b/packages/ui/src/foundations.ts new file mode 100644 index 0000000..cbf54d6 --- /dev/null +++ b/packages/ui/src/foundations.ts @@ -0,0 +1,49 @@ +export const gaUiFoundations = ` +:host { + box-sizing: border-box; + color: var(--ga-text-primary); + font-family: var(--ga-font-sans); + font-size: var(--ga-font-size-md); + line-height: var(--ga-line-normal); + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +:host([hidden]) { + display: none !important; +} + +*, +*::before, +*::after { + box-sizing: inherit; +} + +button, +input, +textarea, +select { + color: inherit; + font: inherit; +} + +button { + -webkit-tap-highlight-color: transparent; +} + +::selection { + background: color-mix(in srgb, var(--ga-color-accent) 24%, transparent); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} +`; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts new file mode 100644 index 0000000..09214f2 --- /dev/null +++ b/packages/ui/src/index.ts @@ -0,0 +1,12 @@ +export { gaUiFoundations } from "./foundations"; +export { gaUiStyles } from "./styles"; +export { gaUiTokens } from "./tokens"; +export { + gaBadgeTag, + gaButtonTag, + gaFeedbackTag, + gaMarkTag, + gaSwitchTag, + gaTabTag, + gaTextFieldTag, +} from "./components"; diff --git a/packages/ui/src/styles.ts b/packages/ui/src/styles.ts new file mode 100644 index 0000000..2db1942 --- /dev/null +++ b/packages/ui/src/styles.ts @@ -0,0 +1,5 @@ +import { gaUiFoundations } from "./foundations"; +import { gaUiTokens } from "./tokens"; + +/** Styles that can be handed directly to CE's `config({ globalStyles })`. */ +export const gaUiStyles: string[] = [gaUiTokens, gaUiFoundations]; diff --git a/packages/ui/src/tokens.ts b/packages/ui/src/tokens.ts new file mode 100644 index 0000000..16ad3ed --- /dev/null +++ b/packages/ui/src/tokens.ts @@ -0,0 +1,124 @@ +/** + * GA-UT's shared design decisions as portable CSS. + * + * The semantic layer deliberately sits above the raw brand colors so the + * components can support dark surfaces without changing their public API. + */ +export const gaUiTokens = ` +:root, +:host { + --ga-color-porcelain: #f7f5f1; + --ga-color-ink: #171816; + --ga-color-accent: #e84d3d; + --ga-color-celadon: #b9cdbd; + --ga-color-focus: #246bfe; + + --ga-surface-canvas: #f7f5f1; + --ga-surface-subtle: #efede7; + --ga-surface-raised: #fffefa; + --ga-surface-inverse: #171816; + --ga-surface-accent: #cf392c; + --ga-surface-accent-hover: #b92f25; + --ga-surface-celadon: #dfe9e1; + + --ga-text-primary: #171816; + --ga-text-secondary: #5f625c; + --ga-text-tertiary: #74776f; + --ga-text-inverse: #fffefa; + --ga-text-accent: #b92f25; + + --ga-border-subtle: #dedbd4; + --ga-border-strong: #bdbab2; + --ga-border-inverse: #343630; + --ga-focus-ring: rgba(36, 107, 254, 0.3); + + --ga-success: #377653; + --ga-success-surface: #e5eee7; + --ga-warning: #9a5a14; + --ga-warning-surface: #f7ead5; + --ga-danger: #b9342a; + --ga-danger-surface: #f8e3df; + --ga-neutral: #5f625c; + --ga-neutral-surface: #ebe9e3; + + --ga-font-sans: "SUIT Variable", "Pretendard Variable", Pretendard, Inter, + ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --ga-font-display: "GA-UT Serif", "Iowan Old Style", "Palatino Linotype", + Palatino, Georgia, serif; + --ga-font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --ga-font-size-xs: 0.75rem; + --ga-font-size-sm: 0.875rem; + --ga-font-size-md: 1rem; + --ga-font-size-lg: 1.125rem; + --ga-font-size-xl: 1.375rem; + --ga-font-weight-regular: 450; + --ga-font-weight-medium: 590; + --ga-font-weight-semibold: 680; + --ga-font-weight-bold: 780; + --ga-line-tight: 1.2; + --ga-line-normal: 1.5; + + --ga-space-1: 0.25rem; + --ga-space-2: 0.5rem; + --ga-space-3: 0.75rem; + --ga-space-4: 1rem; + --ga-space-5: 1.25rem; + --ga-space-6: 1.5rem; + --ga-space-8: 2rem; + --ga-space-10: 2.5rem; + --ga-space-12: 3rem; + --ga-space-16: 4rem; + + --ga-radius-sm: 0.5rem; + --ga-radius-md: 0.75rem; + --ga-radius-lg: 1rem; + --ga-radius-round: 999px; + + --ga-shadow-xs: 0 1px 2px rgba(23, 24, 22, 0.08); + --ga-shadow-sm: 0 1px 2px rgba(23, 24, 22, 0.08), + 0 5px 14px rgba(23, 24, 22, 0.05); + --ga-shadow-focus: 0 0 0 3px var(--ga-focus-ring); + + --ga-duration-fast: 120ms; + --ga-duration-base: 180ms; + --ga-ease-out: cubic-bezier(0.2, 0.8, 0.2, 1); +} + +:root[data-theme="dark"], +:host([data-theme="dark"]), +:host-context([data-theme="dark"]), +:host-context(.ga-dark), +:host-context(.theme-dark) { + --ga-surface-canvas: #171816; + --ga-surface-subtle: #20221f; + --ga-surface-raised: #282a26; + --ga-surface-inverse: #f7f5f1; + --ga-surface-accent: #f06455; + --ga-surface-accent-hover: #ff7565; + --ga-surface-celadon: #263a2d; + + --ga-text-primary: #f7f5f1; + --ga-text-secondary: #c3c4be; + --ga-text-tertiary: #999c94; + --ga-text-inverse: #171816; + --ga-text-accent: #ff8b7e; + + --ga-border-subtle: #363934; + --ga-border-strong: #50534c; + --ga-border-inverse: #dedbd4; + --ga-focus-ring: rgba(36, 107, 254, 0.48); + + --ga-success: #94cba5; + --ga-success-surface: #22382a; + --ga-warning: #f0b867; + --ga-warning-surface: #422f1d; + --ga-danger: #ff8b7e; + --ga-danger-surface: #442824; + --ga-neutral: #c3c4be; + --ga-neutral-surface: #30322f; + + --ga-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.28); + --ga-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.32), + 0 7px 18px rgba(0, 0, 0, 0.18); +} +`; diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json new file mode 100644 index 0000000..19b273a --- /dev/null +++ b/packages/ui/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@ga-ut/ce/web": ["../ce/src/web/index.ts"] + } + }, + "include": ["src"] +} diff --git a/test/ui.spec.ts b/test/ui.spec.ts new file mode 100644 index 0000000..ed951b2 --- /dev/null +++ b/test/ui.spec.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { gaUiStyles, gaUiTokens } from "../packages/ui/src"; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +const mount = async (tagName: string): Promise => { + const element = document.createElement(tagName) as T; + document.body.append(element); + await flush(); + return element; +}; + +describe("@ga-ut/ui", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + it("registers the GA-UT component set and exports shared tokens", () => { + for (const tagName of [ + "ga-button", + "ga-switch", + "ga-text-field", + "ga-badge", + "ga-feedback", + "ga-tab", + "ga-mark", + ]) { + expect(customElements.get(tagName), `${tagName} should be registered`).toBeDefined(); + } + + expect(gaUiTokens).toEqual(expect.any(String)); + expect(gaUiTokens).toContain("--ga-"); + expect(gaUiStyles).toContain(gaUiTokens); + expect(gaUiStyles.length).toBeGreaterThan(1); + }); + + it("forwards button variant and disabled state to the native control", async () => { + const element = await mount("ga-button"); + + element.setAttribute("variant", "outline"); + element.setAttribute("disabled", ""); + await flush(); + + const button = element.shadowRoot?.querySelector( + 'button[part="button"]' + ); + + expect(button).toBeInstanceOf(HTMLButtonElement); + expect(button?.dataset.variant).toBe("outline"); + expect(button?.disabled).toBe(true); + }); + + it("renders the GA-UT mark as a scalable geometric symbol", async () => { + const element = await mount("ga-mark"); + element.setAttribute("size", "lg"); + await flush(); + + const mark = element.shadowRoot?.querySelector('[part="mark"]'); + expect(mark?.dataset.size).toBe("lg"); + expect(mark?.querySelector("svg")).toBeInstanceOf(SVGElement); + expect(mark?.textContent?.trim()).toBe(""); + }); + + it("updates a switch and emits a composed ga-change event", async () => { + const changes: CustomEvent<{ checked: boolean }>[] = []; + const onChange = (event: Event) => { + changes.push(event as CustomEvent<{ checked: boolean }>); + }; + document.body.addEventListener("ga-change", onChange); + + const element = await mount("ga-switch"); + const button = element.shadowRoot?.querySelector( + 'button[part="switch"][role="switch"]' + ); + + button?.click(); + await flush(); + + document.body.removeEventListener("ga-change", onChange); + expect(element.hasAttribute("checked")).toBe(true); + expect(button?.getAttribute("aria-checked")).toBe("true"); + expect(changes).toHaveLength(1); + expect(changes[0]?.detail).toEqual({ checked: true }); + expect(changes[0]?.bubbles).toBe(true); + expect(changes[0]?.composed).toBe(true); + }); + + it("syncs text input values and emits ga-input", async () => { + const inputs: CustomEvent<{ value: string }>[] = []; + const element = await mount("ga-text-field"); + element.addEventListener("ga-input", (event) => { + inputs.push(event as CustomEvent<{ value: string }>); + }); + + const input = element.shadowRoot?.querySelector('input[part="input"]'); + expect(input).toBeInstanceOf(HTMLInputElement); + + if (!input) throw new Error("ga-text-field did not render its input"); + input.value = "GA-UT"; + input.dispatchEvent(new Event("input", { bubbles: true, composed: true })); + await flush(); + + expect(element.getAttribute("value")).toBe("GA-UT"); + expect(inputs).toHaveLength(1); + expect(inputs[0]?.detail).toEqual({ value: "GA-UT" }); + }); + + it("dismisses feedback after emitting ga-dismiss", async () => { + const onDismiss = vi.fn(); + const element = document.createElement("ga-feedback") as HTMLElement; + element.setAttribute("dismiss", ""); + element.addEventListener("ga-dismiss", onDismiss); + document.body.append(element); + await flush(); + + const dismiss = element.shadowRoot?.querySelector( + 'button[part="dismiss"]' + ); + expect(dismiss).toBeInstanceOf(HTMLButtonElement); + + dismiss?.click(); + await flush(); + + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(onDismiss.mock.calls[0]?.[0]).toMatchObject({ cancelable: true }); + expect(element.hasAttribute("hidden")).toBe(true); + }); + + it("keeps feedback visible when ga-dismiss is prevented", async () => { + const element = document.createElement("ga-feedback") as HTMLElement; + element.setAttribute("dismiss", ""); + element.addEventListener("ga-dismiss", (event) => event.preventDefault()); + document.body.append(element); + await flush(); + + const dismiss = element.shadowRoot?.querySelector( + 'button[part="dismiss"]' + ); + dismiss?.click(); + await flush(); + + expect(element.hasAttribute("hidden")).toBe(false); + }); + + it("uses selected state for a tablist's roving tabindex", async () => { + const tablist = document.createElement("div"); + tablist.setAttribute("role", "tablist"); + const selected = document.createElement("ga-tab") as HTMLElement; + const unselected = document.createElement("ga-tab") as HTMLElement; + const disabled = document.createElement("ga-tab") as HTMLElement; + selected.setAttribute("selected", ""); + disabled.setAttribute("disabled", ""); + tablist.append(selected, unselected, disabled); + document.body.append(tablist); + await flush(); + + const selectedControl = selected.shadowRoot?.querySelector( + 'button[part="tab"]' + ); + const unselectedControl = unselected.shadowRoot?.querySelector( + 'button[part="tab"]' + ); + const disabledControl = disabled.shadowRoot?.querySelector( + 'button[part="tab"]' + ); + + expect(selectedControl?.tabIndex).toBe(0); + expect(unselectedControl?.tabIndex).toBe(-1); + expect(disabledControl?.tabIndex).toBe(-1); + }); + + it("keeps a tablist keyboard-reachable without an explicit selection", async () => { + const tablist = document.createElement("div"); + tablist.setAttribute("role", "tablist"); + const first = document.createElement("ga-tab") as HTMLElement; + const second = document.createElement("ga-tab") as HTMLElement; + tablist.append(first, second); + document.body.append(tablist); + await flush(); + + const firstControl = first.shadowRoot?.querySelector( + 'button[part="tab"]' + ); + const secondControl = second.shadowRoot?.querySelector( + 'button[part="tab"]' + ); + + expect(firstControl?.tabIndex).toBe(0); + expect(secondControl?.tabIndex).toBe(-1); + }); + + it.each([ + ["ArrowRight", 0, 2], + ["ArrowLeft", 0, 2], + ["Home", 2, 0], + ["End", 0, 2], + ])("moves tab focus with %s and skips disabled tabs", async (key, startIndex, targetIndex) => { + const tablist = document.createElement("div"); + tablist.setAttribute("role", "tablist"); + const tabs = Array.from({ length: 3 }, () => document.createElement("ga-tab") as HTMLElement); + tabs[1]?.setAttribute("disabled", ""); + const wrappers = tabs.map((tab) => { + const wrapper = document.createElement("span"); + wrapper.append(tab); + return wrapper; + }); + tablist.append(...wrappers); + document.body.append(tablist); + await flush(); + + const startControl = tabs[startIndex]?.shadowRoot?.querySelector( + 'button[part="tab"]' + ); + const targetControl = tabs[targetIndex]?.shadowRoot?.querySelector( + 'button[part="tab"]' + ); + const onActivate = vi.fn(); + targetControl?.addEventListener("click", onActivate); + + startControl?.dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true, composed: true, cancelable: true }) + ); + await flush(); + + expect(tabs[targetIndex]?.shadowRoot?.activeElement).toBe(targetControl); + expect(onActivate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 60a29dc..aa1bfc5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,10 @@ { "extends": "./tsconfig.base.json", "compilerOptions": { + "baseUrl": ".", + "paths": { + "@ga-ut/ce/web": ["packages/ce/src/web/index.ts"] + }, "types": ["vitest/globals"] }, "include": ["test", "tests", "vitest.config.ts"] diff --git a/vitest.config.ts b/vitest.config.ts index cbfd6c3..9098e88 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,14 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + alias: { + "@ga-ut/ce/web": new URL( + "./packages/ce/src/web/index.ts", + import.meta.url + ).pathname, + }, + }, test: { environment: "jsdom", include: ["test/**/*.spec.ts", "tests/**/*.test.ts"],