Files
SnapOtter/apps/web/src/components/common/bottom-sheet.tsx
T
SnapOtterandGitHub 6f276b4ef0 feat(a11y): WCAG 2.2 AA accessibility compliance (#209)
* feat(a11y): add i18n keys for ARIA labels and screen reader text

* fix(security): harden API against pentest findings

- Default TRUST_PROXY=false to prevent XFF rate limit bypass (PT-01)
- Return 400 instead of 500 on malformed JSON input (PT-03)
- Default MAX_PIPELINE_STEPS=20 to prevent DoS (PT-04)
- Validate clientJobId length (max 128) across all routes (PT-06)
- Add security headers to all reply.hijack() streaming responses (PT-07)
- Sanitize usernames in audit log to prevent stored XSS (PT-08)
- Block TRACE method with 405 response (PT-10)
- Add 429 RateLimited response to OpenAPI spec (PT-12)
- Default MAX_SVG_SIZE_MB=50 to limit SVGZ decompression (PT-13)
- Pin Dockerfile base images by digest
- Sanitize OIDC IdP error and sub claim in audit log
- Sync Docker compose/Dockerfile defaults with env.ts

* feat(a11y): convert all hardcoded aria-labels to i18n keys

Replace 49 hardcoded aria-label="..." strings across 25 files with
their corresponding t.a11y.* and t.common.* i18n references. Add
useTranslation import and hook call to 15 components that lacked it.
Zero hardcoded aria-labels remain in the codebase.

* feat(a11y): add aria-labels to icon-only buttons, aria-hidden on decorative icons, sr-only status text

* feat(a11y): add aria-live regions for processing status announcements

* feat(a11y): add skip-nav link, route announcer, main content landmark, and page h1 elements

* feat(a11y): add prefers-reduced-motion support, preserve functional spinners

* feat(a11y): add useFocusTrap hook for modal focus management

* feat(a11y): add focus trapping and dialog roles to all modals

* feat(a11y): add toggle switch roles, form labels, and error association

* fix(a11y): fix contrast failures, touch targets, and add nav landmark to sidebar

* fix(a11y): add role=switch to remaining toggle buttons found in verification sweep
2026-06-07 23:32:41 +08:00

119 lines
3.2 KiB
TypeScript

import { useDrag } from "@use-gesture/react";
import { X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { useFocusTrap } from "@/hooks/use-focus-trap";
interface BottomSheetProps {
open: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
maxHeight?: string;
}
export function BottomSheet({
open,
onClose,
title,
children,
maxHeight = "70dvh",
}: BottomSheetProps) {
const { t } = useTranslation();
const sheetRef = useRef<HTMLDivElement>(null);
useFocusTrap(sheetRef, open);
const [translateY, setTranslateY] = useState(0);
// Close on Escape key
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
// Reset translation when opened
useEffect(() => {
if (open) setTranslateY(0);
}, [open]);
const handleDismiss = useCallback(() => {
setTranslateY(0);
onClose();
}, [onClose]);
const bind = useDrag(
({ movement: [, my], last, cancel }) => {
// Only allow downward dragging
if (my < 0) {
setTranslateY(0);
return;
}
if (last) {
if (my > 100) {
handleDismiss();
} else {
setTranslateY(0);
}
return;
}
setTranslateY(my);
},
{ axis: "y", filterTaps: true },
);
if (!open) return null;
return (
<>
{/* Backdrop */}
<div
aria-hidden="true"
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
onClick={handleDismiss}
/>
{/* Sheet */}
<div
ref={sheetRef}
role="dialog"
aria-modal="true"
aria-labelledby={title ? "bottom-sheet-title" : undefined}
className="fixed inset-x-0 bottom-0 z-50 bg-background border-t border-border rounded-t-2xl shadow-xl flex flex-col animate-in slide-in-from-bottom"
style={{
maxHeight,
transform: translateY > 0 ? `translateY(${translateY}px)` : undefined,
transition: translateY > 0 ? "none" : "transform 0.2s ease-out",
}}
>
{/* Drag handle */}
<div {...bind()} className="flex justify-center pt-2 pb-1 cursor-grab touch-none">
<div className="w-8 h-1 rounded-full bg-muted-foreground/30" />
</div>
{/* Header */}
{title && (
<div className="flex items-center justify-between px-4 pb-2 shrink-0">
<h2 id="bottom-sheet-title" className="text-sm font-semibold text-foreground">
{title}
</h2>
<button
type="button"
onClick={handleDismiss}
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
aria-label={t.common.close}
>
<X className="h-4 w-4" />
</button>
</div>
)}
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto px-4 pb-4 min-h-0">{children}</div>
</div>
</>
);
}