Skip to content
Draft
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
27 changes: 23 additions & 4 deletions packages/client/src/components/AddTimeBlockModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { TimeZoneSelect } from './TimeZoneSelect.js';
import { useTimezone } from '../context/TimezoneContext.js';
import { useToast } from '../context/ToastContext.js';
import { getCurrentDateKeyInTimeZone, toIsoStringInTimeZone } from '../utils/timezone.js';
import { useFocusTrap } from '../utils/useFocusTrap.js';

interface AddTimeBlockModalProps {
project: ProjectDetail;
Expand Down Expand Up @@ -147,6 +148,7 @@ export function AddTimeBlockModal({
}: AddTimeBlockModalProps): JSX.Element {
const { showToast } = useToast();
const { timeZone } = useTimezone();
const containerRef = useFocusTrap<HTMLDivElement>();
const defaultDate = useMemo(() => getCurrentDateKeyInTimeZone(timeZone), [timeZone]);
const [defaultYearPart, defaultMonthPart] = defaultDate.split('-');
const defaultYear = Number(defaultYearPart || 1970);
Expand Down Expand Up @@ -217,6 +219,17 @@ export function AddTimeBlockModal({
})();
}, [isPm]);

// Close modal on Escape
useEffect(() => {
function handleEscape(event: KeyboardEvent): void {
if (event.key === 'Escape' && !pending) {
onClose();
}
}
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [onClose, pending]);

function toggleEngineer(engineerId: number): void {
setSelectedEngineerIds((prev) =>
prev.includes(engineerId)
Expand Down Expand Up @@ -372,9 +385,9 @@ export function AddTimeBlockModal({
}

return (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Add time blocks">
<div className="modal-card">
<h3>{isPm ? 'Add Time Blocks' : 'Add Personal Time Block'}</h3>
<div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="add-time-block-title">
<div className="modal-card" ref={containerRef}>
<h3 id="add-time-block-title">{isPm ? 'Add Time Blocks' : 'Add Personal Time Block'}</h3>

<form onSubmit={(event) => void handleSubmit(event)}>
<div className="calendar-panel">
Expand Down Expand Up @@ -402,15 +415,21 @@ export function AddTimeBlockModal({
))}
</div>

<div className="calendar-grid">
<div className="calendar-grid" role="group" aria-label="Date picker">
{calendarDayCells.map((cell) => {
const isSelected = selectedDates.includes(cell.dateKey);
const labelParts = [formatDateLabel(cell.dateKey)];
if (cell.isToday) labelParts.push('Today');
if (!cell.inCurrentMonth) labelParts.push('Outside current month');
const label = labelParts.join(', ');
return (
<button
key={cell.dateKey}
type="button"
className={`calendar-day${isSelected ? ' selected' : ''}${cell.inCurrentMonth ? '' : ' outside'}${cell.isToday ? ' today' : ''}`}
onClick={() => toggleDate(cell.dateKey)}
aria-label={label}
aria-pressed={isSelected}
>
{cell.dayOfMonth}
</button>
Expand Down
21 changes: 17 additions & 4 deletions packages/client/src/components/AvailabilitySolverModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {

import { apiFetch } from '../api/client.js';
import { useTimezone } from '../context/TimezoneContext.js';
import { useFocusTrap } from '../utils/useFocusTrap.js';

interface AvailabilitySolverModalProps {
project: ProjectDetail;
Expand All @@ -22,11 +23,23 @@ export function AvailabilitySolverModal({
onCreateBlock
}: AvailabilitySolverModalProps): JSX.Element {
const { timeZone } = useTimezone();
const containerRef = useFocusTrap<HTMLDivElement>();

const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<AvailabilitySolverResponse | null>(null);

// Close modal on Escape
useEffect(() => {
function handleEscape(event: KeyboardEvent): void {
if (event.key === 'Escape') {
onClose();
}
}
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [onClose]);

useEffect(() => {
let cancelled = false;

Expand Down Expand Up @@ -86,11 +99,11 @@ export function AvailabilitySolverModal({
}

return (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Availability Solver">
<div className="modal-card solver-modal">
<div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="availability-solver-title">
<div className="modal-card solver-modal" ref={containerRef}>
<div className="solver-header">
<CalendarSearch size={20} />
<h3>Find a Time for Everyone</h3>
<CalendarSearch size={20} aria-hidden="true" />
<h3 id="availability-solver-title">Find a Time for Everyone</h3>
</div>

<p className="hint solver-description">
Expand Down
23 changes: 20 additions & 3 deletions packages/client/src/components/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { useEffect } from 'react';

import { useFocusTrap } from '../utils/useFocusTrap.js';

interface ConfirmDialogProps {
title: string;
message: string;
Expand All @@ -19,10 +23,23 @@ export function ConfirmDialog({
onConfirm,
onCancel
}: ConfirmDialogProps): JSX.Element {
const containerRef = useFocusTrap<HTMLDivElement>();

// Close dialog on Escape
useEffect(() => {
function handleEscape(event: KeyboardEvent): void {
if (event.key === 'Escape' && !pending) {
onCancel();
}
}
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [onCancel, pending]);

return (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label={title}>
<div className="modal-card">
<h3>{title}</h3>
<div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="confirm-dialog-title">
<div className="modal-card" ref={containerRef}>
<h3 id="confirm-dialog-title">{title}</h3>
<p>{message}</p>
<div className="button-row">
<button type="button" className="secondary-button" onClick={onCancel} disabled={pending}>
Expand Down
21 changes: 17 additions & 4 deletions packages/client/src/components/CreateProjectModal.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useState, type FormEvent } from 'react';
import { useEffect, useState, type FormEvent } from 'react';
import { ChevronRight } from 'lucide-react';

import type { CreateProjectRequest, ProjectResponse } from '@opencalendar/shared';

import { apiFetch } from '../api/client.js';
import { useToast } from '../context/ToastContext.js';
import { useFocusTrap } from '../utils/useFocusTrap.js';

interface CreateProjectModalProps {
onClose: () => void;
Expand All @@ -13,6 +14,7 @@ interface CreateProjectModalProps {

export function CreateProjectModal({ onClose, onCreated }: CreateProjectModalProps): JSX.Element {
const { showToast } = useToast();
const containerRef = useFocusTrap<HTMLDivElement>();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [signupPassword, setSignupPassword] = useState('');
Expand All @@ -24,6 +26,17 @@ export function CreateProjectModal({ onClose, onCreated }: CreateProjectModalPro
const [error, setError] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);

// Close modal on Escape
useEffect(() => {
function handleEscape(event: KeyboardEvent): void {
if (event.key === 'Escape' && !pending) {
onClose();
}
}
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [onClose, pending]);

async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
setPending(true);
Expand Down Expand Up @@ -58,9 +71,9 @@ export function CreateProjectModal({ onClose, onCreated }: CreateProjectModalPro
}

return (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Create project">
<div className="modal-card">
<h3>Create Project</h3>
<div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="create-project-title">
<div className="modal-card" ref={containerRef}>
<h3 id="create-project-title">Create Project</h3>
<form onSubmit={(event) => void handleSubmit(event)}>
<label>
Name
Expand Down
54 changes: 40 additions & 14 deletions packages/client/src/context/ToastContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,23 +39,49 @@ export function ToastProvider({ children }: { children: ReactNode }): JSX.Elemen
[showToast]
);

const errorToasts = useMemo(() => toasts.filter((t) => t.tone === 'error'), [toasts]);
const nonErrorToasts = useMemo(() => toasts.filter((t) => t.tone !== 'error'), [toasts]);

function renderToast(toast: ToastItem): JSX.Element {
return (
<div key={toast.id} className={`toast toast-${toast.tone}`}>
<span className="toast-content">
{toast.tone === 'success' ? <CheckCircle size={16} aria-hidden="true" /> : null}
{toast.tone === 'error' ? <AlertCircle size={16} aria-hidden="true" /> : null}
{toast.tone === 'info' ? <Info size={16} aria-hidden="true" /> : null}
{toast.message}
</span>
<button
type="button"
className="toast-close"
aria-label="Dismiss notification"
onClick={() => dismissToast(toast.id)}
>
<X size={16} aria-hidden="true" />
</button>
</div>
);
}

return (
<ToastContext.Provider value={value}>
{children}
<div className="toast-stack" aria-live="polite" aria-atomic="true">
{toasts.map((toast) => (
<div key={toast.id} className={`toast toast-${toast.tone}`}>
<span className="toast-content">
{toast.tone === 'success' ? <CheckCircle size={16} /> : null}
{toast.tone === 'error' ? <AlertCircle size={16} /> : null}
{toast.tone === 'info' ? <Info size={16} /> : null}
{toast.message}
</span>
<button type="button" className="toast-close" onClick={() => dismissToast(toast.id)}>
<X size={16} />
</button>
</div>
))}
{/* Error toasts: role="alert" implicitly sets aria-live="assertive" and aria-atomic="true",
providing immediate screen reader announcements for error messages */}
<div
className="toast-stack toast-stack-errors"
role="alert"
aria-atomic="true"
>
{errorToasts.map(renderToast)}
</div>
{/* Success / info toasts use aria-live="polite" to avoid interrupting the user */}
<div
className="toast-stack toast-stack-info"
aria-live="polite"
aria-atomic="true"
>
{nonErrorToasts.map(renderToast)}
</div>
</ToastContext.Provider>
);
Expand Down
Loading