Skip to content

Commit 63774b8

Browse files
edenoclaude
andcommitted
refactor: shared Modal primitive + replace native dialogs with in-app feedback
Behavior-preserving consolidation of dialog accessibility and feedback. No modal field/validation/save changes; YAML output unchanged (golden baselines byte-identical). - Add a shared <Modal> primitive (src/components/Modal/) owning ESC close, optional overlay-click close, body-scroll lock, focus trap, and focus return to the opener; renders nothing when closed. Add ConfirmDialog built on it. Honors prefers-reduced -motion; unique per-instance ids via useId(). - Migrate CameraModal and ElectrodeGroupModal onto Modal: lift each form into a child rendered only while open, so initial state computes once (fixes the prior unstable init effect). ElectrodeGroupModal now gains a focus trap + focus return it never had, drops the interim trap, and sources device types from the single canonical valueList.deviceTypes() (was a hardcoded list missing one entry). - Rebase AlertModal onto Modal so it inherits the trap/focus-return; keep its public props and visual classes. - Replace all blocking native dialogs in the new UI: window.confirm() -> ConfirmDialog (electrode-group delete, behavioral-event delete); alert() -> AlertModal with titles (navigating success alerts defer navigation to dismissal) and inline role=alert regions (channel-map validation errors, calendar day-creation failures). Legacy form left untouched. - Correct DataAcqSection's misleading "debounced" docstring (no behavior change). - Tests: Modal, ConfirmDialog, migrated-modal trap/return, device-source parity, the delete/alert dialog flows, and a source-scan test asserting zero native dialogs in the new-UI surfaces. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 094c805 commit 63774b8

27 files changed

Lines changed: 1356 additions & 951 deletions

‎.claude/docs/plans/v3-workspace-cutover/PLAN.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# v3.0.0 Workspace Cutover — Implementation Plan
22

3-
**Status:** Phases 0–2 complete (merged to `modern`). Phase 3 next.
3+
**Status:** Phases 0–3 complete (merged to `modern`). Phase 4 next.
44

55
Carries the `modern` branch from its mid-build state (M8a complete; new multi-page workspace exists
66
but cannot export YAML and loses data on refresh) through to **v3.0.0**, where the workspace UI is a

‎.claude/docs/plans/v3-workspace-cutover/overview.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,16 @@ not executed here.
159159
navigation race in `src/pages/Home/index.jsx` and the stale-`days`-snapshot guard in batch day
160160
creation in `src/pages/AnimalWorkspace/index.jsx` are fixed in
161161
[Phase 2](phase-2-navigation-stub-honesty.md), not silently dropped.
162+
- **Remaining dialogs not yet on the shared `<Modal>` primitive.** Phase 3 created the primitive and
163+
migrated `CameraModal`, `ElectrodeGroupModal`, and `AlertModal`. Three other overlay surfaces still
164+
use bespoke markup and lack the full dialog a11y contract (focus trap / focus return / ESC / proper
165+
ARIA): `src/pages/AnimalEditor/ChannelMapEditor.jsx`, `src/pages/AnimalEditor/CopyFromAnimalDialog.jsx`
166+
(uses `<dialog open>` without `showModal()`), and `src/components/CalendarDayCreator/CalendarDayCreator.jsx`
167+
(inline card carrying `role="dialog"`). These were **out of scope for Phase 3** (its task list named
168+
only the three migrated modals). **Follow-up:** migrate them onto `<Modal>` (or fix in place) — a
169+
natural fit for the [Phase 9](phase-9-a11y-keyboard.md) accessibility pass, which should also sweep
170+
the pre-existing color-contrast issues (inline-warning text, channel-map select focus rings, calendar
171+
day-number contrast) flagged in the Phase 3 UX review.
162172

163173
## Estimated Effort
164174

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, it, expect } from 'vitest';
2+
import fs from 'fs';
3+
import path from 'path';
4+
import { fileURLToPath } from 'url';
5+
6+
const __filename = fileURLToPath(import.meta.url);
7+
const __dirname = path.dirname(__filename);
8+
const SRC = path.resolve(__dirname, '../..');
9+
10+
/**
11+
* Directories whose new-UI surfaces must use the in-app Modal/AlertModal/ConfirmDialog
12+
* components instead of blocking native dialogs.
13+
*/
14+
const SCOPED_DIRS = [
15+
path.join(SRC, 'pages'),
16+
path.join(SRC, 'components', 'CalendarDayCreator'),
17+
];
18+
19+
// The legacy form is the frozen safety net and is intentionally exempt.
20+
const EXEMPT = [path.join(SRC, 'pages', 'LegacyFormView.jsx')];
21+
22+
/** Native blocking-dialog calls: bare alert()/confirm() or window.alert/confirm(). */
23+
const NATIVE_DIALOG = /(^|[^.\w])(alert|confirm)\s*\(|window\.(alert|confirm)\s*\(/;
24+
25+
/**
26+
* Recursively collect .js/.jsx source files (excluding tests) under a directory.
27+
* @param {string} dir Directory to walk.
28+
* @returns {string[]} Absolute file paths.
29+
*/
30+
function collectSourceFiles(dir) {
31+
if (!fs.existsSync(dir)) return [];
32+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
33+
const full = path.join(dir, entry.name);
34+
if (entry.isDirectory()) {
35+
if (entry.name === '__tests__') return [];
36+
return collectSourceFiles(full);
37+
}
38+
if (!/\.(js|jsx)$/.test(entry.name)) return [];
39+
if (/\.(test|spec)\.(js|jsx)$/.test(entry.name)) return [];
40+
return [full];
41+
});
42+
}
43+
44+
/**
45+
* Find native-dialog call sites in a file, ignoring comment lines/segments so that
46+
* doc comments mentioning "alert()" or "window.confirm()" don't trip the scan.
47+
* @param {string} file Absolute file path.
48+
* @returns {string[]} Offending "relpath:line" entries.
49+
*/
50+
function findNativeDialogs(file) {
51+
const offenders = [];
52+
const lines = fs.readFileSync(file, 'utf-8').split('\n');
53+
lines.forEach((line, i) => {
54+
const trimmed = line.trim();
55+
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return;
56+
const code = line.split('//')[0]; // drop trailing line comments
57+
if (NATIVE_DIALOG.test(code)) {
58+
offenders.push(`${path.relative(SRC, file)}:${i + 1}`);
59+
}
60+
});
61+
return offenders;
62+
}
63+
64+
describe('no native dialogs in the new UI', () => {
65+
it('uses Modal/AlertModal/ConfirmDialog instead of alert()/window.confirm()', () => {
66+
const offenders = SCOPED_DIRS.flatMap(collectSourceFiles)
67+
.filter((file) => !EXEMPT.includes(file))
68+
.flatMap(findNativeDialogs);
69+
70+
expect(offenders, `Native dialog calls found:\n${offenders.join('\n')}`).toEqual([]);
71+
});
72+
});

‎src/components/AlertModal.jsx‎

Lines changed: 28 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import React, { useEffect, useRef } from 'react';
1+
import React, { useId } from 'react';
22
import PropTypes from 'prop-types';
3+
import Modal from './Modal/Modal';
34
import './AlertModal.scss';
45

56
/**
@@ -24,54 +25,10 @@ import './AlertModal.scss';
2425
* @returns {JSX.Element|null} Modal component or null if closed
2526
*/
2627
const AlertModal = ({ isOpen, message, title = 'Alert', onClose, type = 'info' }) => {
27-
const closeButtonRef = useRef(null);
28-
29-
// Handle ESC key
30-
useEffect(() => {
31-
if (!isOpen) return;
32-
33-
const handleEscape = (e) => {
34-
if (e.key === 'Escape') {
35-
onClose();
36-
}
37-
};
38-
39-
document.addEventListener('keydown', handleEscape);
40-
return () => document.removeEventListener('keydown', handleEscape);
41-
}, [isOpen, onClose]);
42-
43-
// Focus close button when modal opens
44-
useEffect(() => {
45-
if (isOpen && closeButtonRef.current) {
46-
closeButtonRef.current.focus();
47-
}
48-
}, [isOpen]);
49-
50-
// Lock body scroll when modal is open
51-
useEffect(() => {
52-
if (isOpen) {
53-
document.body.style.overflow = 'hidden';
54-
} else {
55-
document.body.style.overflow = '';
56-
}
57-
58-
return () => {
59-
document.body.style.overflow = '';
60-
};
61-
}, [isOpen]);
62-
63-
// Handle click on overlay (outside modal)
64-
const handleOverlayClick = (e) => {
65-
if (e.target.classList.contains('alert-modal-overlay')) {
66-
onClose();
67-
}
68-
};
69-
70-
// Don't render if not open
71-
if (!isOpen) return null;
72-
73-
const titleId = 'alert-modal-title';
74-
const messageId = 'alert-modal-message';
28+
// Unique per instance so multiple alerts in the tree can't collide on ids.
29+
const baseId = useId();
30+
const titleId = `${baseId}-alert-title`;
31+
const messageId = `${baseId}-alert-message`;
7532

7633
// Icon mapping for accessibility (not relying on color alone)
7734
const iconMap = {
@@ -81,39 +38,33 @@ const AlertModal = ({ isOpen, message, title = 'Alert', onClose, type = 'info' }
8138
error: '❌',
8239
};
8340

41+
// Built on the shared Modal primitive, which provides the focus trap, focus
42+
// return, ESC/overlay close, and scroll lock. AlertModal keeps its own visual
43+
// classes (alert-modal-content + type variant, message, close button).
8444
return (
85-
<div
86-
className="alert-modal-overlay"
87-
onClick={handleOverlayClick}
88-
role="presentation"
89-
>
90-
<div
91-
className={`alert-modal-content alert-modal-${type}`}
92-
role="alertdialog"
93-
aria-modal="true"
94-
aria-labelledby={titleId}
95-
aria-describedby={messageId}
96-
onClick={(e) => e.stopPropagation()} // Prevent overlay click when clicking content
97-
>
98-
<h2 id={titleId} className="alert-modal-title">
45+
<Modal
46+
isOpen={isOpen}
47+
onClose={onClose}
48+
role="alertdialog"
49+
titleId={titleId}
50+
describedById={messageId}
51+
className={`alert-modal-content alert-modal-${type}`}
52+
title={
53+
<>
9954
<span className="alert-modal-icon" aria-hidden="true">
10055
{iconMap[type]}
10156
</span>
10257
{title}
103-
</h2>
104-
<p id={messageId} className="alert-modal-message">
105-
{message}
106-
</p>
107-
<button
108-
ref={closeButtonRef}
109-
onClick={onClose}
110-
className="alert-modal-close"
111-
aria-label="Close alert"
112-
>
113-
Close
114-
</button>
115-
</div>
116-
</div>
58+
</>
59+
}
60+
>
61+
<p id={messageId} className="alert-modal-message">
62+
{message}
63+
</p>
64+
<button type="button" onClick={onClose} className="alert-modal-close" aria-label="Close alert">
65+
Close
66+
</button>
67+
</Modal>
11768
);
11869
};
11970

‎src/components/CalendarDayCreator/CalendarDayCreator.css‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,3 +313,13 @@
313313
gap: 0.5rem;
314314
}
315315
}
316+
317+
/* Inline error shown when day creation fails (replaces a blocking dialog). */
318+
.calendar-create-error {
319+
margin: 0.75rem 0;
320+
padding: 0.75rem 1rem;
321+
background-color: #ffebee;
322+
color: #b71c1c;
323+
border-left: 4px solid #d32f2f;
324+
border-radius: 4px;
325+
}

‎src/components/CalendarDayCreator/CalendarDayCreator.jsx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ export function CalendarDayCreator({ animalId, existingDays = [], onCreateDays,
8888

8989
// Selected dates (Set for O(1) lookup)
9090
const [selectedDates, setSelectedDates] = useState(new Set());
91+
// Inline error shown if day creation fails (replaces a blocking alert()).
92+
const [createError, setCreateError] = useState(null);
9193

9294
/**
9395
* Navigate to previous month
@@ -173,6 +175,7 @@ export function CalendarDayCreator({ animalId, existingDays = [], onCreateDays,
173175
const dates = Array.from(selectedDates).sort();
174176

175177
try {
178+
setCreateError(null);
176179
// Call parent callback with all dates
177180
await onCreateDays(dates);
178181

@@ -181,7 +184,7 @@ export function CalendarDayCreator({ animalId, existingDays = [], onCreateDays,
181184
if (onClose) onClose();
182185
} catch (error) {
183186
console.error('Failed to create days:', error);
184-
alert(`Failed to create days: ${error.message}`);
187+
setCreateError(`Failed to create days: ${error.message}`);
185188
}
186189
}, [selectedDates, onCreateDays, onClose]);
187190

@@ -203,6 +206,12 @@ export function CalendarDayCreator({ animalId, existingDays = [], onCreateDays,
203206

204207
<CalendarLegend />
205208

209+
{createError && (
210+
<div className="calendar-create-error" role="alert">
211+
{createError}
212+
</div>
213+
)}
214+
206215
<div className="calendar-actions">
207216
<button
208217
type="button"

‎src/components/CalendarDayCreator/__tests__/CalendarDayCreator.test.jsx‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,6 @@ describe('CalendarDayCreator Component', () => {
341341
it('handles creation errors', async () => {
342342
const user = userEvent.setup();
343343
const onCreateDays = vi.fn().mockRejectedValue(new Error('Creation failed'));
344-
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
345344

346345
render(
347346
<CalendarDayCreator
@@ -368,13 +367,11 @@ describe('CalendarDayCreator Component', () => {
368367
const createButton = screen.getByRole('button', { name: /create 1 recording day/i });
369368
await user.click(createButton);
370369

371-
// Verify error alert shown
370+
// The failure is shown inline (role=alert), not via a native alert().
372371
await waitFor(() => {
373-
expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to create days'));
372+
expect(screen.getByRole('alert')).toHaveTextContent(/Failed to create days/i);
374373
});
375374
}
376-
377-
alertSpy.mockRestore();
378375
});
379376
});
380377

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import React, { useId } from 'react';
2+
import PropTypes from 'prop-types';
3+
import Modal from './Modal';
4+
import './ConfirmDialog.scss';
5+
6+
/**
7+
* Accessible confirm/cancel dialog built on the shared Modal primitive. Replaces
8+
* blocking window.confirm() prompts. Cancel, ESC, and overlay click all invoke
9+
* onCancel; only the Confirm button invokes onConfirm.
10+
*
11+
* @param {object} props
12+
* @param {boolean} props.isOpen Whether the dialog is shown.
13+
* @param {string} props.title Dialog heading.
14+
* @param {React.ReactNode} props.message Body / question.
15+
* @param {string} [props.confirmLabel] Confirm button text.
16+
* @param {string} [props.cancelLabel] Cancel button text.
17+
* @param {boolean} [props.destructive] Style the confirm action as destructive.
18+
* @param {Function} props.onConfirm Called when the user confirms.
19+
* @param {Function} props.onCancel Called for cancel / ESC / overlay close.
20+
* @returns {JSX.Element|null}
21+
*/
22+
const ConfirmDialog = ({
23+
isOpen,
24+
title,
25+
message,
26+
confirmLabel = 'Confirm',
27+
cancelLabel = 'Cancel',
28+
destructive = false,
29+
onConfirm,
30+
onCancel,
31+
}) => {
32+
// Unique per instance so multiple dialogs in the tree can't collide on ids.
33+
const baseId = useId();
34+
const titleId = `${baseId}-title`;
35+
const messageId = `${baseId}-message`;
36+
37+
return (
38+
<Modal
39+
isOpen={isOpen}
40+
onClose={onCancel}
41+
title={title}
42+
titleId={titleId}
43+
describedById={messageId}
44+
className="confirm-dialog"
45+
>
46+
<p id={messageId} className="confirm-dialog-message">
47+
{message}
48+
</p>
49+
<div className="form-actions">
50+
<button type="button" className="btn-cancel" onClick={onCancel}>
51+
{cancelLabel}
52+
</button>
53+
<button
54+
type="button"
55+
className={destructive ? 'btn-danger' : 'btn-save'}
56+
onClick={onConfirm}
57+
>
58+
{confirmLabel}
59+
</button>
60+
</div>
61+
</Modal>
62+
);
63+
};
64+
65+
ConfirmDialog.propTypes = {
66+
isOpen: PropTypes.bool.isRequired,
67+
title: PropTypes.node.isRequired,
68+
message: PropTypes.node.isRequired,
69+
confirmLabel: PropTypes.string,
70+
cancelLabel: PropTypes.string,
71+
destructive: PropTypes.bool,
72+
onConfirm: PropTypes.func.isRequired,
73+
onCancel: PropTypes.func.isRequired,
74+
};
75+
76+
export default ConfirmDialog;

0 commit comments

Comments
 (0)