케이스 이어하기 UI와 공통 탭·이미지 복구를 반영
This commit is contained in:
parent
72353ecd82
commit
f1b80676c1
38 changed files with 3581 additions and 455 deletions
106
apps/web/src/components/avatar/ResilientImage.tsx
Normal file
106
apps/web/src/components/avatar/ResilientImage.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
125
apps/web/src/components/ui/Tabs.tsx
Normal file
125
apps/web/src/components/ui/Tabs.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue