diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d301e276..f9f6bff7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,6 +4,7 @@ import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-route import { Toaster, toast } from "sonner"; import { ConnectionMonitor } from "./components/common/connection-monitor"; import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider"; +import { RouteAnnouncer } from "./components/common/route-announcer"; import { I18nProvider } from "./contexts/i18n-context"; import { useAuth } from "./hooks/use-auth"; import { useMobile } from "./hooks/use-mobile"; @@ -221,6 +222,13 @@ export function App() { + + {en.a11y.skipToContent} + + }> diff --git a/apps/web/src/components/common/before-after-slider.tsx b/apps/web/src/components/common/before-after-slider.tsx index 61ae9c11..07ace22c 100644 --- a/apps/web/src/components/common/before-after-slider.tsx +++ b/apps/web/src/components/common/before-after-slider.tsx @@ -134,7 +134,7 @@ export function BeforeAfterSlider({
(null); + useFocusTrap(sheetRef, open); const [translateY, setTranslateY] = useState(0); // Close on Escape key @@ -74,6 +78,9 @@ export function BottomSheet({ {/* Sheet */}
-

{title}

+

+ {title} +

diff --git a/apps/web/src/components/common/dropzone.tsx b/apps/web/src/components/common/dropzone.tsx index ff114776..e20d8e4a 100644 --- a/apps/web/src/components/common/dropzone.tsx +++ b/apps/web/src/components/common/dropzone.tsx @@ -205,7 +205,7 @@ export function Dropzone({ return (
{t.dropzone.dropPrompt}

-

{t.dropzone.browseOrPaste}

+

{t.dropzone.browseOrPaste}

-

+

{acceptDescription ?? t.dropzone.defaultFormats}

diff --git a/apps/web/src/components/common/file-library-modal.tsx b/apps/web/src/components/common/file-library-modal.tsx index 05ccadb9..b0f91dde 100644 --- a/apps/web/src/components/common/file-library-modal.tsx +++ b/apps/web/src/components/common/file-library-modal.tsx @@ -1,6 +1,7 @@ import { Check, FolderOpen, ImageIcon, Loader2, Search, X } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "@/contexts/i18n-context"; +import { useFocusTrap } from "@/hooks/use-focus-trap"; import { apiListFiles, formatHeaders, @@ -40,7 +41,7 @@ function AuthImage({ src, alt, className }: { src: string; alt: string; classNam if (failed) { return (
- +
); } @@ -64,6 +65,8 @@ interface FileLibraryModalProps { export function FileLibraryModal({ open, onClose, onImport }: FileLibraryModalProps) { const { t } = useTranslation(); + const dialogRef = useRef(null); + useFocusTrap(dialogRef, open); const [files, setFiles] = useState([]); const [loading, setLoading] = useState(false); const [importing, setImporting] = useState(false); @@ -149,11 +152,17 @@ export function FileLibraryModal({ open, onClose, onImport }: FileLibraryModalPr className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-default" onClick={onClose} /> -
+
{/* Header */}
-

+

{t.automate.importFromLibrary}

@@ -215,6 +218,7 @@ export function ImageViewer({ disabled={zoom >= ZOOM_STEPS[ZOOM_STEPS.length - 1]} className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed" title="Zoom in" + aria-label={t.a11y.zoomIn} > @@ -224,6 +228,7 @@ export function ImageViewer({ onClick={fitToContainer} className={`px-2 py-1 rounded text-xs ${fitMode === "fit" ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted"}`} title="Fit to view" + aria-label={t.a11y.fitToView} > @@ -232,6 +237,7 @@ export function ImageViewer({ onClick={actualSize} className={`px-2 py-1 rounded text-xs ${fitMode === "actual" && zoom === 100 ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted"}`} title="Actual size (100%)" + aria-label={t.a11y.actualSize} > @@ -251,7 +257,7 @@ export function ImageViewer({

Preview not available

-

{filename}

+

{filename}

) : bgPreview?.backgroundSrc || bgPreview?.containerBackground ? ( /* Layered bg-removal preview: background layer + subject layer */ diff --git a/apps/web/src/components/common/multi-image-viewer.tsx b/apps/web/src/components/common/multi-image-viewer.tsx index ab78595d..25f13645 100644 --- a/apps/web/src/components/common/multi-image-viewer.tsx +++ b/apps/web/src/components/common/multi-image-viewer.tsx @@ -3,6 +3,7 @@ import { useCallback } from "react"; import { BeforeAfterSlider } from "@/components/common/before-after-slider"; import { ImageViewer } from "@/components/common/image-viewer"; import { ThumbnailStrip } from "@/components/common/thumbnail-strip"; +import { useTranslation } from "@/contexts/i18n-context"; import { useFileStore } from "@/stores/file-store"; const BROWSER_PREVIEWABLE_EXTS = new Set([ @@ -24,6 +25,7 @@ function canBrowserPreview(url: string): boolean { } export function MultiImageViewer() { + const { t } = useTranslation(); const { entries, selectedIndex, setSelectedIndex, navigateNext, navigatePrev } = useFileStore(); const handleKeyDown = useCallback( @@ -65,7 +67,7 @@ export function MultiImageViewer() { return (
@@ -120,7 +122,7 @@ export function MultiImageViewer() { type="button" onClick={navigateNext} className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors" - aria-label="Next image" + aria-label={t.a11y.nextImage} > diff --git a/apps/web/src/components/common/progress-card.tsx b/apps/web/src/components/common/progress-card.tsx index a8ad84be..30be0e9e 100644 --- a/apps/web/src/components/common/progress-card.tsx +++ b/apps/web/src/components/common/progress-card.tsx @@ -22,7 +22,11 @@ export function ProgressCard({ active, phase, label, stage, percent, elapsed }: const sublabel = [stage, `${elapsed}s`].filter(Boolean).join(" · "); return ( -
+
{icon} diff --git a/apps/web/src/components/common/route-announcer.tsx b/apps/web/src/components/common/route-announcer.tsx new file mode 100644 index 00000000..e1ff1a7d --- /dev/null +++ b/apps/web/src/components/common/route-announcer.tsx @@ -0,0 +1,48 @@ +import { useEffect, useRef } from "react"; +import { useLocation } from "react-router-dom"; + +export function RouteAnnouncer() { + const location = useLocation(); + const isFirstRender = useRef(true); + const announcerRef = useRef(null); + + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + + const timer = setTimeout(() => { + const h1 = document.querySelector("h1"); + if (h1) { + if (!h1.hasAttribute("tabindex")) { + h1.setAttribute("tabindex", "-1"); + } + h1.focus({ preventScroll: true }); + if (announcerRef.current) { + announcerRef.current.textContent = h1.textContent || ""; + } + } else { + const main = document.getElementById("main-content"); + if (main) { + if (!main.hasAttribute("tabindex")) { + main.setAttribute("tabindex", "-1"); + } + main.focus({ preventScroll: true }); + } + } + }, 300); + + return () => clearTimeout(timer); + }, [location.pathname]); + + return ( +
+ ); +} diff --git a/apps/web/src/components/common/search-bar.tsx b/apps/web/src/components/common/search-bar.tsx index 9c428cf0..2de2dd6d 100644 --- a/apps/web/src/components/common/search-bar.tsx +++ b/apps/web/src/components/common/search-bar.tsx @@ -19,6 +19,7 @@ export function SearchBar({ value, onChange, placeholder }: SearchBarProps) { value={value} onChange={(e) => onChange(e.target.value)} placeholder={resolvedPlaceholder} + aria-label={resolvedPlaceholder} className="w-full ps-10 pe-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" />
diff --git a/apps/web/src/components/common/tool-card.tsx b/apps/web/src/components/common/tool-card.tsx index e328501b..19e44e4d 100644 --- a/apps/web/src/components/common/tool-card.tsx +++ b/apps/web/src/components/common/tool-card.tsx @@ -36,8 +36,9 @@ export function ToolCard({ tool }: ToolCardProps) {
@@ -58,10 +59,26 @@ export function ToolCard({ tool }: ToolCardProps) { {t.common.experimental} )} - {aiStatus === "not_installed" && } - {aiStatus === "queued" && } + {aiStatus === "not_installed" && ( + <> +
diff --git a/apps/web/src/components/common/url-import-modal.tsx b/apps/web/src/components/common/url-import-modal.tsx index 6a0dcf22..e91d8332 100644 --- a/apps/web/src/components/common/url-import-modal.tsx +++ b/apps/web/src/components/common/url-import-modal.tsx @@ -1,6 +1,7 @@ import { AlertCircle, Check, Clock, Link, Loader2, RotateCw, X } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "@/contexts/i18n-context"; +import { useFocusTrap } from "@/hooks/use-focus-trap"; import { type UrlImportEntry, useUrlImport } from "@/hooks/use-url-import"; import { format } from "@/lib/format"; import { extractUrls } from "@/lib/url-parser"; @@ -45,6 +46,8 @@ function filenameFromUrl(url: string): string { export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) { const { t } = useTranslation(); + const dialogRef = useRef(null); + useFocusTrap(dialogRef, true); const [text, setText] = useState(""); const [adding, setAdding] = useState(false); @@ -104,18 +107,22 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) { {/* Modal card */}
{/* Header */}
-

{t.urlImport.title}

+

+ {t.urlImport.title} +

diff --git a/apps/web/src/components/editor/common/fill-dialog.tsx b/apps/web/src/components/editor/common/fill-dialog.tsx index e05ac161..91945bec 100644 --- a/apps/web/src/components/editor/common/fill-dialog.tsx +++ b/apps/web/src/components/editor/common/fill-dialog.tsx @@ -1,6 +1,7 @@ // apps/web/src/components/editor/common/fill-dialog.tsx import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; import { cn, generateId } from "@/lib/utils"; import { useEditorStore } from "@/stores/editor-store"; import type { CanvasObject } from "@/types/editor"; @@ -44,6 +45,7 @@ function resolveColor( } export function FillDialog({ open, onClose }: FillDialogProps) { + const { t } = useTranslation(); const [content, setContent] = useState("foreground"); const [customColor, setCustomColor] = useState("#ff0000"); const [opacity, setOpacity] = useState(100); @@ -119,7 +121,7 @@ export function FillDialog({ open, onClose }: FillDialogProps) {

Fill

diff --git a/apps/web/src/components/editor/common/image-resize-dialog.tsx b/apps/web/src/components/editor/common/image-resize-dialog.tsx index 0e83ec6c..a8c2cea1 100644 --- a/apps/web/src/components/editor/common/image-resize-dialog.tsx +++ b/apps/web/src/components/editor/common/image-resize-dialog.tsx @@ -1,9 +1,11 @@ import { Lock, Unlock, X } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; import { cn } from "@/lib/utils"; import { useEditorStore } from "@/stores/editor-store"; export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const { t } = useTranslation(); const canvasSize = useEditorStore((s) => s.canvasSize); const resizeImage = useEditorStore((s) => s.resizeImage); @@ -68,7 +70,7 @@ export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: ( @@ -63,7 +65,7 @@ export function EditorRightPanel() { type="button" onClick={togglePanel} className="px-1.5 py-2 text-muted-foreground hover:text-foreground" - aria-label="Collapse panel" + aria-label={t.a11y.collapsePanel} > diff --git a/apps/web/src/components/editor/options/crop-options.tsx b/apps/web/src/components/editor/options/crop-options.tsx index 05a5cc66..bd6afcdc 100644 --- a/apps/web/src/components/editor/options/crop-options.tsx +++ b/apps/web/src/components/editor/options/crop-options.tsx @@ -1,6 +1,7 @@ import { ArrowLeftRight, Check, X } from "lucide-react"; import { useCallback, useState } from "react"; import { ASPECT_RATIOS } from "@/components/editor/tools/crop-tool"; +import { useTranslation } from "@/contexts/i18n-context"; import { cn } from "@/lib/utils"; import { useEditorStore } from "@/stores/editor-store"; @@ -9,6 +10,7 @@ import { useEditorStore } from "@/stores/editor-store"; // --------------------------------------------------------------------------- export function CropOptions() { + const { t } = useTranslation(); const cropState = useEditorStore((s) => s.cropState); const setCropState = useEditorStore((s) => s.setCropState); const applyCrop = useEditorStore((s) => s.applyCrop); @@ -123,7 +125,7 @@ export function CropOptions() { type="button" onClick={handleSwap} title="Swap dimensions" - aria-label="Swap dimensions" + aria-label={t.a11y.swapDimensions} className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" > @@ -150,7 +152,7 @@ export function CropOptions() { type="button" onClick={handleApply} title="Apply Crop (Enter)" - aria-label="Apply Crop" + aria-label={t.a11y.applyCrop} className={cn( "flex h-7 items-center gap-1 rounded bg-primary px-2.5 text-xs text-primary-foreground", "hover:bg-primary/90 transition-colors", @@ -163,7 +165,7 @@ export function CropOptions() { type="button" onClick={handleCancel} title="Cancel Crop (Escape)" - aria-label="Cancel Crop" + aria-label={t.a11y.cancelCrop} className={cn( "flex h-7 items-center gap-1 rounded border border-border px-2.5 text-xs", "text-muted-foreground hover:bg-muted hover:text-foreground transition-colors", diff --git a/apps/web/src/components/editor/options/text-options.tsx b/apps/web/src/components/editor/options/text-options.tsx index 1c1ca65a..bc93867d 100644 --- a/apps/web/src/components/editor/options/text-options.tsx +++ b/apps/web/src/components/editor/options/text-options.tsx @@ -11,6 +11,7 @@ import { Underline, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; import { cn } from "@/lib/utils"; import { useEditorStore } from "@/stores/editor-store"; import type { TextAttrs } from "@/types/editor"; @@ -131,6 +132,7 @@ function NumberInput({ // --------------------------------------------------------------------------- function FontDropdown({ value, onChange }: { value: string; onChange: (name: string) => void }) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); const containerRef = useRef(null); const fonts = useMemo(() => getAllFonts(), []); @@ -162,7 +164,7 @@ function FontDropdown({ value, onChange }: { value: string; onChange: (name: str
@@ -296,6 +298,7 @@ function ColorPickerPopover({ // --- Main component --- export function ColorPanel() { + const { t } = useTranslation(); const foregroundColor = useEditorStore((s) => s.foregroundColor); const backgroundColor = useEditorStore((s) => s.backgroundColor); const recentColors = useEditorStore((s) => s.recentColors); @@ -362,7 +365,7 @@ export function ColorPanel() { pickerTarget === "bg" ? "border-primary ring-1 ring-primary" : "border-border", )} style={{ backgroundColor: backgroundColor }} - aria-label="Background color" + aria-label={t.a11y.backgroundColor} data-testid="bg-color-swatch" /> {/* Foreground swatch (top-left, overlapping) */} @@ -374,7 +377,7 @@ export function ColorPanel() { pickerTarget === "fg" ? "border-primary ring-1 ring-primary" : "border-border", )} style={{ backgroundColor: foregroundColor }} - aria-label="Foreground color" + aria-label={t.a11y.foregroundColor} data-testid="fg-color-swatch" /> @@ -388,7 +391,7 @@ export function ColorPanel() { "bg-card/80 hover:bg-muted", )} title="Swap colors (X)" - aria-label="Swap foreground and background colors" + aria-label={t.a11y.swapColors} data-testid="swap-colors" > @@ -404,7 +407,7 @@ export function ColorPanel() { "bg-card/80 hover:bg-muted", )} title="Reset colors (D)" - aria-label="Reset to default black and white" + aria-label={t.a11y.resetColors} data-testid="reset-colors" > @@ -426,7 +429,7 @@ export function ColorPanel() { )} maxLength={7} spellCheck={false} - aria-label="Foreground color hex value" + aria-label={t.a11y.foregroundHex} data-testid="foreground-hex-input" />
diff --git a/apps/web/src/components/editor/panels/history-panel.tsx b/apps/web/src/components/editor/panels/history-panel.tsx index 69300328..acefcfab 100644 --- a/apps/web/src/components/editor/panels/history-panel.tsx +++ b/apps/web/src/components/editor/panels/history-panel.tsx @@ -22,6 +22,7 @@ import { Undo2, } from "lucide-react"; import { useCallback, useMemo, useSyncExternalStore } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; import { cn } from "@/lib/utils"; import { useEditorStore } from "@/stores/editor-store"; @@ -80,6 +81,7 @@ interface HistoryEntry { } export function HistoryPanel() { + const { t } = useTranslation(); const lastAction = useEditorStore((s) => s.lastAction); // Force re-render when history changes by subscribing to history version @@ -170,7 +172,7 @@ export function HistoryPanel() { ? "text-muted-foreground hover:text-foreground hover:bg-muted" : "text-muted-foreground/30 cursor-not-allowed", )} - aria-label="Undo" + aria-label={t.a11y.undo} title="Undo (Ctrl+Z)" > @@ -185,7 +187,7 @@ export function HistoryPanel() { ? "text-muted-foreground hover:text-foreground hover:bg-muted" : "text-muted-foreground/30 cursor-not-allowed", )} - aria-label="Redo" + aria-label={t.a11y.redo} title="Redo (Ctrl+Shift+Z)" > diff --git a/apps/web/src/components/editor/panels/layers-panel.tsx b/apps/web/src/components/editor/panels/layers-panel.tsx index 00db0ef2..640374aa 100644 --- a/apps/web/src/components/editor/panels/layers-panel.tsx +++ b/apps/web/src/components/editor/panels/layers-panel.tsx @@ -14,6 +14,7 @@ import { Unlock, } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; import { cn } from "@/lib/utils"; import { useEditorStore } from "@/stores/editor-store"; import type { EditorLayer, ObjectEffects } from "@/types/editor"; @@ -80,6 +81,7 @@ const DEFAULT_STROKE: NonNullable = { // --------------------------------------------------------------------------- export function LayersPanel() { + const { t } = useTranslation(); const layers = useEditorStore((s) => s.layers); const activeLayerId = useEditorStore((s) => s.activeLayerId); const objects = useEditorStore((s) => s.objects); @@ -167,7 +169,11 @@ export function LayersPanel() {
{/* Layer list */} -
+
{displayLayers.map((layer) => { const realIndex = layers.findIndex((l) => l.id === layer.id); return ( @@ -202,7 +208,7 @@ export function LayersPanel() { onClick={addLayer} className="flex items-center justify-center h-7 w-7 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" title="New Layer (Ctrl+Shift+N)" - aria-label="Add layer" + aria-label={t.a11y.addLayer} data-testid="add-layer-btn" > @@ -217,7 +223,7 @@ export function LayersPanel() { disabled={layers.length <= 1} className="flex items-center justify-center h-7 w-7 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40 disabled:cursor-not-allowed" title="Delete Layer" - aria-label="Delete layer" + aria-label={t.a11y.deleteLayer} data-testid="delete-layer-btn" > @@ -523,7 +529,7 @@ function LayerRow({ className="w-full h-full object-cover" /> ) : ( - + )}
@@ -673,7 +679,7 @@ function LayerEffectsSection({ }) { return (
-

+

Layer Effects

diff --git a/apps/web/src/components/editor/panels/navigator-panel.tsx b/apps/web/src/components/editor/panels/navigator-panel.tsx index 44cd799b..1e4a9e1a 100644 --- a/apps/web/src/components/editor/panels/navigator-panel.tsx +++ b/apps/web/src/components/editor/panels/navigator-panel.tsx @@ -2,6 +2,7 @@ import { Minus, Plus } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; import { useEditorStore } from "@/stores/editor-store"; const THUMBNAIL_MAX_HEIGHT = 80; @@ -10,6 +11,7 @@ const MIN_ZOOM = 0.01; const MAX_ZOOM = 64; export function NavigatorPanel() { + const { t } = useTranslation(); const canvasRef = useRef(null); const containerRef = useRef(null); const isDraggingRef = useRef(false); @@ -245,7 +247,7 @@ export function NavigatorPanel() { type="button" onClick={() => setZoom(Math.max(MIN_ZOOM, zoom / 1.2))} className="p-0.5 text-muted-foreground hover:text-foreground" - aria-label="Zoom out" + aria-label={t.a11y.zoomOut} > @@ -262,7 +264,7 @@ export function NavigatorPanel() { type="button" onClick={() => setZoom(Math.min(MAX_ZOOM, zoom * 1.2))} className="p-0.5 text-muted-foreground hover:text-foreground" - aria-label="Zoom in" + aria-label={t.a11y.zoomIn} > diff --git a/apps/web/src/components/files/file-details.tsx b/apps/web/src/components/files/file-details.tsx index 494e32b9..0e643758 100644 --- a/apps/web/src/components/files/file-details.tsx +++ b/apps/web/src/components/files/file-details.tsx @@ -44,7 +44,7 @@ function AuthImage({ src, alt, className }: { src: string; alt: string; classNam if (failed) { return (
- +
); } diff --git a/apps/web/src/components/help/help-dialog.tsx b/apps/web/src/components/help/help-dialog.tsx index b4950c4d..af98a6c3 100644 --- a/apps/web/src/components/help/help-dialog.tsx +++ b/apps/web/src/components/help/help-dialog.tsx @@ -1,7 +1,8 @@ import { APP_VERSION } from "@snapotter/shared"; import { BookOpen, ExternalLink, Github, Keyboard, X } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useTranslation } from "@/contexts/i18n-context"; +import { useFocusTrap } from "@/hooks/use-focus-trap"; import { formatShortcut } from "@/hooks/use-keyboard-shortcuts"; interface HelpDialogProps { @@ -25,6 +26,8 @@ const SHORTCUTS = [ export function HelpDialog({ open, onClose }: HelpDialogProps) { const { t } = useTranslation(); + const dialogRef = useRef(null); + useFocusTrap(dialogRef, open); useEffect(() => { if (!open) return; const handler = (e: KeyboardEvent) => { @@ -44,14 +47,23 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) { onClick={onClose} /> -
+
{/* Header */}
-

{t.help.heading}

+

+ {t.help.heading} +

diff --git a/apps/web/src/components/layout/app-layout.tsx b/apps/web/src/components/layout/app-layout.tsx index 5e233708..8112f1f3 100644 --- a/apps/web/src/components/layout/app-layout.tsx +++ b/apps/web/src/components/layout/app-layout.tsx @@ -1,6 +1,7 @@ import { Globe, Menu, X } from "lucide-react"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useTranslation } from "@/contexts/i18n-context"; +import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; import { useConnectionStore } from "@/stores/connection-store"; @@ -30,7 +31,9 @@ export function AppLayout({ const [settingsOpen, setSettingsOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); - const { locale, setLocale, supportedLocales } = useTranslation(); + const mobileSidebarRef = useRef(null); + useFocusTrap(mobileSidebarRef, mobileSidebarOpen); + const { t, locale, setLocale, supportedLocales } = useTranslation(); const isMobile = useMobile(); const connectionStatus = useConnectionStore((s) => s.status); const bannerVisible = connectionStatus !== "connected"; @@ -58,7 +61,13 @@ export function AppLayout({ className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm cursor-default" onClick={() => setMobileSidebarOpen(false)} /> -
+
@@ -70,6 +79,7 @@ export function AppLayout({ type="button" onClick={() => setMobileSidebarOpen(false)} className="p-2.5 rounded-lg hover:bg-muted" + aria-label={t.a11y.closeSidebar} > @@ -118,6 +128,7 @@ export function AppLayout({ type="button" onClick={() => setMobileSidebarOpen(true)} className="p-2.5 -ms-1 rounded-lg hover:bg-muted" + aria-label={t.a11y.openSidebar} > @@ -132,7 +143,10 @@ export function AppLayout({ {showToolPanel && !isMobile && } -
+
{children || }
diff --git a/apps/web/src/components/layout/footer.tsx b/apps/web/src/components/layout/footer.tsx index 8663b774..46b40642 100644 --- a/apps/web/src/components/layout/footer.tsx +++ b/apps/web/src/components/layout/footer.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "@/contexts/i18n-context"; import { useTheme } from "@/hooks/use-theme"; function LanguageSelector() { - const { locale, setLocale, supportedLocales } = useTranslation(); + const { t, locale, setLocale, supportedLocales } = useTranslation(); const [open, setOpen] = useState(false); const ref = useRef(null); @@ -52,7 +52,7 @@ function LanguageSelector() { stroke="currentColor" strokeWidth="2" role="img" - aria-label="Selected" + aria-label={t.a11y.selected} > diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index c253afb1..b9abb97d 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -44,6 +44,7 @@ export function Sidebar({ expanded = false, }: SidebarProps) { const location = useLocation(); + const { t } = useTranslation(); const { topItems, bottomItems } = useNavItems(); const renderItem = (item: SidebarItem, isActive: boolean) => { @@ -101,11 +102,11 @@ export function Sidebar({ if (expanded) { return ( -
+ ); } @@ -115,9 +116,9 @@ export function Sidebar({
-
+
+
{bottomItems.map((item) => renderItem(item, false))} diff --git a/apps/web/src/components/settings/ai-features-section.tsx b/apps/web/src/components/settings/ai-features-section.tsx index f57e27fd..1cb6886a 100644 --- a/apps/web/src/components/settings/ai-features-section.tsx +++ b/apps/web/src/components/settings/ai-features-section.tsx @@ -299,7 +299,7 @@ function BundleCard({ disabled className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium opacity-50" > - +