diff --git a/src/time-slot-bar-week/time-slot-bar-week-days.style.tsx b/src/time-slot-bar-week/time-slot-bar-week-days.style.tsx index 4296431f09..892e660cf8 100644 --- a/src/time-slot-bar-week/time-slot-bar-week-days.style.tsx +++ b/src/time-slot-bar-week/time-slot-bar-week-days.style.tsx @@ -130,6 +130,7 @@ export const TimeSlotComponent = styled(TimeSlot)` ${(props) => { if (props.$type === "vertical") { return css` + position: relative; max-width: 200px; height: ${props.$height}px; min-height: ${props.$height}px; @@ -139,6 +140,11 @@ export const TimeSlotComponent = styled(TimeSlot)` } }} + &:focus-within { + outline: 2px solid ${Colour["focus-ring"]}; + outline-offset: -2px; + } + ${(props) => { if (!props.$halfFill) { return css` diff --git a/src/time-slot-bar-week/time-slot-bar-week-days.tsx b/src/time-slot-bar-week/time-slot-bar-week-days.tsx index dec6e356d7..9d6004e642 100644 --- a/src/time-slot-bar-week/time-slot-bar-week-days.tsx +++ b/src/time-slot-bar-week/time-slot-bar-week-days.tsx @@ -3,14 +3,17 @@ import dayjs, { Dayjs } from "dayjs"; import isEmpty from "lodash/isEmpty"; import maxBy from "lodash/maxBy"; import minBy from "lodash/minBy"; -import React, { useMemo, useState } from "react"; +import React, { useMemo, useRef, useState } from "react"; import { useResizeDetector } from "react-resize-detector"; +import { VisuallyHidden, inertValue } from "../shared/accessibility"; import { InternalCalendarProps } from "../shared/internal-calendar"; import { CellStyleProps, DayCell } from "../shared/internal-calendar/day-cell"; import { Colour } from "../theme"; import { TimeSlot } from "../time-slot-bar/types"; import { DateHelper } from "../util"; import { CalendarHelper } from "../util/calendar-helper"; +import { StringHelper } from "../util/string-helper"; +import { TimeHelper } from "../util/time-helper"; import { CellWeekText, ChevronIcon, @@ -51,6 +54,14 @@ interface TimeSlotWeekDaysProps interface TimeSlotCell extends TimeSlot { cellLength: number; halfFill?: "top" | "bottom" | undefined; + isActualSlot?: boolean | undefined; + rowIndex?: number | undefined; +} + +interface FocusableSlotMeta { + key: string; + date: string; + rowIndex: number; } export const TimeSlotBarWeekDays = ({ @@ -75,6 +86,7 @@ export const TimeSlotBarWeekDays = ({ const dateFormat = "YYYY-MM-DD"; const [expandAll, setExpandAll] = useState(false); const [hoverDay, setHoverDay] = useState(); + const slotButtonRefs = useRef>({}); const currentCalendarWeek = useMemo((): Dayjs[] => { return CalendarHelper.generateDaysForCurrentWeek(calendarDate); }, [calendarDate]); @@ -90,10 +102,20 @@ export const TimeSlotBarWeekDays = ({ // React spring animation configuration const { height: actualHeight = 0, ref: cellsRef } = useResizeDetector(); + const hasCollapsedContent = + !!maxVisibleCellHeight && actualHeight > maxVisibleCellHeight; + const visibleRowCount = + maxVisibleCellHeight !== undefined + ? Math.max(1, Math.floor((maxVisibleCellHeight + 4) / 16)) + : 0; + const collapsedHeight = + 112 + visibleRowCount > 0 + ? visibleRowCount * 16 - 4 + : maxVisibleCellHeight; const height = maxVisibleCellHeight - ? actualHeight < maxVisibleCellHeight || expandAll + ? !hasCollapsedContent || expandAll ? actualHeight - : maxVisibleCellHeight + : collapsedHeight : actualHeight; const expandableStyles = useSpring({ height }); @@ -111,6 +133,33 @@ export const TimeSlotBarWeekDays = ({ return {}; }, [daySlots]); + const focusableSlotsByDate = useMemo(() => { + return currentCalendarWeek.reduce>( + (result, day) => { + const formattedDate = day.format(dateFormat); + + result[formattedDate] = getCellsForDate(formattedDate) + .filter( + ( + slot + ): slot is TimeSlotCell & { + isActualSlot: true; + rowIndex: number; + } => !!slot.isActualSlot && slot.rowIndex !== undefined + ) + .map((slot) => ({ + key: `${formattedDate}-${slot.id}`, + date: formattedDate, + rowIndex: slot.rowIndex, + })) + .sort((a, b) => a.rowIndex - b.rowIndex); + + return result; + }, + {} + ); + }, [currentCalendarWeek, getCellsForDate]); + // ============================================================================= // EVENT HANDLERS // ============================================================================= @@ -132,11 +181,80 @@ export const TimeSlotBarWeekDays = ({ onSlotClick?.(date, slot); }; + const handleSlotButtonClick = + (date: string, slot: TimeSlot) => + (event: React.MouseEvent) => { + event.stopPropagation(); + + handleSlotClick(date, slot); + }; + const handleExpandCollapseClick = (event: React.MouseEvent) => { event.preventDefault(); setExpandAll((prevExpandValue) => !prevExpandValue); }; + const handleSlotKeyDown = ( + event: React.KeyboardEvent, + currentSlot: FocusableSlotMeta + ) => { + const sameColumnSlots = focusableSlotsByDate[currentSlot.date] ?? []; + + const focusSlot = (slot?: FocusableSlotMeta) => { + if (!slot) return; + slotButtonRefs.current[slot.key]?.focus(); + }; + + switch (event.key) { + case "ArrowRight": + case "ArrowDown": { + event.preventDefault(); + const currentIndex = sameColumnSlots.findIndex( + (slot) => slot.key === currentSlot.key + ); + focusSlot(sameColumnSlots[currentIndex + 1]); + break; + } + case "ArrowLeft": + case "ArrowUp": { + event.preventDefault(); + const currentIndex = sameColumnSlots.findIndex( + (slot) => slot.key === currentSlot.key + ); + focusSlot(sameColumnSlots[currentIndex - 1]); + break; + } + case "Home": + event.preventDefault(); + focusSlot(sameColumnSlots[0]); + break; + case "End": + event.preventDefault(); + focusSlot(sameColumnSlots[sameColumnSlots.length - 1]); + break; + case "PageUp": + event.preventDefault(); + focusSlot(sameColumnSlots[0]); + break; + case "PageDown": { + event.preventDefault(); + if (hasCollapsedContent && !expandAll) { + setExpandAll(true); + const lastVisibleSlot = [...sameColumnSlots] + .reverse() + .find((slot) => slot.rowIndex < visibleRowCount); + focusSlot(lastVisibleSlot ?? sameColumnSlots[0]); + break; + } + + focusSlot(sameColumnSlots[sameColumnSlots.length - 1]); + break; + } + default: + break; + } + }; + // ============================================================================= // HELPER FUNCTIONS // ============================================================================= @@ -198,6 +316,7 @@ export const TimeSlotBarWeekDays = ({ startTime: "", endTime: "", clickable: false, + isActualSlot: false, styleAttributes: { backgroundColor: Colour["bg-stronger"], }, @@ -205,6 +324,19 @@ export const TimeSlotBarWeekDays = ({ }; } + function getSlotAriaLabel(date: string, slot: TimeSlot) { + const { startTime: slotStartTime, endTime: slotEndTime } = slot; + + return StringHelper.joinNonEmptyStrings([ + dayjs(date).format("D MMMM YYYY dddd"), + slotStartTime && slotEndTime + ? TimeHelper.formatTimeRange(slotStartTime, slotEndTime) + : undefined, + slot.label, + slot.clickable ?? true ? "Available" : "Unavailable", + ]); + } + function initializeAndFillSlots(slots: TimeSlot[]): TimeSlotCell[] { const cellsArray = Array(numberOfCells).fill({}); @@ -226,6 +358,8 @@ export const TimeSlotBarWeekDays = ({ // Keep fixed slots as 1 long cell cellsArray[Math.floor(startIndex)] = { ...slot, + isActualSlot: true, + rowIndex: Math.floor(startIndex), cellLength: endIndex - startIndex, }; break; @@ -257,6 +391,8 @@ export const TimeSlotBarWeekDays = ({ id: `${slot.id}-${i}`, startTime, endTime, + isActualSlot: true, + rowIndex: Math.floor(startIndex + i), cellLength: 1, halfFill, }; @@ -327,6 +463,20 @@ export const TimeSlotBarWeekDays = ({ ); } + function getCellsForDate(formattedDate: string) { + return ( + generatedDaySlots[formattedDate] ?? + Array(variant === "flexible" ? numberOfCells : 1) + .fill(undefined) + .map((_, index) => + generateFallbackCell( + index, + variant === "fixed" ? numberOfCells : undefined + ) + ) + ); + } + // ============================================================================= // RENDER FUNCTIONS // ============================================================================= @@ -349,7 +499,7 @@ export const TimeSlotBarWeekDays = ({ const renderHeader = () => { return ( - + {currentCalendarWeek.map((day, dayIndex) => { const dayCellStyleProps = generateStyleProps(day); @@ -358,6 +508,7 @@ export const TimeSlotBarWeekDays = ({ key={`day-${dayIndex}`} date={day} calendarDate={dayjs(selectedDate)} + role="columnheader" onSelect={() => { handleDayClick( day, @@ -414,69 +565,92 @@ export const TimeSlotBarWeekDays = ({ ); }; + const renderSlotCell = (formattedDate: string, slot: TimeSlotCell) => { + const { + id, + clickable = true, + isActualSlot, + styleAttributes, + cellLength, + halfFill, + } = slot; + const { + styleType = "default", + backgroundColor, + backgroundColor2, + } = styleAttributes; + const slotId = `${formattedDate}-${id}`; + const isCollapsedSlot = + hasCollapsedContent && + !expandAll && + !!isActualSlot && + (slot.rowIndex ?? 0) >= visibleRowCount; + + return ( + + clickable && handleSlotClick(formattedDate, slot) + } + > + {isActualSlot && ( + +