feat: 접근성 검사 표준 선택 기능 — WCAG/KWCAG 버전별 선택 지원

3가지 검사 모드(한 페이지, 사이트 크롤링, 목록 업로드) 모두에서 접근성 표준을
선택할 수 있도록 추가. WCAG 2.0 A/AA, 2.1 AA, 2.2 AA와 KWCAG 2.1, 2.2를
지원하며, KWCAG 선택 시 axe-core 결과를 KWCAG 검사항목으로 자동 매핑.

- KWCAG 2.2 (33항목) / 2.1 (24항목) ↔ WCAG 매핑 테이블 (kwcag_mapping.py)
- AccessibilityChecker에 표준 파싱 및 KWCAG 변환 로직 추가
- 전체 API 파이프라인에 accessibility_standard 파라미터 전파
- 프론트엔드 3개 폼에 공용 표준 선택 드롭다운 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2026-02-14 08:36:14 +09:00
parent 21259eb40a
commit bffce65aca
19 changed files with 857 additions and 59 deletions

View File

@ -11,7 +11,7 @@
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toggle": "^1.1.1",

View File

@ -12,7 +12,7 @@
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toggle": "^1.1.1",

View File

@ -0,0 +1,58 @@
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
/** 접근성 표준 옵션 */
export const ACCESSIBILITY_STANDARDS = [
{ value: "wcag_2.1_aa", label: "WCAG 2.1 AA" },
{ value: "wcag_2.0_a", label: "WCAG 2.0 A" },
{ value: "wcag_2.0_aa", label: "WCAG 2.0 AA" },
{ value: "wcag_2.2_aa", label: "WCAG 2.2 AA" },
{ value: "kwcag_2.2", label: "KWCAG 2.2" },
{ value: "kwcag_2.1", label: "KWCAG 2.1" },
] as const;
export type AccessibilityStandard = (typeof ACCESSIBILITY_STANDARDS)[number]["value"];
interface AccessibilityStandardSelectProps {
value: AccessibilityStandard;
onChange: (value: AccessibilityStandard) => void;
disabled?: boolean;
}
/** 접근성 기준 선택 셀렉트 (3개 폼 공용) */
export function AccessibilityStandardSelect({
value,
onChange,
disabled,
}: AccessibilityStandardSelectProps) {
return (
<div>
<label className="text-xs text-muted-foreground mb-1.5 block">
</label>
<Select
value={value}
onValueChange={(v) => onChange(v as AccessibilityStandard)}
disabled={disabled}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACCESSIBILITY_STANDARDS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@ -10,6 +10,10 @@ import { api, ApiError } from "@/lib/api";
import { isValidUrl } from "@/lib/constants";
import { useBatchInspectionStore } from "@/stores/useBatchInspectionStore";
import { cn } from "@/lib/utils";
import {
AccessibilityStandardSelect,
type AccessibilityStandard,
} from "./AccessibilityStandardSelect";
/** 동시 검사 수 옵션 */
const CONCURRENCY_OPTIONS = [1, 2, 4, 8] as const;
@ -29,6 +33,8 @@ export function BatchUploadForm() {
const [file, setFile] = useState<File | null>(null);
const [parsedUrls, setParsedUrls] = useState<string[]>([]);
const [concurrency, setConcurrency] = useState<number>(4);
const [accessibilityStandard, setAccessibilityStandard] =
useState<AccessibilityStandard>("wcag_2.1_aa");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
@ -129,7 +135,8 @@ export function BatchUploadForm() {
const response = await api.startBatchInspection(
file,
name.trim(),
concurrency
concurrency,
accessibilityStandard
);
setBatchInspection(response.batch_inspection_id, name.trim());
router.push(`/batch-inspections/${response.batch_inspection_id}/progress`);
@ -255,29 +262,39 @@ export function BatchUploadForm() {
)}
</div>
{/* 동시 검사 수 */}
<div>
<label className="text-xs text-muted-foreground mb-1.5 block">
</label>
<div className="flex gap-2">
{CONCURRENCY_OPTIONS.map((option) => (
<Button
key={option}
type="button"
variant={concurrency === option ? "default" : "outline"}
size="sm"
className={cn(
"flex-1",
concurrency === option && "pointer-events-none"
)}
onClick={() => setConcurrency(option)}
disabled={isLoading}
>
{option}
</Button>
))}
{/* 옵션 영역 */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* 동시 검사 수 */}
<div>
<label className="text-xs text-muted-foreground mb-1.5 block">
</label>
<div className="flex gap-1.5">
{CONCURRENCY_OPTIONS.map((option) => (
<Button
key={option}
type="button"
variant={concurrency === option ? "default" : "outline"}
size="sm"
className={cn(
"flex-1 text-xs",
concurrency === option && "pointer-events-none"
)}
onClick={() => setConcurrency(option)}
disabled={isLoading}
>
{option}
</Button>
))}
</div>
</div>
{/* 접근성 기준 */}
<AccessibilityStandardSelect
value={accessibilityStandard}
onChange={setAccessibilityStandard}
disabled={isLoading}
/>
</div>
{/* 검사 시작 버튼 */}

View File

@ -9,12 +9,18 @@ import { Search, Loader2 } from "lucide-react";
import { api, ApiError } from "@/lib/api";
import { isValidUrl } from "@/lib/constants";
import { useInspectionStore } from "@/stores/useInspectionStore";
import {
AccessibilityStandardSelect,
type AccessibilityStandard,
} from "./AccessibilityStandardSelect";
/** 한 페이지 검사 폼 */
export function SinglePageForm() {
const [url, setUrl] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [accessibilityStandard, setAccessibilityStandard] =
useState<AccessibilityStandard>("wcag_2.1_aa");
const router = useRouter();
const { setInspection } = useInspectionStore();
@ -35,7 +41,7 @@ export function SinglePageForm() {
setIsLoading(true);
try {
const response = await api.startInspection(trimmedUrl);
const response = await api.startInspection(trimmedUrl, accessibilityStandard);
setInspection(response.inspection_id, trimmedUrl);
router.push(`/inspections/${response.inspection_id}/progress`);
} catch (err) {
@ -87,6 +93,13 @@ export function SinglePageForm() {
)}
</Button>
</div>
{/* 접근성 기준 선택 */}
<AccessibilityStandardSelect
value={accessibilityStandard}
onChange={setAccessibilityStandard}
disabled={isLoading}
/>
</form>
{error && (

View File

@ -10,6 +10,10 @@ import { api, ApiError } from "@/lib/api";
import { isValidUrl } from "@/lib/constants";
import { useSiteInspectionStore } from "@/stores/useSiteInspectionStore";
import { cn } from "@/lib/utils";
import {
AccessibilityStandardSelect,
type AccessibilityStandard,
} from "./AccessibilityStandardSelect";
/** 최대 페이지 수 옵션 (0 = 무제한) */
const MAX_PAGES_OPTIONS = [10, 20, 50, 0] as const;
@ -28,6 +32,8 @@ export function SiteCrawlForm() {
const [maxPages, setMaxPages] = useState<number>(20);
const [maxDepth, setMaxDepth] = useState<number>(2);
const [concurrency, setConcurrency] = useState<number>(4);
const [accessibilityStandard, setAccessibilityStandard] =
useState<AccessibilityStandard>("wcag_2.1_aa");
const router = useRouter();
const { setSiteInspection } = useSiteInspectionStore();
@ -52,7 +58,8 @@ export function SiteCrawlForm() {
trimmedUrl,
maxPages,
maxDepth,
concurrency
concurrency,
accessibilityStandard
);
setSiteInspection(response.site_inspection_id, trimmedUrl);
router.push(
@ -93,7 +100,7 @@ export function SiteCrawlForm() {
</div>
{/* 옵션 영역 */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
{/* 최대 페이지 수 */}
<div>
<label className="text-xs text-muted-foreground mb-1.5 block">
@ -168,6 +175,13 @@ export function SiteCrawlForm() {
))}
</div>
</div>
{/* 접근성 기준 */}
<AccessibilityStandardSelect
value={accessibilityStandard}
onChange={setAccessibilityStandard}
disabled={isLoading}
/>
</div>
{/* 사이트 크롤링 시작 버튼 */}

View File

@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -64,10 +64,13 @@ class ApiClient {
}
/** 검사 시작 */
async startInspection(url: string): Promise<StartInspectionResponse> {
async startInspection(url: string, accessibilityStandard?: string): Promise<StartInspectionResponse> {
return this.request("/api/inspections", {
method: "POST",
body: JSON.stringify({ url }),
body: JSON.stringify({
url,
accessibility_standard: accessibilityStandard,
}),
});
}
@ -164,7 +167,8 @@ class ApiClient {
url: string,
maxPages?: number,
maxDepth?: number,
concurrency?: number
concurrency?: number,
accessibilityStandard?: string
): Promise<StartSiteInspectionResponse> {
return this.request("/api/site-inspections", {
method: "POST",
@ -173,6 +177,7 @@ class ApiClient {
max_pages: maxPages,
max_depth: maxDepth,
concurrency: concurrency,
accessibility_standard: accessibilityStandard,
}),
});
}
@ -220,7 +225,8 @@ class ApiClient {
async startBatchInspection(
file: File,
name: string,
concurrency?: number
concurrency?: number,
accessibilityStandard?: string
): Promise<StartBatchInspectionResponse> {
const formData = new FormData();
formData.append("file", file);
@ -228,6 +234,9 @@ class ApiClient {
if (concurrency !== undefined) {
formData.append("concurrency", String(concurrency));
}
if (accessibilityStandard) {
formData.append("accessibility_standard", accessibilityStandard);
}
// NOTE: Content-Type을 직접 설정하지 않아야 boundary가 자동 설정됨
const response = await fetch(`${this.baseUrl}/api/batch-inspections`, {