케이스 이어하기 UI와 공통 탭·이미지 복구를 반영

This commit is contained in:
Yun Chan 2026-09-01 11:45:23 +09:00
parent 72353ecd82
commit f1b80676c1
38 changed files with 3581 additions and 455 deletions

View file

@ -0,0 +1,106 @@
import { useEffect, useState, type ImgHTMLAttributes, type ReactNode } from "react";
import { apiUrl } from "../../lib/api";
const CONTROL_CHARACTER = /[\u0000-\u001F\u007F]/;
const EXPLICIT_SCHEME = /^([a-z][a-z\d+.-]*):/i;
const SAFE_IMAGE_PROTOCOLS = new Set(["http:", "https:"]);
/**
* API http(s) URL로 .
* , , URL, data/javascript/file scheme은 .
*/
export function normalizeImageSource(source?: string | null): string {
const value = (source ?? "").trim();
if (!value || CONTROL_CHARACTER.test(value)) return "";
const scheme = EXPLICIT_SCHEME.exec(value)?.[1]?.toLowerCase();
if (scheme && scheme !== "http" && scheme !== "https") return "";
try {
const browserOrigin =
typeof window === "undefined" ? "http://localhost" : window.location.origin;
const candidate = scheme
? value
: value.startsWith("//")
? `${typeof window === "undefined" ? "https:" : window.location.protocol}${value}`
: apiUrl(value);
const parsed = new URL(candidate, browserOrigin);
if (!SAFE_IMAGE_PROTOCOLS.has(parsed.protocol)) return "";
if (parsed.username || parsed.password) return "";
return parsed.href;
} catch {
return "";
}
}
export interface ResilientImageProps
extends Omit<
ImgHTMLAttributes<HTMLImageElement>,
"alt" | "onError" | "onLoad" | "src"
> {
src?: string | null;
/** 장식 이미지면 빈 문자열. 의미가 있으면 구체적인 대체 텍스트. */
alt: string;
fallback: ReactNode;
}
/**
* fallback을 .
* src가 URL을 , URL은 .
*/
export function ResilientImage({
src,
alt,
fallback,
decoding = "async",
style,
...imageProps
}: ResilientImageProps) {
const normalizedSrc = normalizeImageSource(src);
const [loadedSrc, setLoadedSrc] = useState("");
const [failedSrc, setFailedSrc] = useState("");
useEffect(() => {
setLoadedSrc("");
setFailedSrc("");
}, [normalizedSrc]);
const failed = Boolean(normalizedSrc) && failedSrc === normalizedSrc;
const loaded = Boolean(normalizedSrc) && loadedSrc === normalizedSrc && !failed;
const fallbackState = !normalizedSrc ? "empty" : failed ? "failed" : "loading";
return (
<>
{!loaded ? (
<span
data-image-fallback={fallbackState}
role={alt ? "img" : undefined}
aria-label={alt || undefined}
aria-hidden={alt ? undefined : true}
>
{fallback}
</span>
) : null}
{normalizedSrc && !failed ? (
<img
{...imageProps}
src={normalizedSrc}
alt={alt}
decoding={decoding}
hidden={!loaded}
style={loaded ? style : { ...style, display: "none" }}
data-image-state={loaded ? "ready" : "loading"}
onLoad={() => {
setFailedSrc("");
setLoadedSrc(normalizedSrc);
}}
onError={() => {
setLoadedSrc("");
setFailedSrc(normalizedSrc);
}}
/>
) : null}
</>
);
}

View file

@ -162,6 +162,13 @@ export function AppShell({ children, className, contextLabel, hideNav, bleed, wi
.filter(Boolean)
.join(" ")}
>
<a
className="vg-skip-link"
href="#vg-main-content"
onClick={() => window.requestAnimationFrame(() => mainRef.current?.focus())}
>
</a>
{hideTopbar ? null : <Topbar contextLabel={contextLabel} />}
<div className={"vg-shell__body" + (showNav ? "" : " vg-shell__body--bare")}>
{showNav ? (
@ -170,7 +177,7 @@ export function AppShell({ children, className, contextLabel, hideNav, bleed, wi
showAdminEntry={canAccessRole(user, "admin")}
/>
) : null}
<main ref={mainRef} className={mainClassName}>
<main id="vg-main-content" ref={mainRef} className={mainClassName} tabIndex={-1}>
<div className="vg-main__inner">{children}</div>
</main>
</div>

View file

@ -1,5 +1,6 @@
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Icon } from "../ui/Icon";
import { ResilientImage } from "../avatar/ResilientImage";
import { accessibleRolesFor, canAccessRole, roleHomePath, roleLabel, useAuth, type Role } from "../../lib/auth";
import { useTheme } from "../../lib/useTheme";
@ -101,7 +102,11 @@ export function Topbar({ contextLabel }: TopbarProps) {
<>
<span className="vg-topbar__user">
<span className="vg-topbar__avatar" aria-hidden="true">
{user.avatarUrl ? <img src={user.avatarUrl} alt="" /> : initials(user.name)}
<ResilientImage
src={user.avatarUrl}
alt=""
fallback={initials(user.name)}
/>
</span>
<span className="vg-topbar__uname">{user.name}</span>
</span>

View file

@ -17,6 +17,29 @@
.vg-shell--fullscreen {
height: 100dvh;
}
.vg-skip-link {
position: fixed;
z-index: 100;
top: var(--sp-2);
left: var(--sp-2);
min-height: 44px;
display: inline-flex;
align-items: center;
padding: 0 var(--sp-3);
border-radius: var(--radius-sm);
background: var(--accent-deep);
color: var(--text-on-accent);
font-size: var(--fs-sm);
font-weight: 700;
text-decoration: none;
transform: translateY(calc(-100% - var(--sp-3)));
transition: transform var(--dur-fast) var(--ease-out);
}
.vg-skip-link:focus {
transform: translateY(0);
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
/* ── 톱바 ── */
.vg-topbar {

View file

@ -0,0 +1,125 @@
import {
useRef,
type KeyboardEvent,
type ReactNode,
} from "react";
export interface TabItem<T extends string> {
value: T;
label: ReactNode;
disabled?: boolean;
}
export interface TabsProps<T extends string> {
id: string;
ariaLabel: string;
items: readonly TabItem<T>[];
value: T;
onValueChange: (value: T) => void;
listClassName?: string;
panelClassName?: string;
children: ReactNode;
}
function tabToken(value: string) {
return encodeURIComponent(value).replaceAll("%", "-");
}
/**
* Tabs ARIA ·roving focus· .
* , listClassName/panelClassName이 .
*/
export function Tabs<T extends string>({
id,
ariaLabel,
items,
value,
onValueChange,
listClassName,
panelClassName,
children,
}: TabsProps<T>) {
const triggerRefs = useRef<Array<HTMLButtonElement | null>>([]);
const activeIndex = items.findIndex((item) => item.value === value);
const activeItem = items[activeIndex];
if (!activeItem) {
throw new Error(`Tabs(${id})에 등록되지 않은 값입니다: ${value}`);
}
const panelId = `${id}-panel`;
const triggerId = (itemValue: T) => `${id}-tab-${tabToken(itemValue)}`;
const moveFocus = (
event: KeyboardEvent<HTMLButtonElement>,
currentIndex: number,
) => {
const enabledIndices = items
.map((item, index) => (item.disabled ? -1 : index))
.filter((index) => index >= 0);
if (enabledIndices.length === 0) return;
const position = enabledIndices.indexOf(currentIndex);
let nextIndex: number | undefined;
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
nextIndex = enabledIndices[(position + 1) % enabledIndices.length];
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
nextIndex =
enabledIndices[(position - 1 + enabledIndices.length) % enabledIndices.length];
} else if (event.key === "Home") {
nextIndex = enabledIndices[0];
} else if (event.key === "End") {
nextIndex = enabledIndices[enabledIndices.length - 1];
}
if (nextIndex === undefined) return;
event.preventDefault();
const nextItem = items[nextIndex];
onValueChange(nextItem.value);
triggerRefs.current[nextIndex]?.focus();
};
return (
<>
<div
id={id}
className={["vg-tabs", listClassName ?? ""].filter(Boolean).join(" ")}
role="tablist"
aria-label={ariaLabel}
>
{items.map((item, index) => {
const selected = value === item.value;
return (
<button
ref={(element) => {
triggerRefs.current[index] = element;
}}
id={triggerId(item.value)}
type="button"
key={item.value}
role="tab"
aria-controls={panelId}
aria-selected={selected}
tabIndex={selected ? 0 : -1}
disabled={item.disabled}
className={`vg-tabs__trigger${selected ? " is-active" : ""}`}
onClick={() => onValueChange(item.value)}
onKeyDown={(event) => moveFocus(event, index)}
>
{item.label}
</button>
);
})}
</div>
<div
id={panelId}
className={["vg-tabs__panel", panelClassName ?? ""].filter(Boolean).join(" ")}
role="tabpanel"
aria-labelledby={triggerId(activeItem.value)}
tabIndex={0}
>
{children}
</div>
</>
);
}

View file

@ -16,6 +16,9 @@ export type { PanelProps } from "./Panel";
export { Surface, surfaceClassName } from "./Surface";
export type { SurfaceProps, SurfaceOptions, SurfaceVariant } from "./Surface";
export { Tabs } from "./Tabs";
export type { TabsProps, TabItem } from "./Tabs";
export { Kicker } from "./Kicker";
export type { KickerProps } from "./Kicker";

View file

@ -160,6 +160,16 @@
box-shadow: none;
}
/* ── Tabs behavior primitive. 시각 표면은 만들지 않는다. ── */
.vg-tabs,
.vg-tabs__panel {
min-width: 0;
}
.vg-tabs__trigger:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
/* ── Card / Panel 크기 계약. 표면은 위 Surface만 소유한다. ── */
.vg-card {
padding: var(--sp-5);