diff --git a/packages/web/src/components/CreateGraphModal.tsx b/packages/web/src/components/CreateGraphModal.tsx
index ce8c98b9..cfec251f 100644
--- a/packages/web/src/components/CreateGraphModal.tsx
+++ b/packages/web/src/components/CreateGraphModal.tsx
@@ -6,6 +6,7 @@ import { useGraph } from '../contexts/GraphContext';
import { useAuth } from '../contexts/AuthContext';
import { useNotifications } from '../contexts/NotificationContext';
import { CreateGraphInput } from '../types/graph';
+import { createPortal } from 'react-dom';
interface CreateGraphModalProps {
isOpen: boolean;
@@ -212,7 +213,9 @@ export function CreateGraphModal({ isOpen, onClose, parentGraphId }: CreateGraph
if (!isOpen) return null;
- return (
+ // In document.body: inside the layout's content wrapper the phone's top bar
+ // and bottom nav (z-30, outside it) would draw over the dialog and take its taps.
+ return createPortal((
{/* Enhanced Backdrop with gradient */}
@@ -773,5 +776,5 @@ export function CreateGraphModal({ isOpen, onClose, parentGraphId }: CreateGraph
- );
+ ), document.body);
}
\ No newline at end of file
diff --git a/packages/web/src/components/DeleteGraphModal.tsx b/packages/web/src/components/DeleteGraphModal.tsx
index f87eb3f0..5f4158b0 100644
--- a/packages/web/src/components/DeleteGraphModal.tsx
+++ b/packages/web/src/components/DeleteGraphModal.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useMemo } from 'react';
+import React, { useState, useEffect, useMemo, useRef } from 'react';
import { useQuery, useMutation } from '@apollo/client';
import { X, Trash2, GitBranch, Shield } from 'lucide-react';
import { AlertTriangle, CheckCircle, WORK_ITEM_TYPES } from '../constants/workItemConstants';
@@ -8,6 +8,8 @@ import { GET_WORK_ITEMS, GET_EDGES, DELETE_WORK_ITEM, DELETE_EDGE } from '../lib
import { GET_CHILD_GRAPHS } from '../graphql/graph';
import { projectDeleteBlock } from '../lib/workspaceRules';
import type { Graph } from '../types/graph';
+import { createPortal } from 'react-dom';
+import { useModalA11y } from '../hooks/useModalA11y';
interface DeleteGraphModalProps {
isOpen: boolean;
@@ -17,6 +19,8 @@ interface DeleteGraphModalProps {
}
export function DeleteGraphModal({ isOpen, onClose, graph }: DeleteGraphModalProps) {
+ const panelRef = useRef(null);
+ useModalA11y(panelRef, { isOpen, label: 'Delete graph' });
const { currentGraph, deleteGraph } = useGraph();
const targetGraph = graph ?? currentGraph;
// Asked of the backend each time: another tab may have added a graph inside since this tab's list was read.
@@ -239,7 +243,9 @@ export function DeleteGraphModal({ isOpen, onClose, graph }: DeleteGraphModalPro
}
};
- return (
+ // In document.body: inside the layout's content wrapper the phone's top bar
+ // and bottom nav (z-30, outside it) would draw over the dialog and take its taps.
+ return createPortal((
{/* Enhanced Backdrop with gradient */}
@@ -249,7 +255,7 @@ export function DeleteGraphModal({ isOpen, onClose, graph }: DeleteGraphModalPro
/>
{/* Enhanced Modal with better styling */}
-
+
{/* Gradient accent line at top - red for danger */}
@@ -807,5 +813,5 @@ export function DeleteGraphModal({ isOpen, onClose, graph }: DeleteGraphModalPro
- );
+ ), document.body);
}
diff --git a/packages/web/src/components/GraphSelector.test.tsx b/packages/web/src/components/GraphSelector.test.tsx
index c1f56351..2bb276f0 100644
--- a/packages/web/src/components/GraphSelector.test.tsx
+++ b/packages/web/src/components/GraphSelector.test.tsx
@@ -2,6 +2,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { GraphSelector } from './GraphSelector';
+import { onGraphRequests, requestGraph } from '../lib/graphRequests';
const graph = (id: string, name: string, extra: Record = {}) => ({
id, name, type: 'SUBGRAPH', createdBy: 'me', parentGraphId: null, depth: 0, path: [id], nodeCount: 1, edgeCount: 0, isShared: false, ...extra,
@@ -58,7 +59,8 @@ describe('the graph selector edit and delete buttons', () => {
const { onDeleteGraph, actions } = open();
const own = actions('Welcome', 0);
fireEvent.mouseEnter(own.trash);
- expect(screen.queryByText(/System graph/)).toBeNull();
+ expect(screen.queryByRole('tooltip')).toBeNull();
+ expect(own.trash.getAttribute('aria-describedby')).toBeNull();
fireEvent.click(own.trash);
expect(onDeleteGraph).toHaveBeenCalledWith(expect.objectContaining({ id: 'w', createdBy: 'me' }));
});
@@ -67,8 +69,54 @@ describe('the graph selector edit and delete buttons', () => {
const { onDeleteGraph, actions } = open();
const shared = actions('Welcome', 1);
fireEvent.mouseEnter(shared.trash);
- expect(screen.getByText("🔒 System graph — can't be deleted")).toBeTruthy();
+ expect(screen.getByRole('tooltip').textContent).toBe("🔒 System graph — can't be deleted");
fireEvent.click(shared.trash);
expect(onDeleteGraph).not.toHaveBeenCalled();
});
+
+ it('explain a locked button when it is tapped, on screen and to screen readers', () => {
+ const { actions } = open();
+ const shared = actions('Welcome', 1);
+ fireEvent.click(shared.trash);
+ expect(screen.getByRole('tooltip').textContent).toBe("🔒 System graph — can't be deleted");
+ expect(document.getElementById(shared.trash.getAttribute('aria-describedby')!)?.textContent).toBe("🔒 System graph — can't be deleted");
+ expect(shared.trash.getAttribute('aria-disabled')).toBe('true');
+ });
+
+ it('put the reason on the left of a button near the right edge, as on a phone', () => {
+ const { actions } = open();
+ const shared = actions('Welcome', 1);
+ shared.trash.getBoundingClientRect = () => ({ left: window.innerWidth - 40, right: window.innerWidth - 10, top: 100, height: 30, width: 30, bottom: 130, x: 0, y: 0, toJSON: () => ({}) });
+ fireEvent.click(shared.trash);
+ const tip = screen.getByRole('tooltip');
+ expect(tip.className).toContain('-translate-x-full');
+ expect(parseFloat(tip.style.left)).toBe(window.innerWidth - 48);
+ });
+});
+
+describe('the phone top bar graph selector', () => {
+ it('asks the workspace to create, rename and delete a graph, by name', () => {
+ const answer = { create: vi.fn(), edit: vi.fn(), delete: vi.fn() };
+ const stop = onGraphRequests(answer);
+ render(
+ requestGraph('create')}
+ onEditGraph={graph => requestGraph('edit', graph)}
+ onDeleteGraph={graph => requestGraph('delete', graph)}
+ />
+ );
+ const list = () => {
+ fireEvent.click(screen.getByTestId('graph-selector-mobile'));
+ return within(screen.getByTestId('graph-selector-dropdown'));
+ };
+ fireEvent.click(list().getByRole('button', { name: 'Edit Ideas' }));
+ fireEvent.click(list().getByRole('button', { name: 'Delete Ideas' }));
+ fireEvent.click(list().getByTitle('Create New Graph'));
+ stop();
+
+ expect(answer.edit).toHaveBeenCalledWith(expect.objectContaining({ id: 'i', name: 'Ideas' }));
+ expect(answer.delete).toHaveBeenCalledWith(expect.objectContaining({ id: 'i', name: 'Ideas' }));
+ expect(answer.create).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/packages/web/src/components/GraphSelector.tsx b/packages/web/src/components/GraphSelector.tsx
index cb9be473..8b978f05 100644
--- a/packages/web/src/components/GraphSelector.tsx
+++ b/packages/web/src/components/GraphSelector.tsx
@@ -9,6 +9,9 @@ import { GET_WORK_ITEMS, GET_EDGES } from '../lib/queries';
import { offlineEnabled, localGuest } from '../lib/offline/engine';
import { exampleAction, starterHierarchy, systemLocked } from '../lib/workspaceRules';
+/** Room the locked-button reason needs beside its button (it is one line, about 200 px). */
+const LOCKED_TIP_WIDTH = 240;
+
interface GraphSelectorProps {
onCreateGraph?: () => void;
onEditGraph?: (graph: any) => void;
@@ -55,7 +58,11 @@ export function GraphSelector({ onCreateGraph, onEditGraph, onDeleteGraph, testI
});
};
const [buttonPosition, setButtonPosition] = useState<{ top: number; left: number; width: number } | null>(null);
- const [hoveredTooltip, setHoveredTooltip] = useState<{ message: string; x: number; y: number } | null>(null);
+ const [hoveredTooltip, setHoveredTooltip] = useState<{ message: string; x: number; y: number; left?: boolean } | null>(null);
+ const tooltipTimer = useRef(null);
+ useEffect(() => () => {
+ if (tooltipTimer.current !== null) window.clearTimeout(tooltipTimer.current);
+ }, []);
const dropdownRef = useRef(null);
const buttonRef = useRef(null);
@@ -155,31 +162,44 @@ export function GraphSelector({ onCreateGraph, onEditGraph, onDeleteGraph, testI
const graphActions = (row: any) => {
const graph = availableGraphs.find(g => g.id === row.id) ?? row;
const isSystemGraph = systemLocked(graph, currentUser?.id);
- const action = (run: (graph: any) => void, locked: string, hover: string, icon: ReactNode) => (
-
- );
+ // Why a locked button does nothing: beside it (on its left when the right
+ // edge is near, as on a phone), on hover or on a tap, and to screen readers.
+ const explain = (target: HTMLElement, message: string, fade: boolean) => {
+ const rect = target.getBoundingClientRect();
+ const left = rect.right + 8 + LOCKED_TIP_WIDTH > window.innerWidth;
+ if (tooltipTimer.current !== null) window.clearTimeout(tooltipTimer.current);
+ tooltipTimer.current = fade ? window.setTimeout(() => setHoveredTooltip(null), 2500) : null;
+ setHoveredTooltip({ message, x: left ? rect.left - 8 : rect.right + 8, y: rect.top + rect.height / 2, left });
+ };
+ const action = (label: string, run: (graph: any) => void, locked: string, hover: string, icon: ReactNode) => {
+ const reasonId = `graph-locked-${label.toLowerCase()}-${graph.id}`;
+ return (
+
+ );
+ };
return (
-