웹 접근성 조치
Deploy Frontend / deploy (push) Successful in 9m6s

This commit is contained in:
2026-10-11 15:32:56 +09:00
parent 5ad64a596b
commit 48999f5699
19 changed files with 425 additions and 182 deletions
+13 -2
View File
@@ -16,9 +16,20 @@ interface CodeSelectProps extends React.ComponentPropsWithoutRef<typeof Select>
useYn?: string;
placeholder?: string;
className?: string;
/** 레이블(label htmlFor) 연결용 트리거 id */
id?: string;
title?: string;
}
function CodeSelect({ upCd, useYn, placeholder = "선택", className, ...props }: CodeSelectProps) {
function CodeSelect({
upCd,
useYn,
placeholder = "선택",
className,
id,
title,
...props
}: CodeSelectProps) {
const listQuery = useCodeList(useYn ? undefined : upCd);
const listByUseYnQuery = useCodeListByUseYn(useYn ? upCd : undefined, useYn ?? "");
@@ -27,7 +38,7 @@ function CodeSelect({ upCd, useYn, placeholder = "선택", className, ...props }
return (
<Select {...props} disabled={props.disabled || isLoading}>
<SelectTrigger className={cn("w-[12rem]", className)}>
<SelectTrigger id={id} title={title} className={cn("w-[12rem]", className)}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
@@ -84,9 +84,9 @@ function CklstArtclFormDialog({
/>
</div>
<div className="space-y-2">
<Label>사용여부</Label>
<Label htmlFor="cklstArtclUseYn">사용여부</Label>
<Select value={useYn} onValueChange={(v) => setValue("useYn", v as "Y" | "N")}>
<SelectTrigger className="w-full">
<SelectTrigger id="cklstArtclUseYn" className="w-full">
<SelectValue placeholder="사용여부" />
</SelectTrigger>
<SelectContent>
@@ -100,8 +100,9 @@ function CklstMngFormDialog({
/>
</div>
<div className="space-y-2">
<Label>문서구분</Label>
<Label htmlFor="cklstDocSeComCd">문서구분</Label>
<CodeSelect
id="cklstDocSeComCd"
upCd="ASDOCSE"
value={docSeComCd}
onValueChange={(v) => setValue("docSeComCd", v)}
@@ -110,8 +111,9 @@ function CklstMngFormDialog({
/>
</div>
<div className="space-y-2">
<Label>업무구분</Label>
<Label htmlFor="cklstTaskSeComCd">업무구분</Label>
<CodeSelect
id="cklstTaskSeComCd"
upCd={docSeComCd || "ASDOCSE"}
value={watch("taskSeComCd")}
onValueChange={(v) => setValue("taskSeComCd", v)}
@@ -121,9 +123,9 @@ function CklstMngFormDialog({
/>
</div>
<div className="space-y-2">
<Label>사용여부</Label>
<Label htmlFor="cklstUseYn">사용여부</Label>
<Select value={useYn} onValueChange={(v) => setValue("useYn", v as "Y" | "N")}>
<SelectTrigger className="w-full">
<SelectTrigger id="cklstUseYn" className="w-full">
<SelectValue placeholder="사용여부" />
</SelectTrigger>
<SelectContent>
@@ -44,14 +44,23 @@ function CklstPreviewDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[64rem]">
<DialogContent className="flex max-h-[90vh] max-w-[64rem] flex-col">
<DialogHeader>
<DialogTitle>체크리스트 미리보기 — {cklstNm}</DialogTitle>
</DialogHeader>
{displayRows.length === 0 ? (
<p className="py-6 text-center text-muted-foreground">표시할 항목이 없습니다.</p>
) : (
<Table caption={`체크리스트 미리보기 ${cklstNm} 목록으로 점검항목, 주요 검토내용, 검토 여부 정보 제공`}>
// 항목이 많아 창 높이를 넘으면 표 영역만 스크롤 (초점을 받아 키보드로도 스크롤 가능)
<div
tabIndex={0}
role="region"
aria-label="체크리스트 미리보기 목록"
className="min-h-0 flex-1 overflow-y-auto rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<Table
caption={`체크리스트 미리보기 ${cklstNm} 목록으로 점검항목, 주요 검토내용, 검토 여부 정보 제공`}
>
<colgroup>
<col width="25%" />
<col width="50%" />
@@ -84,7 +93,10 @@ function CklstPreviewDialog({
<TableCell>{row.rvwCn ?? "—"}</TableCell>
<TableCell align="center">
{showRadios ? (
<RadioGroup className="flex items-center justify-center">
<RadioGroup
aria-label="검토 여부"
className="flex items-center justify-center"
>
<div className="flex items-center gap-1">
<RadioGroupItem value="Y" id={`${row.rowKey}-preview-y`} />
<Label htmlFor={`${row.rowKey}-preview-y`}>예</Label>
@@ -103,6 +115,7 @@ function CklstPreviewDialog({
})}
</TableBody>
</Table>
</div>
)}
</DialogContent>
</Dialog>
@@ -289,13 +289,13 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
</colgroup>
<TableHeader>
<TableRow>
<TableHead>점검항목</TableHead>
<TableHead>주요 검토내용</TableHead>
<TableHead>사용 여부</TableHead>
<TableHead>체크 사용</TableHead>
<TableHead>정렬 순서</TableHead>
<TableHead>구분</TableHead>
<TableHead>관리</TableHead>
<TableHead scope="col">점검항목</TableHead>
<TableHead scope="col">주요 검토내용</TableHead>
<TableHead scope="col">사용 여부</TableHead>
<TableHead scope="col">체크 사용</TableHead>
<TableHead scope="col">정렬 순서</TableHead>
<TableHead scope="col">구분</TableHead>
<TableHead scope="col">관리</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -55,7 +55,11 @@ function CklstRvwRowComponent({
onValueChange={(v) => onChange({ cklstArtclSn: Number(v), isDirty: true })}
disabled={!row.isNew}
>
<SelectTrigger className="w-full flex-1">
{/* 비활성(기존 항목) 상태에서도 텍스트 명도 대비 4.5:1 이상 유지 */}
<SelectTrigger
title="점검항목 선택"
className="w-full flex-1 disabled:opacity-100"
>
<SelectValue placeholder="점검항목 선택" />
</SelectTrigger>
<SelectContent>
@@ -73,12 +77,14 @@ function CklstRvwRowComponent({
value={row.rvwCn ?? ""}
onChange={(e) => onChange({ rvwCn: e.target.value, isDirty: true })}
placeholder="주요 검토내용"
title="주요 검토내용"
/>
</TableCell>
<TableCell align="center">
<RadioGroup
value={row.useYn ?? "Y"}
onValueChange={(v) => onChange({ useYn: v as "Y" | "N", isDirty: true })}
aria-label="사용 여부"
className="flex justify-center gap-4"
>
<div className="flex items-center gap-2">
@@ -95,6 +101,7 @@ function CklstRvwRowComponent({
<RadioGroup
value={row.bchckUseYn ?? "Y"}
onValueChange={(v) => onChange({ bchckUseYn: v as "Y" | "N", isDirty: true })}
aria-label="체크 사용"
className="flex justify-center gap-4"
>
<div className="flex items-center gap-2">
@@ -111,6 +118,7 @@ function CklstRvwRowComponent({
<Input
type="number"
min={0}
title="정렬 순서"
value={row.sortSeq ?? ""}
onChange={(e) =>
onChange({
@@ -79,6 +79,17 @@ function getBadgeColor(state: BadgeState) {
}
}
function getBadgeStateText(state: BadgeState) {
switch (state) {
case "completed":
return "완료";
case "current":
return "진행중";
case "pending":
return "대기";
}
}
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
@@ -95,7 +106,9 @@ function ProgressStepBadge({ prgrsSttsComCd }: ProgressStepBadgeProps) {
{badges.map((badge, index) => (
<Fragment key={`${badge.label}-${index}`}>
{index > 0 && <span className="mx-2">&gt;</span>}
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge>
<Badge color={getBadgeColor(badge.state)}>
{badge.label}({getBadgeStateText(badge.state)})
</Badge>
</Fragment>
))}
</div>
@@ -120,6 +120,17 @@ function getBadgeColor(state: BadgeState) {
}
}
function getBadgeStateText(state: BadgeState) {
switch (state) {
case "completed":
return "완료";
case "current":
return "진행중";
case "pending":
return "대기";
}
}
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
@@ -137,7 +148,9 @@ function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadg
{badges.map((badge, index) => (
<Fragment key={badge.label}>
{index > 0 && <span className="mx-2">&gt;</span>}
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge>
<Badge color={getBadgeColor(badge.state)}>
{badge.label}({getBadgeStateText(badge.state)})
</Badge>
</Fragment>
))}
</div>
+14 -1
View File
@@ -121,6 +121,17 @@ function getBadgeColor(state: BadgeState) {
}
}
function getBadgeStateText(state: BadgeState) {
switch (state) {
case "completed":
return "완료";
case "current":
return "진행중";
case "pending":
return "대기";
}
}
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
@@ -138,7 +149,9 @@ function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadg
{badges.map((badge, index) => (
<Fragment key={badge.label}>
{index > 0 && <span className="mx-2">&gt;</span>}
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge>
<Badge color={getBadgeColor(badge.state)}>
{badge.label}({getBadgeStateText(badge.state)})
</Badge>
</Fragment>
))}
</div>
@@ -1,6 +1,7 @@
"use client";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { AxiosError } from "axios";
import { useAlertStore } from "@/shared/lib";
import type { RvwMnpwMngResponseDto } from "@/entities/rvw-mnpw";
@@ -10,6 +11,14 @@ import { Badge, Button } from "@common/components";
import type { RvwMnpwBlockConfig } from "../model/rvw-mnpw-block-config";
/** 서버 오류 응답의 message 를 우선 표시하고, 없으면 기본 문구를 사용한다 */
function getErrorMessage(error: unknown, fallback: string) {
if (error instanceof AxiosError) {
return error.response?.data?.message || fallback;
}
return fallback;
}
interface RvwMnpwBlockProps {
block: RvwMnpwBlockConfig;
items: RvwMnpwMngResponseDto[];
@@ -33,8 +42,7 @@ function RvwMnpwBlock({ block, items, selectedPerson }: RvwMnpwBlockProps) {
});
alert("배치되었습니다.");
} catch (error) {
const message = error instanceof Error ? error.message : "배치에 실패했습니다.";
alert(message);
alert(getErrorMessage(error, "배치에 실패했습니다."));
}
};
@@ -48,8 +56,7 @@ function RvwMnpwBlock({ block, items, selectedPerson }: RvwMnpwBlockProps) {
userUuid: item.userUuid,
});
} catch (error) {
const message = error instanceof Error ? error.message : "제거에 실패했습니다.";
alert(message);
alert(getErrorMessage(error, "제거에 실패했습니다."));
}
};
@@ -119,6 +119,17 @@ function getBadgeColor(state: BadgeState) {
}
}
function getBadgeStateText(state: BadgeState) {
switch (state) {
case "completed":
return "완료";
case "current":
return "진행중";
case "pending":
return "대기";
}
}
function formatOptional(value: string | undefined) {
return value?.trim() ? value : "-";
}
@@ -136,7 +147,9 @@ function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadg
{badges.map((badge, index) => (
<Fragment key={`${badge.label}-${index}`}>
{index > 0 && <span className="mx-2">&gt;</span>}
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge>
<Badge color={getBadgeColor(badge.state)}>
{badge.label}({getBadgeStateText(badge.state)})
</Badge>
</Fragment>
))}
</div>
@@ -120,6 +120,7 @@ function TmplntDtlInput({ facilityIndex, planStpNo, fieldKey, decimals }: Tmplnt
interface TmplntFacilityMatrixProps {
swtrmtFcltCd: string;
facilityName?: string;
sido: string;
gugun: string;
facilityIndex: number;
@@ -129,6 +130,7 @@ interface TmplntFacilityMatrixProps {
function TmplntFacilityMatrix({
swtrmtFcltCd,
facilityName,
sido,
gugun,
facilityIndex,
@@ -147,7 +149,10 @@ function TmplntFacilityMatrix({
const originalData = Array.isArray(data) ? (data[0] ?? null) : null;
return (
<Table caption="처리시설별 하수도정비기본계획 목록으로 구분, 당초, 현재, 변경 정보 제공" variant="secondary">
<Table
caption={`${facilityName ? `${facilityName} ` : ""}처리시설별 하수도정비기본계획 목록으로 구분, 당초, 현재, 변경 정보 제공`}
variant="secondary"
>
<colgroup>
<col width="12%" />
<col width="9.75%" />
@@ -317,7 +322,10 @@ function TmplntFacilityAttachments({
return (
<div className="mt-6 space-y-2">
<h3 className="mb-4 text-xl font-semibold">처리구역 필수 첨부파일</h3>
<Table caption="처리구역 필수 첨부파일 목록으로 파일명, 파일 확장자, 파일첨부 정보 제공" variant="secondary">
<Table
caption={`${facilityName ? `${facilityName} ` : ""}처리구역 필수 첨부파일 목록으로 파일명, 파일 확장자, 파일첨부 정보 제공`}
variant="secondary"
>
<colgroup>
<col width="40%" />
<col width="15%" />
@@ -481,6 +489,8 @@ function TmplntFormSection() {
<TabsTrigger
key={field.id}
value={field.swtrmtFcltCd}
id={`tmplnt-form-tab-${field.swtrmtFcltCd}`}
aria-controls={`tmplnt-form-panel-${field.swtrmtFcltCd}`}
className="px-6 py-3 text-2xl"
>
{facilityName}
@@ -497,10 +507,14 @@ function TmplntFormSection() {
<TabsContent
key={field.id}
value={field.swtrmtFcltCd}
id={`tmplnt-form-panel-${field.swtrmtFcltCd}`}
aria-labelledby={`tmplnt-form-tab-${field.swtrmtFcltCd}`}
className="mt-0 border-0 p-0 shadow-none"
>
<h3 className="sr-only">{facilityName} 하수도정비기본계획</h3>
<TmplntFacilityMatrix
swtrmtFcltCd={field.swtrmtFcltCd}
facilityName={facilityName}
sido={sido ?? ""}
gugun={gugun ?? ""}
facilityIndex={facilityIndex}
@@ -77,6 +77,7 @@ function getDtlValue(
interface TmplntFacilityMatrixProps {
item: SewerBscPlanTmplntResponseDto;
facilityName?: string;
sido?: string;
gugun?: string;
crtrYr?: string;
@@ -85,6 +86,7 @@ interface TmplntFacilityMatrixProps {
function TmplntFacilityMatrix({
item,
facilityName,
sido,
gugun,
crtrYr,
@@ -102,7 +104,10 @@ function TmplntFacilityMatrix({
const originalData = Array.isArray(data) ? (data[0] ?? null) : null;
return (
<Table caption="처리시설별 하수도정비기본계획 목록으로 구분, 당초, 현재, 변경 정보 제공" variant="secondary">
<Table
caption={`${facilityName ? `${facilityName} ` : ""}처리시설별 하수도정비기본계획 목록으로 구분, 당초, 현재, 변경 정보 제공`}
variant="secondary"
>
<colgroup>
<col width="12%" />
<col width="9.75%" />
@@ -217,6 +222,8 @@ function TmplntSection({ tmplntList, det, dtl }: TmplntSectionProps) {
<TabsTrigger
key={item.swtrmtFcltCd}
value={item.swtrmtFcltCd ?? ""}
id={`tmplnt-view-tab-${item.swtrmtFcltCd}`}
aria-controls={`tmplnt-view-panel-${item.swtrmtFcltCd}`}
className="!px-6 !py-3 text-xl"
>
{facilityName}
@@ -228,10 +235,17 @@ function TmplntSection({ tmplntList, det, dtl }: TmplntSectionProps) {
{tmplntList.map((item) => {
const facilityName = resolveFacilityName(item, facilityOptions);
return (
<TabsContent key={item.swtrmtFcltCd} value={item.swtrmtFcltCd ?? ""}>
<TabsContent
key={item.swtrmtFcltCd}
value={item.swtrmtFcltCd ?? ""}
id={`tmplnt-view-panel-${item.swtrmtFcltCd}`}
aria-labelledby={`tmplnt-view-tab-${item.swtrmtFcltCd}`}
>
<h3 className="sr-only">{facilityName} 하수도정비기본계획</h3>
<div className="overflow-x-auto">
<TmplntFacilityMatrix
item={item}
facilityName={facilityName}
sido={det.sido}
gugun={det.gugun}
crtrYr={dtl?.crtrYr}
@@ -242,7 +256,10 @@ function TmplntSection({ tmplntList, det, dtl }: TmplntSectionProps) {
{(item.atchFileGroupId || item.atchFileInfo?.orgnlFileNm) && (
<div className="mt-6 space-y-2">
<h3 className="mb-4 text-xl font-semibold">처리구역 필수 첨부파일</h3>
<Table caption="처리구역 필수 첨부파일 목록으로 파일명, 파일첨부 정보 제공" variant="secondary">
<Table
caption={`${facilityName ? `${facilityName} ` : ""}처리구역 필수 첨부파일 목록으로 파일명, 파일첨부 정보 제공`}
variant="secondary"
>
<colgroup>
<col width="35%" />
<col width="65%" />
@@ -127,7 +127,9 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
<TableHeader>
{canSidoGugunSelectable && (
<TableRow>
<TableHead scope="row" data-required>시도</TableHead>
<TableHead scope="row" data-required>
<label htmlFor="sewer-bsc-plan-apply-sido">시도</label>
</TableHead>
<TableCell>
<Select
value={sidoComCd || undefined}
@@ -136,7 +138,12 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
setSggComCd("");
}}
>
<SelectTrigger className="w-full">
<SelectTrigger
id="sewer-bsc-plan-apply-sido"
title="시도 선택"
aria-required="true"
className="w-full"
>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
@@ -148,14 +155,16 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
</SelectContent>
</Select>
</TableCell>
<TableHead scope="row">시군구</TableHead>
<TableHead scope="row">
<label htmlFor="sewer-bsc-plan-apply-sgg">시군구</label>
</TableHead>
<TableCell>
<Select
value={sggComCd || undefined}
onValueChange={setSggComCd}
disabled={!sidoComCd || isUserGugunPending}
>
<SelectTrigger className="w-full">
<SelectTrigger id="sewer-bsc-plan-apply-sgg" title="시군구 선택" className="w-full">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
@@ -170,12 +179,17 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
</TableRow>
)}
<TableRow>
<TableHead scope="row" data-required>계획구분</TableHead>
<TableHead id="sewer-bsc-plan-apply-plan-se-label" scope="row" data-required>
계획구분
</TableHead>
<TableCell colSpan={3}>
<FieldSet className="px-2">
{/* 라디오 그룹이 어떤 항목의 선택인지 알 수 있도록 th 와 연결 */}
<RadioGroup
value={sewerBscPlanSeComCd || undefined}
onValueChange={setSewerBscPlanSeComCd}
aria-labelledby="sewer-bsc-plan-apply-plan-se-label"
aria-required="true"
>
<div className="flex flex-wrap gap-4">
{planSeOptions?.map((option) => (
@@ -199,10 +213,17 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
</TableCell>
</TableRow>
<TableRow>
<TableHead scope="row" data-required>업무구분</TableHead>
<TableHead id="sewer-bsc-plan-apply-task-se-label" scope="row" data-required>
업무구분
</TableHead>
<TableCell colSpan={3}>
<FieldSet className="px-2">
<RadioGroup value={taskSeComCd || undefined} onValueChange={setTaskSeComCd}>
<RadioGroup
value={taskSeComCd || undefined}
onValueChange={setTaskSeComCd}
aria-labelledby="sewer-bsc-plan-apply-task-se-label"
aria-required="true"
>
<div className="flex flex-wrap gap-4">
{taskSeOptions?.map((option) => (
<FieldLabel
@@ -13,6 +13,9 @@ import {
import { type SelectedOgnzUser, toSelectedOgnzUser } from "../model/selected-ognz-user";
const FOCUS_RING_CLASS_NAME =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring";
interface OgnzUserTreeNodeProps {
node: OgnzUserTreeResponseDto;
depth?: number;
@@ -56,7 +59,10 @@ function OgnzUserTreeNode({
<CollapsibleTrigger asChild>
<button
type="button"
className="group flex w-full items-center gap-2 rounded-sm p-2 text-left hover:bg-accent"
className={cn(
"group flex w-full items-center gap-2 rounded-sm p-2 text-left hover:bg-accent",
FOCUS_RING_CLASS_NAME,
)}
>
<ChevronRight
className={cn(
@@ -73,20 +79,41 @@ function OgnzUserTreeNode({
{node.users.map((user) => {
const isSelected = selectedUserId === user.userUnqId;
const selectedUser = toSelectedOgnzUser(user, node);
const rowClassName = cn(
"group flex w-full cursor-pointer items-center gap-2 rounded-sm p-2 text-left hover:bg-accent",
isSelected && "!border !border-primary !bg-primary-transparent",
);
const userLabel = (
<>
<span className="text-[1.4rem] text-label">{user.userNm}</span>
<span className="text-xl text-muted-foreground">({user.jbpsSeNm})</span>
</>
);
// 선택 버튼이 없으면 행 자체를 버튼으로 렌더링해 키보드(Tab/Enter/Space)로 선택 가능하게 한다.
if (!showSelectButton) {
return (
<button
key={user.userUnqId}
type="button"
style={{ paddingInlineStart: indent + 24 }}
className={cn(rowClassName, FOCUS_RING_CLASS_NAME)}
aria-pressed={isSelected}
onClick={() => onSelect(selectedUser)}
>
{userLabel}
</button>
);
}
return (
<div
key={user.userUnqId}
style={{ paddingInlineStart: indent + 24 }}
className={cn(
"group flex cursor-pointer items-center gap-2 rounded-sm p-2 hover:bg-accent",
isSelected && "!border !border-primary !bg-primary-transparent",
)}
className={rowClassName}
onClick={() => onSelect(selectedUser)}
>
<span className="text-[1.4rem] text-label">{user.userNm}</span>
<span className="text-xl text-muted-foreground">({user.jbpsSeNm})</span>
{showSelectButton ? (
{userLabel}
<Button
type="button"
size="sm"
@@ -97,7 +124,6 @@ function OgnzUserTreeNode({
>
선택
</Button>
) : null}
</div>
);
})}
@@ -115,7 +115,9 @@ function PbadmsTaskApplyDialog({
<TableHeader>
{canSidoGugunSelectable && (
<TableRow>
<TableHead scope="row" data-required>시도</TableHead>
<TableHead scope="row" data-required>
<label htmlFor="pbadms-task-apply-sido">시도</label>
</TableHead>
<TableCell>
<Select
value={sidoComCd || undefined}
@@ -124,7 +126,12 @@ function PbadmsTaskApplyDialog({
setSggComCd("");
}}
>
<SelectTrigger className="w-full">
<SelectTrigger
id="pbadms-task-apply-sido"
title="시도 선택"
aria-required="true"
className="w-full"
>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
@@ -136,14 +143,16 @@ function PbadmsTaskApplyDialog({
</SelectContent>
</Select>
</TableCell>
<TableHead scope="row">시군구</TableHead>
<TableHead scope="row">
<label htmlFor="pbadms-task-apply-sgg">시군구</label>
</TableHead>
<TableCell>
<Select
value={sggComCd || undefined}
onValueChange={setSggComCd}
disabled={!sidoComCd || isUserGugunPending}
>
<SelectTrigger className="w-full">
<SelectTrigger id="pbadms-task-apply-sgg" title="시군구 선택" className="w-full">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
@@ -158,9 +167,12 @@ function PbadmsTaskApplyDialog({
</TableRow>
)}
<TableRow>
<TableHead scope="row" data-required>업무구분</TableHead>
<TableHead id="pbadms-task-apply-task-label" scope="row" data-required>
업무구분
</TableHead>
<TableCell colSpan={3}>
<FieldSet className="px-2">
{/* 체크박스 그룹이 어떤 항목의 선택인지 알 수 있도록 th 와 연결 */}
<FieldSet className="px-2" aria-labelledby="pbadms-task-apply-task-label">
<FieldGroup data-slot="checkbox-group" className="flex-row flex-wrap">
{taskOptions?.map((option) => (
<Field key={option.lwpoCd} orientation="horizontal" className="w-auto">
@@ -1,5 +1,7 @@
import { useRef, type MouseEvent } from "react";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import Autoplay from "embla-carousel-autoplay";
import type { UseEmblaCarouselType } from "embla-carousel-react";
import { Pause, Play } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "../../config";
import { getSiteInfo, getBannerList, getImageUrlByAtflCd } from "../../api";
@@ -80,6 +82,31 @@ export function Footer() {
bannerList?.filter((banner) => banner.bnrTypeCd === "BNRFTER") ?? [];
const plugin = useRef(Autoplay({ delay: 2000, stopOnInteraction: true }));
const [carouselApi, setCarouselApi] = useState<UseEmblaCarouselType[1]>();
const [isAutoplaying, setIsAutoplaying] = useState(true);
// 사용자 조작·초점 진입 등으로 자동 넘김이 멈추거나 재개될 때 버튼 상태를 동기화한다
useEffect(() => {
if (!carouselApi) return;
const syncAutoplayState = () => setIsAutoplaying(plugin.current.isPlaying());
syncAutoplayState();
carouselApi.on("autoplay:play", syncAutoplayState);
carouselApi.on("autoplay:stop", syncAutoplayState);
return () => {
carouselApi.off("autoplay:play", syncAutoplayState);
carouselApi.off("autoplay:stop", syncAutoplayState);
};
}, [carouselApi]);
const handleAutoplayToggle = () => {
if (plugin.current.isPlaying()) {
plugin.current.stop();
} else {
plugin.current.play();
}
};
const handlePopupLinkClick = (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
@@ -89,9 +116,10 @@ export function Footer() {
return (
<footer className="flex w-full shrink-0 flex-col items-start border-t border-solid border-border bg-background px-3 pb-1 pt-4 text-foreground sm:flex-row sm:px-6">
<div className="mb-4 w-full sm:mb-0 sm:w-[55%]">
<h6 className="mb-2 text-lg font-bold sm:mb-4 sm:text-xl md:text-2xl">
{/* 제목 수준 건너뜀(h3 → h6) 방지: 사이트명은 섹션 제목이 아니므로 p 사용 */}
<p className="mb-2 text-lg font-bold sm:mb-4 sm:text-xl md:text-2xl">
{siteInfo?.siteNm ?? ""}
</h6>
</p>
<p className="my-1 text-sm opacity-60 sm:text-base md:text-xl">
{siteInfo?.siteAddr ?? ""}
</p>
@@ -132,10 +160,26 @@ export function Footer() {
>지원센타</a>
</div>
<div className="hidden sm:block">
<div className="mt-4 flex justify-end sm:mt-6">
<button
type="button"
onClick={handleAutoplayToggle}
aria-label={isAutoplaying ? "배너 자동 넘김 멈춤" : "배너 자동 넘김 시작"}
className="flex items-center gap-1 rounded-sm border border-border bg-background px-2 py-1 text-sm text-accent-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{isAutoplaying ? (
<Pause className="size-4" aria-hidden="true" />
) : (
<Play className="size-4" aria-hidden="true" />
)}
<span>{isAutoplaying ? "멈춤" : "시작"}</span>
</button>
</div>
<Carousel
setApi={setCarouselApi}
plugins={[plugin.current]}
opts={{ loop: true }}
className="relative my-4 h-20 sm:my-8 sm:h-24"
className="relative mb-4 mt-2 h-20 sm:mb-8 sm:h-24"
>
<CarouselContent>
{mainBanners && mainBanners.length > 0 ? (
@@ -292,7 +292,11 @@ export function Header({
}}
/>
) : (
<Skeleton className="h-[3rem] w-[15rem]" />
// h1 안에는 div(Skeleton)를 둘 수 없어 인라인 요소로 로딩 자리표시
<span
aria-hidden="true"
className="block h-[3rem] w-[15rem] animate-pulse rounded-md bg-[rgba(0,0,0,0.1)]"
/>
)}
<span
className="text-[1.2rem] font-semibold text-foreground"
@@ -1,7 +1,13 @@
"use client";
import React, { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../dialog";
import { useQuery } from "@tanstack/react-query";
import { getMenuDetail } from "../../api";
import { ENDPOINT } from "../../config";
@@ -24,7 +30,6 @@ const PageTitle = ({
});
return (
<>
<div className="flex w-full items-center justify-between px-[1.8rem] pt-[2rem]">
<div className="flex items-center gap-3">
<h2 className="text-[2.4rem] font-semibold text-title">{title}</h2>
@@ -43,9 +48,13 @@ const PageTitle = ({
))}
</div>
)}
{/* 도움말 다이얼로그: DialogTrigger 로 연결해야 닫을 때 키보드 초점이 도움말 버튼으로 복귀한다 */}
<Dialog open={isHelpDialogOpen} onOpenChange={setIsHelpDialogOpen}>
<DialogTrigger asChild>
<button
onClick={() => setIsHelpDialogOpen(true)}
className="flex cursor-pointer items-center gap-3 transition-opacity hover:opacity-80"
type="button"
className="flex cursor-pointer items-center gap-3 rounded-sm transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<img
src="/images/help.png"
@@ -53,16 +62,18 @@ const PageTitle = ({
className="h-[20px] w-[20px]"
/>
</button>
</div>
</div>
{/* 도움말 다이얼로그 */}
<Dialog open={isHelpDialogOpen} onOpenChange={setIsHelpDialogOpen}>
</DialogTrigger>
<DialogContent className="flex max-h-[90vh] flex-col sm:max-w-[85rem]">
<DialogHeader>
<DialogTitle>도움말</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto py-4">
{/* 스크롤 영역이 초점을 받을 수 있어야 키보드(Tab/방향키)로 내용 이동·스크롤이 가능하다 */}
<div
tabIndex={0}
role="region"
aria-label="도움말 내용"
className="min-h-0 flex-1 overflow-y-auto rounded-sm py-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
{menuDetail?.menuHlpCn ? (
<div
style={{
@@ -89,7 +100,8 @@ const PageTitle = ({
</div>
</DialogContent>
</Dialog>
</>
</div>
</div>
);
};