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:
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
{/* 검사 시작 버튼 */}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
|
||||
{/* 사이트 크롤링 시작 버튼 */}
|
||||
|
||||
160
frontend/src/components/ui/select.tsx
Normal file
160
frontend/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
@ -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`, {
|
||||
|
||||
Reference in New Issue
Block a user