mirror of
https://github.com/codeaashu/claude-code.git
synced 2026-04-08 22:28:48 +03:00
claude-code
This commit is contained in:
68
web/components/a11y/Announcer.tsx
Normal file
68
web/components/a11y/Announcer.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
interface AnnouncerContextValue {
|
||||
announce: (message: string, politeness?: "polite" | "assertive") => void;
|
||||
}
|
||||
|
||||
const AnnouncerContext = createContext<AnnouncerContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Provides a programmatic screen-reader announcement API via context.
|
||||
* Place <AnnouncerProvider> near the root of the app, then call `useAnnouncer()`
|
||||
* anywhere to imperatively announce status changes.
|
||||
*
|
||||
* @example
|
||||
* const { announce } = useAnnouncer();
|
||||
* announce("File uploaded successfully");
|
||||
* announce("Error: request failed", "assertive");
|
||||
*/
|
||||
export function AnnouncerProvider({ children }: { children: ReactNode }) {
|
||||
const [politeMsg, setPoliteMsg] = useState("");
|
||||
const [assertiveMsg, setAssertiveMsg] = useState("");
|
||||
const politeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const assertiveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const announce = useCallback((message: string, politeness: "polite" | "assertive" = "polite") => {
|
||||
if (politeness === "assertive") {
|
||||
setAssertiveMsg("");
|
||||
if (assertiveTimer.current) clearTimeout(assertiveTimer.current);
|
||||
assertiveTimer.current = setTimeout(() => setAssertiveMsg(message), 50);
|
||||
} else {
|
||||
setPoliteMsg("");
|
||||
if (politeTimer.current) clearTimeout(politeTimer.current);
|
||||
politeTimer.current = setTimeout(() => setPoliteMsg(message), 50);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const srStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
padding: 0,
|
||||
margin: "-1px",
|
||||
overflow: "hidden",
|
||||
clip: "rect(0,0,0,0)",
|
||||
whiteSpace: "nowrap",
|
||||
borderWidth: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<AnnouncerContext.Provider value={{ announce }}>
|
||||
{children}
|
||||
<div aria-live="polite" aria-atomic="true" style={srStyle}>
|
||||
{politeMsg}
|
||||
</div>
|
||||
<div aria-live="assertive" aria-atomic="true" style={srStyle}>
|
||||
{assertiveMsg}
|
||||
</div>
|
||||
</AnnouncerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAnnouncer() {
|
||||
const ctx = useContext(AnnouncerContext);
|
||||
if (!ctx) throw new Error("useAnnouncer must be used within <AnnouncerProvider>");
|
||||
return ctx;
|
||||
}
|
||||
72
web/components/a11y/FocusTrap.tsx
Normal file
72
web/components/a11y/FocusTrap.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
|
||||
interface FocusTrapProps {
|
||||
children: ReactNode;
|
||||
/** When false, the trap is inactive (e.g. when the panel is hidden) */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const FOCUSABLE_SELECTORS = [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(", ");
|
||||
|
||||
/**
|
||||
* Traps keyboard focus within its children when `active` is true.
|
||||
* Use for modals, drawers, and other overlay patterns.
|
||||
* Note: Radix Dialog handles focus trapping natively — use this only for
|
||||
* custom overlay components that don't use Radix primitives.
|
||||
*/
|
||||
export function FocusTrap({ children, active = true }: FocusTrapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const focusable = () =>
|
||||
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)).filter(
|
||||
(el) => !el.closest("[aria-hidden='true']")
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab") return;
|
||||
const els = focusable();
|
||||
if (els.length === 0) return;
|
||||
|
||||
const first = els[0];
|
||||
const last = els[els.length - 1];
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Move focus into the trap on mount
|
||||
const els = focusable();
|
||||
if (els.length > 0 && !container.contains(document.activeElement)) {
|
||||
els[0].focus();
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [active]);
|
||||
|
||||
return <div ref={containerRef}>{children}</div>;
|
||||
}
|
||||
55
web/components/a11y/LiveRegion.tsx
Normal file
55
web/components/a11y/LiveRegion.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface LiveRegionProps {
|
||||
/** The message to announce. Changing this value triggers an announcement. */
|
||||
message: string;
|
||||
/**
|
||||
* "polite" — waits for user to be idle (new chat messages, status updates)
|
||||
* "assertive" — interrupts immediately (errors, critical alerts)
|
||||
*/
|
||||
politeness?: "polite" | "assertive";
|
||||
}
|
||||
|
||||
/**
|
||||
* Managed aria-live region that announces dynamic content to screen readers.
|
||||
* Clears after 500 ms to ensure repeated identical messages are re-announced.
|
||||
*/
|
||||
export function LiveRegion({ message, politeness = "polite" }: LiveRegionProps) {
|
||||
const [announced, setAnnounced] = useState("");
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!message) return;
|
||||
|
||||
// Clear first to force re-announcement of identical messages
|
||||
setAnnounced("");
|
||||
timerRef.current = setTimeout(() => setAnnounced(message), 50);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [message]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live={politeness}
|
||||
aria-atomic="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
padding: 0,
|
||||
margin: "-1px",
|
||||
overflow: "hidden",
|
||||
clip: "rect(0, 0, 0, 0)",
|
||||
whiteSpace: "nowrap",
|
||||
borderWidth: 0,
|
||||
}}
|
||||
>
|
||||
{announced}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
web/components/a11y/SkipToContent.tsx
Normal file
18
web/components/a11y/SkipToContent.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
export function SkipToContent() {
|
||||
return (
|
||||
<a
|
||||
href="#main-content"
|
||||
className={[
|
||||
"sr-only focus:not-sr-only",
|
||||
"focus:fixed focus:top-4 focus:left-4 focus:z-50",
|
||||
"focus:px-4 focus:py-2 focus:rounded-md",
|
||||
"focus:bg-brand-600 focus:text-white focus:font-medium focus:text-sm",
|
||||
"focus:outline-none focus:ring-2 focus:ring-brand-300 focus:ring-offset-2",
|
||||
].join(" ")}
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
);
|
||||
}
|
||||
31
web/components/a11y/VisuallyHidden.tsx
Normal file
31
web/components/a11y/VisuallyHidden.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface VisuallyHiddenProps {
|
||||
children: ReactNode;
|
||||
/** When true, renders as a span inline; defaults to span */
|
||||
as?: "span" | "div" | "p";
|
||||
}
|
||||
|
||||
/**
|
||||
* Visually hides content while keeping it accessible to screen readers.
|
||||
* Use for icon-only buttons, supplemental context, etc.
|
||||
*/
|
||||
export function VisuallyHidden({ children, as: Tag = "span" }: VisuallyHiddenProps) {
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
padding: 0,
|
||||
margin: "-1px",
|
||||
overflow: "hidden",
|
||||
clip: "rect(0, 0, 0, 0)",
|
||||
whiteSpace: "nowrap",
|
||||
borderWidth: 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user