@codecademy/gamut

68.2.268.2.3-alpha.93a7da.0
dist/DatePicker/DatePickerCalendar/Calendar/CalendarBody.js
+dist/DatePicker/DatePickerCalendar/Calendar/CalendarBody.jsNew file
+186
Index: package/dist/DatePicker/DatePickerCalendar/Calendar/CalendarBody.js
===================================================================
--- package/dist/DatePicker/DatePickerCalendar/Calendar/CalendarBody.js
+++ package/dist/DatePicker/DatePickerCalendar/Calendar/CalendarBody.js
@@ -0,0 +1,186 @@
+import { useCallback, useLayoutEffect, useMemo, useRef } from 'react';
+import * as React from 'react';
+import { useIsoFirstWeekday, useResolvedLocale } from '../../utils/locale';
+import { getDatesWithRow, getMonthGrid, isDateDisabled, isDateInRange, isSameDay, normalizeDate } from './utils/dateGrid';
+import { CalendarTable, DateCell, TableHeader } from './utils/elements';
+import { formatDateForAriaLabel, getWeekdayNames } from './utils/format';
+import { keyHandler } from './utils/keyHandler';
+import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
+export const CalendarBody = ({
+  displayDate,
+  selectedDate,
+  endDate = null,
+  shouldDisableDate,
+  onDateSelect,
+  locale,
+  weekStartsOn,
+  labelledById,
+  focusedDate,
+  onFocusedDateChange,
+  onDisplayDateChange,
+  onEscapeKeyPress,
+  hasAdjacentMonthRight,
+  hasAdjacentMonthLeft,
+  focusGridSync,
+  calendarKeyboardSurfaceRef
+}) => {
+  const resolvedLocale = useResolvedLocale(locale);
+  const firstWeekday = useIsoFirstWeekday(resolvedLocale, weekStartsOn);
+  const year = displayDate.getFullYear();
+  const month = displayDate.getMonth();
+  const weeks = getMonthGrid({
+    year,
+    month,
+    firstWeekday
+  });
+  const weekdayLabels = getWeekdayNames({
+    format: 'short',
+    locale: resolvedLocale,
+    firstWeekday
+  });
+  const weekdayFullNames = getWeekdayNames({
+    format: 'long',
+    locale: resolvedLocale,
+    firstWeekday
+  });
+  const buttonRefs = useRef(new Map());
+  const tableRef = useRef(null);
+  const datesWithRow = useMemo(() => getDatesWithRow(weeks), [weeks]);
+  const focusTarget = focusedDate ?? selectedDate;
+  const isToday = useCallback(date => date !== null && isSameDay(date, new Date()), []);
+  const focusButton = useCallback(date => {
+    if (date === null) return false;
+    const key = normalizeDate(date);
+    const el = buttonRefs.current.get(key);
+    if (!el) return false;
+    el.focus();
+    return true;
+  }, []);
+  useLayoutEffect(() => {
+    // Keep the roving tabindex / focused day aligned with `focusTarget` when it makes sense for a11y.
+    if (focusTarget === null) return;
+
+    // Standalone calendar (e.g. Storybook): always move DOM focus to the active day.
+    if (!focusGridSync) {
+      focusButton(focusTarget);
+      return;
+    }
+    const activeEl = document.activeElement;
+    const inThisGrid = tableRef.current?.contains(activeEl) ?? false;
+    const focusInSharedSurface = calendarKeyboardSurfaceRef?.current?.contains(activeEl) ?? false;
+    const requested = focusGridSync.gridFocusRequested;
+
+    // Month navigation unmounts the active cell; focus often lands on <body>, the dialog shell,
+    // or another non-grid node — not inside calendarKeyboardSurfaceRef, so we must still sync.
+    const surfaceEl = calendarKeyboardSurfaceRef?.current;
+    const focusLostFromCellUnmount = activeEl === document.body || activeEl === document.documentElement || activeEl instanceof HTMLElement && surfaceEl != null && surfaceEl.contains(activeEl) === false && activeEl.contains(surfaceEl);
+
+    // Sync DOM focus when: navigating inside this table; first focus from input (keyboard open);
+    // focus is in the multi-month strip (cross-grid arrows); or focus was lost after the grid updated.
+    // Do not pull focus from the input when the user opened with the mouse and never entered the surface.
+    const shouldSyncFocus = inThisGrid || requested || focusInSharedSurface || focusLostFromCellUnmount && surfaceEl != null;
+    if (!shouldSyncFocus) return;
+    const finish = success => {
+      if (success && requested) {
+        focusGridSync.onGridFocusRequestHandled();
+      }
+    };
+    let success = focusButton(focusTarget);
+    if (success) {
+      finish(true);
+      return;
+    }
+
+    // New cells may not have refs until after this layout pass (e.g. display month just changed).
+    if (shouldSyncFocus) {
+      requestAnimationFrame(() => {
+        success = focusButton(focusTarget);
+        if (success) finish(true);
+      });
+    }
+  }, [focusTarget, focusButton, focusGridSync, calendarKeyboardSurfaceRef, /** Re-run when the month grid remounts so we can re-attach roving focus after displayDate changes. */
+  year, month]);
+  const onKeyDown = useCallback((e, date) => keyHandler({
+    e,
+    date,
+    onFocusedDateChange,
+    datesWithRow,
+    month,
+    year,
+    shouldDisableDate,
+    onDateSelect,
+    onEscapeKeyPress,
+    onDisplayDateChange,
+    hasAdjacentMonthRight,
+    hasAdjacentMonthLeft
+  }), [onFocusedDateChange, datesWithRow, month, year, shouldDisableDate, onDateSelect, onEscapeKeyPress, onDisplayDateChange, hasAdjacentMonthLeft, hasAdjacentMonthRight]);
+  const setButtonRef = useCallback((date, el) => {
+    const normalizedDateTime = normalizeDate(date);
+    if (el) buttonRefs.current.set(normalizedDateTime, el);else buttonRefs.current.delete(normalizedDateTime);
+  }, []);
+  return /*#__PURE__*/_jsxs(CalendarTable, {
+    "aria-labelledby": labelledById,
+    ref: tableRef,
+    role: "grid",
+    children: [/*#__PURE__*/_jsx("thead", {
+      children: /*#__PURE__*/_jsx("tr", {
+        children: weekdayLabels.map((label, i) => /*#__PURE__*/_jsx(TableHeader, {
+          abbr: weekdayFullNames[i],
+          scope: "col",
+          children: label
+        }, label))
+      })
+    }), /*#__PURE__*/_jsx("tbody", {
+      children: weeks.map((week, rowIndex) => /*#__PURE__*/_jsx("tr", {
+        children: week.map((date, colIndex) => {
+          if (date === null) {
+            return (
+              /*#__PURE__*/
+              // eslint-disable-next-line jsx-a11y/control-has-associated-label -- this is a false positive
+              _jsx("td", {
+                role: "gridcell"
+              }, `empty-${rowIndex}-${colIndex}`)
+            );
+          }
+          const selected = isSameDay(date, selectedDate) || isSameDay(date, endDate);
+          const range = !!selectedDate && !!endDate;
+          const inRange = range && isDateInRange({
+            date,
+            startDate: selectedDate,
+            endDate
+          });
+          const disabled = isDateDisabled({
+            date,
+            shouldDisableDate
+          });
+          const today = isToday(date);
+          const isFocused = focusTarget !== null && isSameDay(date, focusTarget);
+          return /*#__PURE__*/_jsx(DateCell, {
+            "aria-current": today ? 'date' : undefined,
+            "aria-disabled": disabled,
+            "aria-label": formatDateForAriaLabel({
+              date,
+              locale: resolvedLocale
+            }),
+            "aria-selected": selected || inRange,
+            isDisabled: disabled,
+            isInRange: inRange,
+            isRangeEnd: range && isSameDay(date, endDate),
+            isRangeStart: range && isSameDay(date, selectedDate),
+            isSelected: selected,
+            isToday: today,
+            ref: el => setButtonRef(date, el),
+            role: "gridcell",
+            tabIndex: isFocused ? 0 : -1,
+            onClick: () => {
+              if (!disabled) onDateSelect(date);
+            },
+            onFocus: () => onFocusedDateChange?.(date),
+            onKeyDown: e => onKeyDown(e, date),
+            children: date.getDate()
+          }, date.getTime());
+        })
+      }, week.join('-')))
+    })]
+  });
+};
\ No newline at end of file