add dialog semantics, focus trap, and ARIA labels to card detail modal

This commit is contained in:
Your Name
2026-02-19 19:49:25 +02:00
parent 21e09279eb
commit 6ca8cfb059

View File

@@ -28,6 +28,22 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const instant = { duration: 0 }; const instant = { duration: 0 };
const modalRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<Element | null>(null);
useEffect(() => {
if (open) {
triggerRef.current = document.activeElement;
const timer = setTimeout(() => {
modalRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
} else if (triggerRef.current instanceof HTMLElement) {
triggerRef.current.focus();
triggerRef.current = null;
}
}, [open]);
return ( return (
<AnimatePresence> <AnimatePresence>
{open && card && cardId && ( {open && card && cardId && (
@@ -48,6 +64,11 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
onClick={onClose} onClick={onClose}
> >
<motion.div <motion.div
ref={modalRef}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="card-detail-title"
layoutId={prefersReducedMotion ? undefined : `card-${cardId}`} layoutId={prefersReducedMotion ? undefined : `card-${cardId}`}
className="relative w-full max-w-4xl overflow-hidden rounded-xl bg-pylon-surface shadow-2xl" className="relative w-full max-w-4xl overflow-hidden rounded-xl bg-pylon-surface shadow-2xl"
initial={prefersReducedMotion ? { opacity: 0 } : undefined} initial={prefersReducedMotion ? { opacity: 0 } : undefined}
@@ -259,6 +280,7 @@ function InlineTitle({ cardId, title, updateCard, hasColor }: InlineTitleProps)
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onBlur={handleSave} onBlur={handleSave}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
aria-label="Card title"
className={`flex-1 font-heading text-xl bg-transparent outline-none border-b pb-0.5 ${ className={`flex-1 font-heading text-xl bg-transparent outline-none border-b pb-0.5 ${
hasColor ? "text-white border-white/50" : "text-pylon-text border-pylon-accent" hasColor ? "text-white border-white/50" : "text-pylon-text border-pylon-accent"
}`} }`}
@@ -268,6 +290,7 @@ function InlineTitle({ cardId, title, updateCard, hasColor }: InlineTitleProps)
return ( return (
<h2 <h2
id="card-detail-title"
onClick={() => setEditing(true)} onClick={() => setEditing(true)}
className={`flex-1 cursor-pointer font-heading text-xl transition-colors ${textColor} ${hoverColor}`} className={`flex-1 cursor-pointer font-heading text-xl transition-colors ${textColor} ${hoverColor}`}
> >
@@ -309,6 +332,7 @@ function CoverColorPicker({
onClick={() => updateCard(cardId, { coverColor: null })} onClick={() => updateCard(cardId, { coverColor: null })}
className="flex size-6 items-center justify-center rounded-full border border-border text-xs text-pylon-text-secondary hover:bg-pylon-column" className="flex size-6 items-center justify-center rounded-full border border-border text-xs text-pylon-text-secondary hover:bg-pylon-column"
title="None" title="None"
aria-label="No cover color"
> >
&times; &times;
</button> </button>
@@ -324,6 +348,7 @@ function CoverColorPicker({
outlineOffset: "1px", outlineOffset: "1px",
}} }}
title={label} title={label}
aria-label={label}
/> />
))} ))}
</div> </div>