Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/web/src/components/CreateGraphModal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from 'react';
import { useDialog } from '../hooks/useDialogManager';
import { useModalA11y } from '../hooks/useModalA11y';
import { X, Folder, FolderOpen, Plus, Copy, FileText } from 'lucide-react';
import { useGraph } from '../contexts/GraphContext';
import { useAuth } from '../contexts/AuthContext';
Expand All @@ -14,6 +15,8 @@ interface CreateGraphModalProps {

export function CreateGraphModal({ isOpen, onClose, parentGraphId }: CreateGraphModalProps) {
useDialog(isOpen, onClose);
const panelRef = useRef<HTMLDivElement>(null);
useModalA11y(panelRef, { isOpen, label: 'Create new graph' });
const { currentTeam, currentUser } = useAuth();
const { createGraph, duplicateGraph, availableGraphs, isCreating } = useGraph();
const { showSuccess, showError } = useNotifications();
Expand Down Expand Up @@ -219,7 +222,7 @@ export function CreateGraphModal({ isOpen, onClose, parentGraphId }: CreateGraph
/>

{/* Modern eye-catching modal */}
<div className="inline-block w-full max-w-2xl p-0 my-8 overflow-hidden text-left align-middle transition-all transform bg-gradient-to-br from-gray-800/98 via-gray-850/98 to-gray-900/98 backdrop-blur-2xl shadow-2xl rounded-2xl border border-gray-600/30 animate-in slide-in-from-bottom-4 duration-300 relative">
<div ref={panelRef} className="inline-block w-full max-w-2xl p-0 my-8 overflow-hidden text-left align-middle transition-all transform bg-gradient-to-br from-gray-800/98 via-gray-850/98 to-gray-900/98 backdrop-blur-2xl shadow-2xl rounded-2xl border border-gray-600/30 animate-in slide-in-from-bottom-4 duration-300 relative">
{/* Animated gradient border */}
<div className="absolute inset-0 rounded-2xl bg-gradient-to-r from-blue-500/20 via-purple-500/20 to-green-500/20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-500 via-purple-500 via-pink-500 to-green-500"></div>
Expand All @@ -246,6 +249,7 @@ export function CreateGraphModal({ isOpen, onClose, parentGraphId }: CreateGraph
</div>
<button
onClick={handleClose}
aria-label="Close"
className="p-2 text-gray-400 hover:text-white hover:bg-red-600 rounded-lg transition-all duration-200 hover:scale-110"
>
<X className="h-5 w-5" />
Expand Down
6 changes: 5 additions & 1 deletion packages/web/src/components/CreateWorkItemModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { useDialog } from '../hooks/useDialogManager';
import { useModalA11y } from '../hooks/useModalA11y';
import { useMutation, useQuery } from '@apollo/client';
import { X, Link, ChevronDown, Plus } from 'lucide-react';
import { CREATE_WORK_ITEM, GET_WORK_ITEMS, GET_EDGES, CREATE_EDGE } from '../lib/queries';
Expand Down Expand Up @@ -50,6 +51,8 @@ interface CreateWorkItemModalProps {

export function CreateWorkItemModal({ isOpen, onClose, parentWorkItemId, position, onSubmit }: CreateWorkItemModalProps) {
useDialog(isOpen, onClose);
const panelRef = React.useRef<HTMLDivElement>(null);
useModalA11y(panelRef, { isOpen, label: parentWorkItemId ? 'Create and connect work item' : 'Create new work item' });
const { currentUser, currentTeam } = useAuth();
const { currentGraph } = useGraph();
const { showSuccess, showError } = useNotifications();
Expand Down Expand Up @@ -345,7 +348,7 @@ export function CreateWorkItemModal({ isOpen, onClose, parentWorkItemId, positio
onClick={onClose}
/>

<div className="inline-block w-full max-w-lg p-0 my-8 overflow-hidden text-left align-middle transition-all transform bg-gradient-to-br from-gray-800/98 via-gray-850/98 to-gray-900/98 backdrop-blur-2xl shadow-2xl rounded-2xl border border-gray-600/30 focus-within:ring-2 focus-within:ring-blue-500/50 animate-in slide-in-from-bottom-4 duration-300 relative" onClick={(e) => e.stopPropagation()}>
<div ref={panelRef} className="inline-block w-full max-w-lg p-0 my-8 overflow-hidden text-left align-middle transition-all transform bg-gradient-to-br from-gray-800/98 via-gray-850/98 to-gray-900/98 backdrop-blur-2xl shadow-2xl rounded-2xl border border-gray-600/30 focus-within:ring-2 focus-within:ring-blue-500/50 animate-in slide-in-from-bottom-4 duration-300 relative" onClick={(e) => e.stopPropagation()}>
<div className="absolute inset-0 rounded-2xl bg-gradient-to-r from-blue-500/20 via-purple-500/20 to-green-500/20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-500 via-purple-500 via-pink-500 to-green-500"></div>

Expand All @@ -371,6 +374,7 @@ export function CreateWorkItemModal({ isOpen, onClose, parentWorkItemId, positio
</div>
<button
onClick={onClose}
aria-label="Close"
className="p-2 text-gray-400 hover:text-white hover:bg-red-600 rounded-lg transition-all duration-200 hover:scale-110"
>
<X className="h-5 w-5" />
Expand Down
17 changes: 11 additions & 6 deletions packages/web/src/components/WorkItemDetailsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useAuth } from '../contexts/AuthContext';
import { useGraph } from '../contexts/GraphContext';
import { useNotifications } from '../contexts/NotificationContext';
import { useDialog } from '../hooks/useDialogManager';
import { useModalA11y } from '../hooks/useModalA11y';
import {
Calendar, Clock,
Layers, Trophy, Target, ListTodo, AlertTriangle, Lightbulb, Microscope,
Expand Down Expand Up @@ -80,8 +81,12 @@ export function WorkItemDetailsModal({
const disconnectDropdownRef = useRef<HTMLDivElement>(null);
const datePickerRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);

useDialog(isOpen, onClose);
// Container already manages its own initial focus (modalRef) below; this adds
// the Tab focus-trap + focus-restore-to-trigger on top.
useModalA11y(dialogRef, { isOpen, initialFocus: false });

useEffect(() => {
if (node) {
Expand All @@ -101,12 +106,11 @@ export function WorkItemDetailsModal({
useEffect(() => {
if (!isOpen) return;

// Escape is handled centrally by useDialog (defers while typing in a field,
// and keeps the dialog-manager's "close top-most" stack coherent). Here we
// only add the Ctrl/Cmd+S save shortcut.
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
e.preventDefault();
onClose();
} else if ((e.ctrlKey || e.metaKey) && e.key === 's') {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.stopPropagation();
e.preventDefault();
if (handleSaveRef.current) {
Expand Down Expand Up @@ -516,10 +520,11 @@ export function WorkItemDetailsModal({

return createPortal((
<div
ref={dialogRef}
className="fixed inset-0 bg-black/70 backdrop-blur-lg z-50 flex items-stretch sm:items-center justify-center p-0 sm:p-4"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-label="Work item details"
>
<div
className="fixed inset-0 cursor-pointer"
Expand Down
125 changes: 125 additions & 0 deletions packages/web/src/hooks/useModalA11y.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { RefObject, useEffect } from 'react';

/**
* Accessibility primitive for modal dialogs. When `isOpen`, it:
* - marks the container role="dialog" aria-modal="true" (keeps an existing role)
* and gives it an accessible name (aria-label / aria-labelledby);
* - moves focus into the dialog on open (the first form field if there is one,
* else the first focusable, else the container);
* - traps Tab / Shift+Tab so KEYBOARD focus cycles within the dialog instead of
* tabbing out to the page behind it;
* - restores focus to the trigger on close (only when focus fell back to <body>,
* so it never fights a close that deliberately moved focus elsewhere).
*
* Scope note: this is a Tab-focus trap + aria-modal hint. It does NOT make the
* background `inert`, so it doesn't block mouse clicks or an AT virtual cursor
* from reaching content behind the dialog — pair it with `useDialog` (Escape /
* click-outside) and rely on the visual backdrop for pointer dismissal.
*/

const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[contenteditable]:not([contenteditable="false"])',
'[tabindex]:not([tabindex="-1"])',
].join(',');

interface ModalA11yOptions {
isOpen: boolean;
/** Accessible name for the dialog (used when there's no visible heading id). */
label?: string;
/** id of a visible heading element; takes precedence over `label`. */
labelledBy?: string;
/** Move focus into the dialog on open (default true). Pass false when the
* component already manages its own initial focus. */
initialFocus?: boolean;
}

function isVisible(el: HTMLElement): boolean {
if (el === document.activeElement) return true;
if (el.getClientRects().length === 0) return false;
const cs = getComputedStyle(el);
return cs.visibility !== 'hidden' && cs.display !== 'none';
}

const isField = (el: HTMLElement) =>
/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) && (el as HTMLInputElement).type !== 'hidden';

export function useModalA11y(ref: RefObject<HTMLElement>, opts: ModalA11yOptions): void {
const { isOpen, label, labelledBy, initialFocus = true } = opts;

useEffect(() => {
const el = ref.current;
if (!isOpen || !el) return;

if (!el.getAttribute('role')) el.setAttribute('role', 'dialog');
el.setAttribute('aria-modal', 'true');
if (labelledBy) el.setAttribute('aria-labelledby', labelledBy);
else if (label && !el.getAttribute('aria-labelledby')) el.setAttribute('aria-label', label);

const previouslyFocused = document.activeElement as HTMLElement | null;

const focusables = () =>
Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(isVisible);

let raf = 0;
if (initialFocus) {
// Defer past paint so portaled fields/buttons exist before we grab focus,
// and prefer the first real form field over an icon-only Close button.
raf = requestAnimationFrame(() => {
const items = focusables();
const target = items.find(isField) || items[0];
if (target) {
target.focus({ preventScroll: true });
} else {
if (!el.getAttribute('tabindex')) el.setAttribute('tabindex', '-1');
el.focus({ preventScroll: true });
}
});
}

const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const items = focusables();
if (items.length === 0) {
e.preventDefault();
el.focus({ preventScroll: true });
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey) {
if (active === first || !el.contains(active)) {
e.preventDefault();
last.focus({ preventScroll: true });
}
} else if (active === last || !el.contains(active)) {
e.preventDefault();
first.focus({ preventScroll: true });
}
};
el.addEventListener('keydown', onKeyDown);

return () => {
if (raf) cancelAnimationFrame(raf);
el.removeEventListener('keydown', onKeyDown);
// Restore focus to the trigger ONLY if focus dropped to <body> as the
// dialog unmounted — never override a close that intentionally moved focus.
const active = document.activeElement as HTMLElement | null;
const focusDropped = !active || active === document.body;
if (
focusDropped &&
previouslyFocused &&
previouslyFocused !== document.body &&
document.contains(previouslyFocused) &&
typeof previouslyFocused.focus === 'function'
) {
previouslyFocused.focus({ preventScroll: true });
}
};
}, [isOpen, label, labelledBy, initialFocus, ref]);
}
130 changes: 130 additions & 0 deletions tests/e2e/a11y-focus.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { test, expect, Page } from '@playwright/test';
import { login, TEST_USERS, getBaseURL } from '../helpers/auth';

/**
* Modal accessibility / keyboard-focus gate (@a11y). Asserts the contract added
* by useModalA11y: a modal is exposed as role="dialog" aria-modal with an
* accessible name, keyboard focus MOVES INTO it on open, Tab/Shift+Tab is
* TRAPPED within it (never leaks to the page behind), and focus is RESTORED to
* the trigger on close. These were entirely untested and mostly unimplemented;
* a modal you can Tab out of (into the graph behind it) is real keyboard friction.
*/

const FOCUSABLE =
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';

async function openWorkspace(page: Page, viewMode = 'cards') {
await login(page, TEST_USERS.ADMIN);
await page.addInitScript((m) => localStorage.setItem('graphdone:viewMode', m), viewMode);
await page.goto(`${getBaseURL()}/`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
}

const focusWithinDialog = (page: Page) =>
page.evaluate(() => {
const d = document.querySelector('[role="dialog"][aria-modal="true"]');
return !!d && !!document.activeElement && d.contains(document.activeElement);
});

async function focusEdge(page: Page, which: 'first' | 'last') {
await page.evaluate(({ sel, which }) => {
const d = document.querySelector('[role="dialog"][aria-modal="true"]');
if (!d) return;
const items = Array.from(d.querySelectorAll(sel)).filter(
(e) => (e as HTMLElement).getClientRects().length > 0
) as HTMLElement[];
(which === 'first' ? items[0] : items[items.length - 1])?.focus();
}, { sel: FOCUSABLE, which });
}

/**
* Prove the trap AT THE BOUNDARY: Tab from the last focusable must wrap back
* inside the dialog, and Shift+Tab from the first must wrap to the last. This
* is the case that fails without the trap — pressing Tab a fixed number of
* times never reaches the edge on a modal with many focusables, so it would
* pass even with the trap removed.
*/
async function tabStaysTrapped(page: Page): Promise<boolean> {
await focusEdge(page, 'last');
await page.keyboard.press('Tab');
if (!(await focusWithinDialog(page))) return false;
await focusEdge(page, 'first');
await page.keyboard.press('Shift+Tab');
return focusWithinDialog(page);
}

test.describe('modal a11y: role + focus trap + restore @a11y', () => {
test.describe.configure({ timeout: 90_000 });

test.describe('desktop', () => {
test.use({ viewport: { width: 1440, height: 900 } });

test('work-item details modal: role=dialog, accessible name, focus trapped', async ({ page }) => {
await openWorkspace(page, 'cards');
await page.locator('[data-testid="view-content"] .grid > div').first().click().catch(() => {});
await page.waitForTimeout(1200);
const badge = page.locator('[data-testid="details-type-badge"]');
if (!(await badge.isVisible().catch(() => false))) test.skip(true, 'details modal did not open');

const dialog = page.locator('[role="dialog"][aria-modal="true"]');
await expect(dialog, 'exposed as a modal dialog').toBeVisible();
await expect(dialog, 'has an accessible name').toHaveAttribute('aria-label', /.+/);
expect(await tabStaysTrapped(page), 'Tab focus stays within the details modal').toBe(true);
});

test('create-graph modal: role=dialog and focus trapped', async ({ page }) => {
await openWorkspace(page, 'cards');
const sel = page.locator('[data-testid="graph-selector"]');
let trigger = null as any;
for (let i = 0; i < (await sel.count()); i++) {
if (await sel.nth(i).isVisible().catch(() => false)) { trigger = sel.nth(i); break; }
}
if (!trigger) test.skip(true, 'no graph selector');
await trigger.click();
await page.waitForTimeout(400);
const create = page.locator('[title="Create New Graph"]').first();
if (!(await create.isVisible().catch(() => false))) test.skip(true, 'no create-graph affordance');
await create.click();
await page.waitForTimeout(700);

const dialog = page.locator('[role="dialog"][aria-modal="true"]');
await expect(dialog, 'create-graph exposed as a modal dialog').toBeVisible();
expect(await tabStaysTrapped(page), 'Tab focus stays within the create-graph modal').toBe(true);
});
});

test.describe('phone', () => {
test.use({ viewport: { width: 390, height: 844 } });

test('create-work-item modal: focus enters, is trapped, and restores to the trigger', async ({ page }) => {
await openWorkspace(page, 'cards');
const fab = page.locator('[aria-label="New work item"]');
if (!(await fab.isVisible().catch(() => false))) test.skip(true, 'no create FAB');
// Focus the trigger first so the "restore to trigger" expectation is deterministic.
await fab.focus();
await fab.click();
await page.waitForTimeout(1000);

const dialog = page.locator('[role="dialog"][aria-modal="true"]');
await expect(dialog, 'create-work-item exposed as a modal dialog').toBeVisible();
// Focus moved INTO the modal (and off the trigger) on open.
expect(await focusWithinDialog(page), 'focus moved into the modal on open').toBe(true);
const onFabWhileOpen = await page.evaluate(
() => document.activeElement?.getAttribute('aria-label') === 'New work item'
);
expect(onFabWhileOpen, 'focus left the trigger while the modal is open').toBe(false);
// Tab is trapped within it.
expect(await tabStaysTrapped(page), 'Tab focus stays within the create modal').toBe(true);

// Close via the backdrop (the dialog-manager defers Escape while a text field
// is focused, by design). Focus-restore fires regardless of how it closes.
await page.mouse.click(5, 5);
await page.waitForTimeout(600);
await expect(dialog, 'modal closed').toHaveCount(0);
const restored = await page.evaluate(
() => document.activeElement?.getAttribute('aria-label') === 'New work item'
);
expect(restored, 'focus restored to the New-work-item trigger').toBe(true);
});
});
});
Loading