diff --git a/src/presentation/components/async-surface.jsx b/src/presentation/components/async-surface.jsx index d86223f..e005d7c 100644 --- a/src/presentation/components/async-surface.jsx +++ b/src/presentation/components/async-surface.jsx @@ -1,23 +1,40 @@ +import { useId } from "react"; + +import { errorMessage } from "./error-copy.js"; +import { Button } from "./ui/button.jsx"; + /** @param {{ label?: string }} props */ export function LoadingSurface({ label = "불러오는 중" }) { return (
); } -/** @param {{ title?: string, action?: React.ReactNode }} props */ -export function EmptySurface({ title = "표시할 항목이 없습니다.", action }) { +/** + * @param {{ + * title?: string, + * description?: string, + * action?: React.ReactNode + * }} props + */ +export function EmptySurface({ + title = "표시할 항목이 없습니다.", + description, + action, +}) { return ( -
-

{title}

+
+

{title}

+ {description ?

{description}

: null} {action}
); @@ -32,17 +49,24 @@ export function EmptySurface({ title = "표시할 항목이 없습니다.", acti * }} props */ export function TerminalErrorSurface({ userMessageKey, action, onAction }) { + const messageId = useId(); + const actionLabels = Object.freeze({ + retry: "다시 시도", + reauth: "로그인", + navigate: "안전한 화면으로 이동", + "reload-once": "한 번 새로고침", + "contact-support": "지원 정보 확인", + }); return (
-

{userMessageKey}

+

{errorMessage(userMessageKey)}

{action !== "none" && ( - + )}
); diff --git a/src/presentation/components/error-copy.js b/src/presentation/components/error-copy.js new file mode 100644 index 0000000..504e3af --- /dev/null +++ b/src/presentation/components/error-copy.js @@ -0,0 +1,21 @@ +const ERROR_MESSAGES = Object.freeze({ + "error.network_unreachable": "네트워크에 연결할 수 없습니다.", + "error.request_timeout": "요청 시간이 초과되었습니다.", + "error.auth_required": "계속하려면 로그인이 필요합니다.", + "error.auth_integration_failure": "로그인 연동을 사용할 수 없습니다.", + "error.forbidden": "이 작업을 수행할 권한이 없습니다.", + "error.not_found": "요청한 항목을 찾을 수 없습니다.", + "error.rate_limited": "요청이 많습니다. 잠시 후 다시 시도해 주세요.", + "error.server_failure": "요청을 완료하지 못했습니다.", + "error.chunk_load_failure": "새 화면 파일을 불러오지 못했습니다.", + "error.render_failure": "화면을 표시하지 못했습니다.", + "error.unknown_failure": "예상하지 못한 문제가 발생했습니다.", +}); + +/** @param {string} messageKey */ +export function errorMessage(messageKey) { + const messages = /** @type {Readonly>} */ ( + ERROR_MESSAGES + ); + return messages[messageKey] ?? "요청을 완료하지 못했습니다."; +} diff --git a/src/presentation/components/state-surfaces.jsx b/src/presentation/components/state-surfaces.jsx new file mode 100644 index 0000000..1526779 --- /dev/null +++ b/src/presentation/components/state-surfaces.jsx @@ -0,0 +1,69 @@ +import { Button } from "./ui/button.jsx"; + +/** + * @param {{ + * eyebrow: string, + * title: string, + * description: string, + * actionLabel?: string, + * onAction?: () => void, + * tone?: "neutral" | "danger" | "warning" + * }} props + */ +function StateSurface({ + eyebrow, + title, + description, + actionLabel, + onAction, + tone = "neutral", +}) { + return ( +
+

{eyebrow}

+

{title}

+

{description}

+ {actionLabel ? : null} +
+ ); +} + +/** @param {{ onSignIn?: () => void }} props */ +export function AuthRequiredSurface({ onSignIn }) { + return ( + + ); +} + +/** @param {{ onNavigate?: () => void }} props */ +export function ForbiddenSurface({ onNavigate }) { + return ( + + ); +} + +/** @param {{ onNavigate?: () => void }} props */ +export function NotFoundSurface({ onNavigate }) { + return ( + + ); +} diff --git a/src/presentation/components/ui/alert.jsx b/src/presentation/components/ui/alert.jsx new file mode 100644 index 0000000..26beed8 --- /dev/null +++ b/src/presentation/components/ui/alert.jsx @@ -0,0 +1,36 @@ +/** + * @param {{ + * title: string, + * children?: React.ReactNode, + * variant?: "info" | "success" | "warning" | "danger", + * onDismiss?: () => void + * }} props + */ +export function Alert({ + title, + children, + variant = "info", + onDismiss, +}) { + return ( +
+
+ {title} + {children ?
{children}
: null} +
+ {onDismiss ? ( + + ) : null} +
+ ); +} diff --git a/src/presentation/components/ui/badge.jsx b/src/presentation/components/ui/badge.jsx new file mode 100644 index 0000000..a4ae2ff --- /dev/null +++ b/src/presentation/components/ui/badge.jsx @@ -0,0 +1,9 @@ +/** + * @param {{ + * children: React.ReactNode, + * variant?: "neutral" | "info" | "success" | "warning" | "danger" + * }} props + */ +export function Badge({ children, variant = "neutral" }) { + return {children}; +} diff --git a/src/presentation/components/ui/button.jsx b/src/presentation/components/ui/button.jsx new file mode 100644 index 0000000..7c7e312 --- /dev/null +++ b/src/presentation/components/ui/button.jsx @@ -0,0 +1,24 @@ +/** + * @param {React.ButtonHTMLAttributes & { + * variant?: "primary" | "secondary" | "danger" | "ghost", + * size?: "default" | "compact" + * }} props + */ +export function Button({ + variant = "primary", + size = "default", + className = "", + type = "button", + ...props +}) { + const classes = [ + "ui-button", + `ui-button--${variant}`, + size === "compact" ? "ui-button--compact" : "", + className, + ] + .filter(Boolean) + .join(" "); + + return + + {children ?
{children}
: null} + {actions ?
{actions}
: null} + + + ); +} diff --git a/src/presentation/components/ui/text-field.jsx b/src/presentation/components/ui/text-field.jsx new file mode 100644 index 0000000..4ffb5b6 --- /dev/null +++ b/src/presentation/components/ui/text-field.jsx @@ -0,0 +1,52 @@ +import { useId } from "react"; + +/** + * @param {Omit, "id"> & { + * id?: string, + * label: string, + * description?: string, + * error?: string + * }} props + */ +export function TextField({ + id, + label, + description, + error, + className = "", + required, + ...inputProps +}) { + const generatedId = useId(); + const inputId = id ?? `field-${generatedId}`; + const descriptionId = description ? `${inputId}-description` : undefined; + const errorId = error ? `${inputId}-error` : undefined; + const describedBy = [descriptionId, errorId].filter(Boolean).join(" "); + + return ( +
+ + {description ? ( +

+ {description} +

+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/src/presentation/examples/state-gallery-page.jsx b/src/presentation/examples/state-gallery-page.jsx index 560eb28..ab7fd4d 100644 --- a/src/presentation/examples/state-gallery-page.jsx +++ b/src/presentation/examples/state-gallery-page.jsx @@ -1,24 +1,98 @@ +import { useState } from "react"; + +import { deriveAsyncState } from "../../application/view-models/async-state.js"; +import { createFailure } from "../../contracts/errors.js"; +import { + AsyncSurface, + EmptySurface, + LoadingSurface, + TerminalErrorSurface, +} from "../components/async-surface.jsx"; import { PageHeader } from "../components/page-header.jsx"; +import { + AuthRequiredSurface, + ForbiddenSurface, + NotFoundSurface, +} from "../components/state-surfaces.jsx"; +import { Button } from "../components/ui/button.jsx"; +import { Card } from "../components/ui/card.jsx"; export default function StateGalleryPage() { + const [lastAction, setLastAction] = useState( + "상태 화면의 작업을 선택하면 결과가 여기에 표시됩니다.", + ); + const refreshingState = deriveAsyncState({ + data: ["기존 데이터"], + isFetching: true, + }); + return (
-
-
-
-

표시할 항목이 없습니다.

-

다음 행동이 있다면 이 위치에 명확한 안내를 제공합니다.

-
-
-

요청을 완료하지 못했습니다.

-

안전한 재시도 또는 지원 참조 정보를 제공합니다.

-
-
+ +
+
+

비동기 데이터 상태

+

초기 로딩과 백그라운드 갱신을 구분해 기존 콘텐츠를 보존합니다.

+
+
+ + + + + +
기존 콘텐츠는 계속 표시됩니다.
+
+
+ + setLastAction("빈 화면 작업을 실행했습니다.")}> + 첫 작업 시작 + + } + /> + + + setLastAction("오류 요청을 다시 시도했습니다.")} + /> + +
+
+ +
+
+

접근과 탐색 상태

+

인증 여부와 서버 권한 결과를 서로 다른 상태로 전달합니다.

+
+
+ setLastAction("로그인 연동 작업을 시작했습니다.")} + /> + setLastAction("접근 가능한 화면으로 이동합니다.")} + /> + setLastAction("시작 화면으로 이동합니다.")} + /> +
+
+ + + {lastAction} +
); } diff --git a/src/presentation/examples/ui-gallery-page.jsx b/src/presentation/examples/ui-gallery-page.jsx index 8b6480b..b28c3b7 100644 --- a/src/presentation/examples/ui-gallery-page.jsx +++ b/src/presentation/examples/ui-gallery-page.jsx @@ -1,20 +1,185 @@ +import { useState } from "react"; + import { PageHeader } from "../components/page-header.jsx"; +import { Alert } from "../components/ui/alert.jsx"; +import { Badge } from "../components/ui/badge.jsx"; +import { Button } from "../components/ui/button.jsx"; +import { Card } from "../components/ui/card.jsx"; +import { Dialog } from "../components/ui/dialog.jsx"; +import { TextField } from "../components/ui/text-field.jsx"; + +const COLOR_TOKENS = Object.freeze([ + ["Surface", "--color-surface"], + ["Muted surface", "--color-surface-muted"], + ["Content", "--color-content"], + ["Muted content", "--color-content-muted"], + ["Action", "--color-action"], + ["Danger", "--color-danger"], + ["Focus", "--color-focus"], +]); export default function UiGalleryPage() { + const [projectName, setProjectName] = useState(""); + const [fieldTouched, setFieldTouched] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); + const [notice, setNotice] = useState( + "구성요소를 조작하면 결과가 여기에 표시됩니다.", + ); + const [alertVisible, setAlertVisible] = useState(true); + const fieldError = + fieldTouched && projectName.trim().length === 0 + ? "프로젝트 이름을 입력해 주세요." + : undefined; + + /** @param {React.FormEvent} event */ + function submitExample(event) { + event.preventDefault(); + setFieldTouched(true); + if (projectName.trim().length === 0) { + setNotice("입력값을 확인해 주세요."); + return; + } + setNotice(`“${projectName.trim()}” 입력을 확인했습니다.`); + } + return (
-
- -

- 버튼, 입력창, 카드, 알림, 모달의 상호작용과 디자인 토큰을 이 - 라우트에 조립합니다. -

+ +
+
+

버튼과 입력

+

키보드, 비활성 상태, 오류 설명을 포함한 기본 상호작용입니다.

+
+
+ +
+ + + + +
+
+ +
+ setProjectName(event.currentTarget.value)} + /> + + +
+
+ +
+
+

피드백과 모달

+

상태 전달은 색에만 의존하지 않으며, 모든 제어에는 이름이 있습니다.

+
+
+ +
+ {alertVisible ? ( + setAlertVisible(false)} + > +

운영 환경에는 실제 저장 포트를 연결하세요.

+
+ ) : ( + + )} +
+ 중립 + 정보 + 준비됨 + 확인 필요 + 실패 +
+
+
+ + + setDialogOpen(false)} + title="연동 확인" + description="도메인 작업을 실행하기 전 확인 화면의 기본 구조입니다." + actions={ + <> + + + + } + > +

민감한 값이나 구현 세부정보는 확인 문구에 포함하지 않습니다.

+
+
+
+
+ +
+
+

디자인 토큰

+

구성요소가 사용하는 의미 기반 색상과 형태 토큰입니다.

+
+
+ {COLOR_TOKENS.map(([label, token]) => ( +
+
+ ))} +
+
+ + + {notice} +
); } diff --git a/src/presentation/styles/theme.css b/src/presentation/styles/theme.css index 9018bce..8b9eebf 100644 --- a/src/presentation/styles/theme.css +++ b/src/presentation/styles/theme.css @@ -258,6 +258,25 @@ background: var(--color-surface); } + .ui-button--danger { + color: white; + background: var(--color-danger); + } + + .ui-button--danger:hover { + background: oklch(0.47 0.2 25); + } + + .ui-button--ghost { + color: var(--color-content); + background: transparent; + } + + .ui-button--ghost:hover { + color: var(--color-action); + background: var(--color-surface-muted); + } + .ui-button--compact { min-height: 2.25rem; padding: 0.4rem 0.75rem; @@ -273,6 +292,217 @@ @apply rounded-surface border border-surface-muted p-6; } + .ui-field { + display: grid; + gap: 0.45rem; + } + + .ui-field__label { + font-weight: 750; + } + + .ui-field__description, + .ui-field__error { + margin: 0; + font-size: 0.875rem; + line-height: 1.5; + } + + .ui-field__description { + color: var(--color-content-muted); + } + + .ui-field__error { + color: var(--color-danger); + font-weight: 650; + } + + .ui-field__input { + width: 100%; + min-height: 2.75rem; + box-sizing: border-box; + border: 1px solid color-mix(in oklch, var(--color-content-muted) 55%, white); + border-radius: var(--radius-control); + padding: 0.65rem 0.8rem; + color: var(--color-content); + background: white; + } + + .ui-field__input[aria-invalid="true"] { + border-color: var(--color-danger); + } + + .ui-card { + display: flex; + min-width: 0; + flex-direction: column; + gap: 1.25rem; + border: 1px solid var(--color-surface-muted); + border-radius: var(--radius-surface); + padding: 1.25rem; + background: white; + box-shadow: 0 8px 28px color-mix(in oklch, var(--color-content) 6%, transparent); + } + + .ui-card__header h3, + .ui-card__header p { + margin-block-end: 0; + } + + .ui-card__header p { + margin-block-start: 0.4rem; + color: var(--color-content-muted); + line-height: 1.55; + } + + .ui-card__content { + flex: 1; + } + + .ui-card__footer { + padding-block-start: 1rem; + border-top: 1px solid var(--color-surface-muted); + } + + .ui-alert { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + border: 1px solid var(--color-surface-muted); + border-radius: var(--radius-control); + padding: 1rem; + background: var(--color-surface); + } + + .ui-alert--info { + border-color: oklch(0.75 0.08 250); + background: oklch(0.96 0.025 250); + } + + .ui-alert--success { + border-color: oklch(0.72 0.1 155); + background: oklch(0.96 0.03 155); + } + + .ui-alert--warning { + border-color: oklch(0.75 0.12 80); + background: oklch(0.97 0.035 80); + } + + .ui-alert--danger { + border-color: oklch(0.72 0.12 25); + background: oklch(0.96 0.03 25); + } + + .ui-alert__content, + .ui-alert__content p { + margin-block: 0.35rem 0; + color: var(--color-content-muted); + } + + .ui-alert__dismiss, + .ui-dialog__close { + display: inline-grid; + width: 2rem; + height: 2rem; + flex: 0 0 auto; + place-items: center; + border: 0; + border-radius: var(--radius-control); + color: var(--color-content); + background: transparent; + font-size: 1.35rem; + cursor: pointer; + } + + .ui-alert__dismiss:hover, + .ui-dialog__close:hover { + background: color-mix(in oklch, var(--color-content) 8%, transparent); + } + + .ui-badge { + display: inline-flex; + border-radius: 999px; + padding: 0.3rem 0.65rem; + color: var(--color-content); + background: var(--color-surface-muted); + font-size: 0.8rem; + font-weight: 750; + } + + .ui-badge--info { + color: oklch(0.38 0.16 255); + background: oklch(0.93 0.04 255); + } + + .ui-badge--success { + color: oklch(0.35 0.12 155); + background: oklch(0.93 0.05 155); + } + + .ui-badge--warning { + color: oklch(0.38 0.12 70); + background: oklch(0.94 0.06 80); + } + + .ui-badge--danger { + color: oklch(0.42 0.18 25); + background: oklch(0.94 0.045 25); + } + + .ui-dialog { + width: min(34rem, calc(100vw - 2rem)); + max-height: calc(100vh - 2rem); + box-sizing: border-box; + overflow: auto; + border: 0; + border-radius: var(--radius-surface); + padding: 0; + color: var(--color-content); + background: white; + box-shadow: 0 24px 70px color-mix(in oklch, var(--color-content) 25%, transparent); + } + + .ui-dialog::backdrop { + background: color-mix(in oklch, var(--color-content) 55%, transparent); + backdrop-filter: blur(2px); + } + + .ui-dialog__surface { + padding: 1.5rem; + } + + .ui-dialog__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + } + + .ui-dialog__header h2 { + margin-block-end: 0.35rem; + } + + .ui-dialog__header p, + .ui-dialog__content p { + color: var(--color-content-muted); + line-height: 1.6; + } + + .ui-dialog__content { + padding-block: 1rem; + } + + .ui-dialog__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.75rem; + padding-block-start: 1rem; + border-top: 1px solid var(--color-surface-muted); + } + .page-header { max-width: 50rem; padding-block: clamp(1rem, 5vw, 3.5rem) 0.5rem; @@ -326,6 +556,155 @@ padding-block-start: 4rem; } + .gallery-section { + display: grid; + gap: 1rem; + padding-block: 0.5rem; + } + + .gallery-section__header { + max-width: 45rem; + } + + .gallery-section__header h2, + .gallery-section__header p { + margin-block-end: 0; + } + + .gallery-section__header p { + margin-block-start: 0.4rem; + color: var(--color-content-muted); + line-height: 1.6; + } + + .component-grid { + display: grid; + gap: 1rem; + } + + .component-grid--two { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .component-grid--three { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .component-stack, + .example-form { + display: grid; + gap: 1rem; + } + + .badge-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + + .token-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); + gap: 0.75rem; + } + + .token-swatch { + display: grid; + gap: 0.45rem; + min-width: 0; + border: 1px solid var(--color-surface-muted); + border-radius: var(--radius-control); + padding: 0.75rem; + background: white; + } + + .token-swatch__color { + height: 4rem; + border: 1px solid color-mix(in oklch, var(--color-content) 15%, transparent); + border-radius: calc(var(--radius-control) / 1.5); + } + + .token-swatch code { + overflow: hidden; + color: var(--color-content-muted); + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; + } + + .gallery-notice { + position: sticky; + z-index: 10; + bottom: 1rem; + display: block; + width: fit-content; + max-width: 100%; + box-sizing: border-box; + border: 1px solid var(--color-surface-muted); + border-radius: 999px; + padding: 0.65rem 1rem; + color: var(--color-content); + background: white; + box-shadow: 0 8px 28px color-mix(in oklch, var(--color-content) 10%, transparent); + font-size: 0.875rem; + font-weight: 650; + } + + .state-surface { + display: flex; + min-height: 12rem; + box-sizing: border-box; + flex-direction: column; + align-items: flex-start; + justify-content: center; + border: 1px solid var(--color-surface-muted); + border-radius: var(--radius-surface); + padding: 1.25rem; + background: var(--color-surface); + } + + .state-surface h2, + .state-surface p { + margin-block-end: 0.75rem; + } + + .state-surface > p:not(.state-surface__eyebrow) { + color: var(--color-content-muted); + line-height: 1.55; + } + + .state-surface__eyebrow { + color: var(--color-action-hover); + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 0.04em; + } + + .state-surface--warning { + border-color: oklch(0.75 0.12 80); + background: oklch(0.97 0.035 80); + } + + .state-surface--danger, + .ui-terminal-error { + border-color: oklch(0.72 0.12 25); + background: oklch(0.98 0.02 25); + } + + .state-surface--loading { + gap: 0.75rem; + } + + .state-surface--loading .ui-skeleton { + width: 100%; + } + + .state-preview-content { + border-radius: var(--radius-control); + padding: 1rem; + background: var(--color-surface); + } + .app-shell__scrim { display: none; } @@ -381,6 +760,11 @@ .readiness-grid { grid-template-columns: minmax(0, 1fr); } + + .component-grid--two, + .component-grid--three { + grid-template-columns: minmax(0, 1fr); + } } @media (prefers-reduced-motion: reduce) { diff --git a/tests/component/async-surface.test.jsx b/tests/component/async-surface.test.jsx index 6537b32..e106465 100644 --- a/tests/component/async-surface.test.jsx +++ b/tests/component/async-surface.test.jsx @@ -59,8 +59,14 @@ describe("async UI state matrix", () => { const state = deriveAsyncState({ failure }); render(); - expect(screen.getByRole("alert")).toHaveTextContent(failure.userMessageKey); - expect(screen.getByRole("button")).toHaveTextContent("retry"); + expect(screen.getByRole("alert")).toHaveTextContent( + "요청을 완료하지 못했습니다.", + ); + expect(screen.getByRole("alert")).toHaveAttribute( + "data-message-key", + failure.userMessageKey, + ); + expect(screen.getByRole("button")).toHaveTextContent("다시 시도"); expect(screen.getByRole("alert")).not.toHaveTextContent("stack"); }); diff --git a/tests/component/render-boundary.test.jsx b/tests/component/render-boundary.test.jsx index d61a43e..65696dc 100644 --- a/tests/component/render-boundary.test.jsx +++ b/tests/component/render-boundary.test.jsx @@ -44,7 +44,13 @@ describe("render recovery boundaries", () => { , ); - expect(screen.getByRole("alert")).toHaveTextContent("error.server_failure"); + expect(screen.getByRole("alert")).toHaveTextContent( + "요청을 완료하지 못했습니다.", + ); + expect(screen.getByRole("alert")).toHaveAttribute( + "data-message-key", + "error.server_failure", + ); }); it("renders a safe boot shell with no endpoint or stack", () => { diff --git a/tests/component/sample-resource-page.test.jsx b/tests/component/sample-resource-page.test.jsx index 34d83bb..9910cac 100644 --- a/tests/component/sample-resource-page.test.jsx +++ b/tests/component/sample-resource-page.test.jsx @@ -44,6 +44,11 @@ describe("removable sample feature page", () => { }; render(); - expect(await screen.findByRole("alert")).toHaveTextContent("error.server_failure"); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("요청을 완료하지 못했습니다."); + expect(alert).toHaveAttribute( + "data-message-key", + "error.server_failure", + ); }); }); diff --git a/tests/component/ui-primitives.test.jsx b/tests/component/ui-primitives.test.jsx new file mode 100644 index 0000000..0eef99e --- /dev/null +++ b/tests/component/ui-primitives.test.jsx @@ -0,0 +1,108 @@ +// @vitest-environment jsdom + +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { Alert } from "../../src/presentation/components/ui/alert.jsx"; +import { Badge } from "../../src/presentation/components/ui/badge.jsx"; +import { Button } from "../../src/presentation/components/ui/button.jsx"; +import { Card } from "../../src/presentation/components/ui/card.jsx"; +import { Dialog } from "../../src/presentation/components/ui/dialog.jsx"; +import { TextField } from "../../src/presentation/components/ui/text-field.jsx"; + +describe("domain-neutral UI primitives", () => { + it("connects field help and validation errors to the input", () => { + render( + , + ); + + const field = screen.getByRole("textbox", { name: "이름" }); + expect(field).toBeRequired(); + expect(field).toHaveAccessibleDescription( + "표시할 이름입니다. 이름을 입력해 주세요.", + ); + expect(field).toHaveAttribute("aria-invalid", "true"); + }); + + it("exposes semantic variants without changing native button behavior", async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + <> + + + , + ); + + await user.click(screen.getByRole("button", { name: "제거" })); + expect(onClick).toHaveBeenCalledOnce(); + expect(screen.getByRole("button", { name: "제거" })).toHaveClass( + "ui-button--danger", + ); + expect(screen.getByRole("button", { name: "사용 불가" })).toBeDisabled(); + }); + + it("labels cards, alerts, and badges with visible content", async () => { + const user = userEvent.setup(); + const dismiss = vi.fn(); + render( + 준비됨}> + + 안전하게 반영했습니다. + + , + ); + + expect(screen.getByRole("article", { name: "상태 카드" })).toBeVisible(); + expect(screen.getByRole("status")).toHaveTextContent( + "저장됨안전하게 반영했습니다.", + ); + expect(screen.getByText("준비됨")).toHaveClass("ui-badge--success"); + await user.click(screen.getByRole("button", { name: "저장됨 알림 닫기" })); + expect(dismiss).toHaveBeenCalledOnce(); + }); + + it("closes a modal and restores focus to its trigger", async () => { + const user = userEvent.setup(); + + function DialogHarness() { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)} + title="연동 확인" + actions={} + > + 안전한 설명 + + + ); + } + + render(); + const trigger = screen.getByRole("button", { name: "모달 열기" }); + await user.click(trigger); + + expect(screen.getByRole("dialog", { name: "연동 확인" })).toHaveAttribute( + "open", + ); + await user.click(screen.getByRole("button", { name: "확인" })); + + await waitFor(() => expect(trigger).toHaveFocus()); + expect(screen.getByRole("dialog", { hidden: true })).not.toHaveAttribute( + "open", + ); + }); +}); diff --git a/tests/e2e/ui-gallery.spec.js b/tests/e2e/ui-gallery.spec.js new file mode 100644 index 0000000..569aaff --- /dev/null +++ b/tests/e2e/ui-gallery.spec.js @@ -0,0 +1,34 @@ +import { expect, test } from "@playwright/test"; + +test("validates and reports the common text-field flow", async ({ page }) => { + await page.goto("/examples/ui"); + await page.getByRole("button", { name: "입력 확인" }).click(); + + const field = page.getByRole("textbox", { name: "프로젝트 이름" }); + await expect(field).toHaveAttribute("aria-invalid", "true"); + await expect(field).toHaveAccessibleDescription( + /프로젝트 이름을 입력해 주세요/, + ); + + await field.fill("Starter"); + await page.getByRole("button", { name: "입력 확인" }).click(); + await expect(page.getByText("“Starter” 입력을 확인했습니다.")).toBeVisible(); +}); + +test("traps modal interaction and restores focus to the trigger", async ({ + page, +}) => { + await page.goto("/examples/ui"); + const trigger = page.getByRole("button", { name: "모달 열기" }); + await trigger.click(); + + const dialog = page.getByRole("dialog", { name: "연동 확인" }); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByRole("button", { name: "연동 확인 닫기" }), + ).toBeFocused(); + + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + await expect(trigger).toBeFocused(); +});