add dialog semantics, focus trap, and ARIA labels to card detail modal
This commit is contained in:
@@ -28,6 +28,22 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
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 (
|
||||
<AnimatePresence>
|
||||
{open && card && cardId && (
|
||||
@@ -48,6 +64,11 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
ref={modalRef}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="card-detail-title"
|
||||
layoutId={prefersReducedMotion ? undefined : `card-${cardId}`}
|
||||
className="relative w-full max-w-4xl overflow-hidden rounded-xl bg-pylon-surface shadow-2xl"
|
||||
initial={prefersReducedMotion ? { opacity: 0 } : undefined}
|
||||
@@ -259,6 +280,7 @@ function InlineTitle({ cardId, title, updateCard, hasColor }: InlineTitleProps)
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label="Card title"
|
||||
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"
|
||||
}`}
|
||||
@@ -268,6 +290,7 @@ function InlineTitle({ cardId, title, updateCard, hasColor }: InlineTitleProps)
|
||||
|
||||
return (
|
||||
<h2
|
||||
id="card-detail-title"
|
||||
onClick={() => setEditing(true)}
|
||||
className={`flex-1 cursor-pointer font-heading text-xl transition-colors ${textColor} ${hoverColor}`}
|
||||
>
|
||||
@@ -309,6 +332,7 @@ function CoverColorPicker({
|
||||
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"
|
||||
title="None"
|
||||
aria-label="No cover color"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -324,6 +348,7 @@ function CoverColorPicker({
|
||||
outlineOffset: "1px",
|
||||
}}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user