diff --git a/packages/client/src/components/AddTimeBlockModal.tsx b/packages/client/src/components/AddTimeBlockModal.tsx index 9fd7b06..512dd44 100644 --- a/packages/client/src/components/AddTimeBlockModal.tsx +++ b/packages/client/src/components/AddTimeBlockModal.tsx @@ -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; @@ -147,6 +148,7 @@ export function AddTimeBlockModal({ }: AddTimeBlockModalProps): JSX.Element { const { showToast } = useToast(); const { timeZone } = useTimezone(); + const containerRef = useFocusTrap(); const defaultDate = useMemo(() => getCurrentDateKeyInTimeZone(timeZone), [timeZone]); const [defaultYearPart, defaultMonthPart] = defaultDate.split('-'); const defaultYear = Number(defaultYearPart || 1970); @@ -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) @@ -372,9 +385,9 @@ export function AddTimeBlockModal({ } return ( -
-
-

{isPm ? 'Add Time Blocks' : 'Add Personal Time Block'}

+
+
+

{isPm ? 'Add Time Blocks' : 'Add Personal Time Block'}

void handleSubmit(event)}>
@@ -402,15 +415,21 @@ export function AddTimeBlockModal({ ))}
-
+
{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 ( diff --git a/packages/client/src/components/AvailabilitySolverModal.tsx b/packages/client/src/components/AvailabilitySolverModal.tsx index 228a27c..430b856 100644 --- a/packages/client/src/components/AvailabilitySolverModal.tsx +++ b/packages/client/src/components/AvailabilitySolverModal.tsx @@ -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; @@ -22,11 +23,23 @@ export function AvailabilitySolverModal({ onCreateBlock }: AvailabilitySolverModalProps): JSX.Element { const { timeZone } = useTimezone(); + const containerRef = useFocusTrap(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [data, setData] = useState(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; @@ -86,11 +99,11 @@ export function AvailabilitySolverModal({ } return ( -
-
+
+
- -

Find a Time for Everyone

+

diff --git a/packages/client/src/components/ConfirmDialog.tsx b/packages/client/src/components/ConfirmDialog.tsx index a7b8925..3de7601 100644 --- a/packages/client/src/components/ConfirmDialog.tsx +++ b/packages/client/src/components/ConfirmDialog.tsx @@ -1,3 +1,7 @@ +import { useEffect } from 'react'; + +import { useFocusTrap } from '../utils/useFocusTrap.js'; + interface ConfirmDialogProps { title: string; message: string; @@ -19,10 +23,23 @@ export function ConfirmDialog({ onConfirm, onCancel }: ConfirmDialogProps): JSX.Element { + const containerRef = useFocusTrap(); + + // 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 ( -

-
-

{title}

+
+
+

{title}

{message}

+
+ ); + } + return ( {children} -
- {toasts.map((toast) => ( -
- - {toast.tone === 'success' ? : null} - {toast.tone === 'error' ? : null} - {toast.tone === 'info' ? : null} - {toast.message} - - -
- ))} + {/* Error toasts: role="alert" implicitly sets aria-live="assertive" and aria-atomic="true", + providing immediate screen reader announcements for error messages */} +
+ {errorToasts.map(renderToast)} +
+ {/* Success / info toasts use aria-live="polite" to avoid interrupting the user */} +
+ {nonErrorToasts.map(renderToast)}
); diff --git a/packages/client/src/pages/PublicBookingPage.tsx b/packages/client/src/pages/PublicBookingPage.tsx index 0c899f4..36e4e01 100644 --- a/packages/client/src/pages/PublicBookingPage.tsx +++ b/packages/client/src/pages/PublicBookingPage.tsx @@ -397,34 +397,38 @@ export function PublicBookingPage(): JSX.Element { {slotsByDay.map((group) => (

{group.dayLabel}

-
    - {group.slots.map((slot) => ( -
  • - -
  • - ))} +
      + {group.slots.map((slot) => { + const engineerNames = slot.engineers.length > 0 + ? slot.engineers.map((engineer) => `${engineer.first_name} ${engineer.last_name}`).join(', ') + : 'Unassigned'; + const slotAriaLabel = `Time slot: ${formatSlotLabel(slot, timeZone)}, ${slot.remaining_slots} remaining, Engineers: ${engineerNames}`; + return ( +
    • + +
    • + ); + })}
))} @@ -439,43 +443,45 @@ export function PublicBookingPage(): JSX.Element { {fullSlotsByDay.map((group) => (

{group.dayLabel}

-
    - {group.slots.map((slot) => ( -
  • - -
  • - ))} +
      + {group.slots.map((slot) => { + const engineerNames = slot.engineers.length > 0 + ? slot.engineers.map((engineer) => `${engineer.first_name} ${engineer.last_name}`).join(', ') + : 'Unassigned'; + const slotAriaLabel = `Time slot: ${formatSlotLabel(slot, timeZone)}, Full — join waitlist. ${slot.waitlist_count} on waitlist. Engineers: ${engineerNames}`; + return ( +
    • + +
    • + ); + })}
))}
) : null} - {error ?

{error}

: null} + {error ?

{error}

: null}