@super-calendar/native 2.3.2 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,7 @@ import {
17
17
  } from "date-fns";
18
18
  import { memo, type ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
19
19
  import {
20
+ type AccessibilityActionEvent,
20
21
  type GestureResponderEvent,
21
22
  Platform,
22
23
  Pressable,
@@ -37,6 +38,7 @@ import Animated, {
37
38
  useAnimatedScrollHandler,
38
39
  useAnimatedStyle,
39
40
  useDerivedValue,
41
+ useReducedMotion,
40
42
  useSharedValue,
41
43
  } from "react-native-reanimated";
42
44
  import { useCalendarTheme } from "../theme";
@@ -65,6 +67,7 @@ import {
65
67
  import { formatHour, layoutDayEvents, type PositionedEvent } from "@super-calendar/core";
66
68
  import type { EventAccessibilityLabeler } from "@super-calendar/core";
67
69
  import { type WeekdayFormat, weekdayFormatToken } from "@super-calendar/core";
70
+ import { SlotStylesProvider, type SlotStyleProps, useSlots } from "../utils/slots";
68
71
  import { useWebGridZoom } from "../utils/useWebGridZoom";
69
72
  import { useWebPagerKeys } from "../utils/useWebPagerKeys";
70
73
  import { withEventAccessibilityLabel } from "../utils/withEventAccessibilityLabel";
@@ -197,6 +200,7 @@ function AnimatedEventBox<T>({
197
200
  }: AnimatedEventBoxProps<T>) {
198
201
  const RenderEventComponent = renderEvent;
199
202
  const theme = useCalendarTheme();
203
+ const slot = useSlots<TimeGridSlot>();
200
204
  // Drag-to-move/resize. Native picks the event up on long-press (so a tap or
201
205
  // scroll isn't hijacked); web activates after a small drag threshold, so a
202
206
  // plain click still selects and a right-click still opens a context menu.
@@ -372,16 +376,55 @@ function AnimatedEventBox<T>({
372
376
  const handleLongPress =
373
377
  !draggable && onLongPress ? () => onLongPress(positioned.event) : undefined;
374
378
 
379
+ // Dragging is gesture-only, so expose the same move/resize commit path as
380
+ // discrete screen-reader actions (VoiceOver/TalkBack invoke them from the
381
+ // actions menu). Steps are one `snapMinutes` unit, matching a drag snap.
382
+ const unit = (n: number) => `${n} minute${n === 1 ? "" : "s"}`;
383
+ const accessibilityActions = draggable
384
+ ? [
385
+ { name: "move-later", label: `Move ${unit(snapMinutes)} later` },
386
+ { name: "move-earlier", label: `Move ${unit(snapMinutes)} earlier` },
387
+ ...(resizable
388
+ ? [
389
+ { name: "extend", label: `Extend by ${unit(snapMinutes)}` },
390
+ { name: "shrink", label: `Shorten by ${unit(snapMinutes)}` },
391
+ ]
392
+ : []),
393
+ ]
394
+ : undefined;
395
+ const handleAccessibilityAction = draggable
396
+ ? (e: AccessibilityActionEvent) => {
397
+ switch (e.nativeEvent.actionName) {
398
+ case "move-later":
399
+ commitDrag(snapMinutes, snapMinutes);
400
+ break;
401
+ case "move-earlier":
402
+ commitDrag(-snapMinutes, -snapMinutes);
403
+ break;
404
+ case "extend":
405
+ commitDrag(0, snapMinutes);
406
+ break;
407
+ case "shrink":
408
+ commitDrag(0, -snapMinutes);
409
+ break;
410
+ }
411
+ }
412
+ : undefined;
413
+
414
+ const eventSlot = slot("event", {
415
+ base: [styles.eventBox, { left, width }],
416
+ themed: theme.containers.timeGridEvent,
417
+ });
375
418
  const box = (
376
- <Animated.View
377
- style={[styles.eventBox, { left, width }, boxStyle, theme.containers.timeGridEvent]}
378
- >
419
+ <Animated.View {...eventSlot} style={[eventSlot.style, boxStyle]}>
379
420
  <RenderEventComponent
380
421
  event={positioned.event}
381
422
  mode={mode}
382
423
  boxHeight={boxHeight}
383
424
  continuesBefore={positioned.continuesBefore}
384
425
  continuesAfter={positioned.continuesAfter}
426
+ accessibilityActions={accessibilityActions}
427
+ onAccessibilityAction={handleAccessibilityAction}
385
428
  onPress={handlePress}
386
429
  onLongPress={handleLongPress}
387
430
  />
@@ -426,6 +469,7 @@ const HourRow = ({
426
469
  hourComponent,
427
470
  }: HourRowProps) => {
428
471
  const theme = useCalendarTheme();
472
+ const slot = useSlots<TimeGridSlot>();
429
473
  // Position via `top` (a layout prop), not a transform. The per-row layout pass
430
474
  // as cellHeight animates keeps the ScrollView's content size in sync while
431
475
  // zooming; a transform is composited and leaves the scroll range stale.
@@ -440,17 +484,21 @@ const HourRow = ({
440
484
  <View style={{ width: hourColumnWidth }}>{hourComponent(hour, ampm)}</View>
441
485
  ) : (
442
486
  <Text
443
- style={[
444
- theme.text.hourLabel,
445
- styles.hourLabel,
446
- { width: hourColumnWidth, color: theme.colors.textMuted },
447
- ]}
487
+ {...slot("hourLabel", {
488
+ base: [styles.hourLabel, { width: hourColumnWidth }],
489
+ themed: [theme.text.hourLabel, { color: theme.colors.textMuted }],
490
+ })}
448
491
  allowFontScaling={false}
449
492
  >
450
493
  {label}
451
494
  </Text>
452
495
  )}
453
- <View style={[styles.hourLine, { backgroundColor: theme.colors.gridLine }]} />
496
+ <View
497
+ {...slot("gridLines", {
498
+ base: styles.hourLine,
499
+ themed: { backgroundColor: theme.colors.gridLine },
500
+ })}
501
+ />
454
502
  </Animated.View>
455
503
  );
456
504
  };
@@ -472,20 +520,16 @@ const TimeslotLine = ({
472
520
  hourColumnWidth,
473
521
  }: TimeslotLineProps) => {
474
522
  const theme = useCalendarTheme();
523
+ const slot = useSlots<TimeGridSlot>();
475
524
  const animatedStyle = useAnimatedStyle(
476
525
  () => ({ top: (hour - minHour + fraction) * cellHeight.value }),
477
526
  [hour, minHour, fraction],
478
527
  );
479
- return (
480
- <Animated.View
481
- style={[
482
- styles.timeslotLine,
483
- styles.nonInteractive,
484
- { left: hourColumnWidth, backgroundColor: theme.colors.gridLine },
485
- animatedStyle,
486
- ]}
487
- />
488
- );
528
+ const lineSlot = slot("gridLines", {
529
+ base: [styles.timeslotLine, styles.nonInteractive, { left: hourColumnWidth }],
530
+ themed: { backgroundColor: theme.colors.gridLine },
531
+ });
532
+ return <Animated.View {...lineSlot} style={[lineSlot.style, animatedStyle]} />;
489
533
  };
490
534
 
491
535
  type NowIndicatorProps = {
@@ -499,22 +543,17 @@ type NowIndicatorProps = {
499
543
 
500
544
  const NowIndicator = ({ cellHeight, nowHours, minHour, left, width, color }: NowIndicatorProps) => {
501
545
  const theme = useCalendarTheme();
546
+ const slot = useSlots<TimeGridSlot>();
502
547
  const animatedStyle = useAnimatedStyle(
503
548
  () => ({ top: (nowHours - minHour) * cellHeight.value }),
504
549
  [nowHours, minHour],
505
550
  );
506
551
 
507
- return (
508
- <Animated.View
509
- style={[
510
- styles.nowIndicator,
511
- styles.nonInteractive,
512
- { left, width, backgroundColor: color },
513
- animatedStyle,
514
- theme.containers.nowIndicator,
515
- ]}
516
- />
517
- );
552
+ const lineSlot = slot("nowIndicator", {
553
+ base: [styles.nowIndicator, styles.nonInteractive, { left, width }],
554
+ themed: [{ backgroundColor: color }, theme.containers.nowIndicator],
555
+ });
556
+ return <Animated.View {...lineSlot} style={[lineSlot.style, animatedStyle]} />;
518
557
  };
519
558
 
520
559
  type ShadeBandProps = {
@@ -538,6 +577,7 @@ const ShadeBand = ({
538
577
  width,
539
578
  color,
540
579
  }: ShadeBandProps) => {
580
+ const slot = useSlots<TimeGridSlot>();
541
581
  const animatedStyle = useAnimatedStyle(
542
582
  () => ({
543
583
  top: (startHour - minHour) * cellHeight.value,
@@ -545,15 +585,15 @@ const ShadeBand = ({
545
585
  }),
546
586
  [startHour, endHour, minHour],
547
587
  );
588
+ const bandSlot = slot("businessHours", {
589
+ base: [styles.shadeBand, styles.nonInteractive, { left, width }],
590
+ themed: { backgroundColor: color },
591
+ });
548
592
  return (
549
593
  <Animated.View
550
594
  testID="business-hours-shade"
551
- style={[
552
- styles.shadeBand,
553
- styles.nonInteractive,
554
- { left, width, backgroundColor: color },
555
- animatedStyle,
556
- ]}
595
+ {...bandSlot}
596
+ style={[bandSlot.style, animatedStyle]}
557
597
  />
558
598
  );
559
599
  };
@@ -654,6 +694,7 @@ function TimetablePageInner<T>({
654
694
  onCreateEvent,
655
695
  }: TimetablePageProps<T>) {
656
696
  const theme = useCalendarTheme();
697
+ const slot = useSlots<TimeGridSlot>();
657
698
  const scrollRef = useAnimatedRef<Animated.ScrollView>();
658
699
 
659
700
  // The visible page tracks the live cellHeight (animates every pinch frame);
@@ -959,6 +1000,14 @@ function TimetablePageInner<T>({
959
1000
  opacity: createActive.value,
960
1001
  }));
961
1002
 
1003
+ const ghostSlot = slot("createGhost", {
1004
+ base: [styles.createGhost, { pointerEvents: "none" }],
1005
+ themed: {
1006
+ backgroundColor: theme.colors.eventBackground,
1007
+ borderColor: theme.colors.todayBackground,
1008
+ },
1009
+ });
1010
+
962
1011
  // The tap/long-press layer behind events; wrapped in the create gesture when
963
1012
  // drag-to-create is on. Hidden from screen readers (a convenience gesture).
964
1013
  const cellLayer =
@@ -1012,20 +1061,24 @@ function TimetablePageInner<T>({
1012
1061
  cellLayer
1013
1062
  )}
1014
1063
 
1015
- {days.map((day, dayIndex) =>
1016
- isWeekend(day) ? (
1064
+ {days.map((day, dayIndex) => {
1065
+ if (!isWeekend(day)) return null;
1066
+ const shadeSlot = slot("weekendShade", {
1067
+ base: [
1068
+ styles.weekendColumn,
1069
+ styles.nonInteractive,
1070
+ { left: dayLeft(dayIndex), width: dayWidth },
1071
+ ],
1072
+ themed: { backgroundColor: theme.colors.weekendBackground },
1073
+ });
1074
+ return (
1017
1075
  <Animated.View
1018
1076
  key={`weekend-${day.toISOString()}`}
1019
- style={[
1020
- styles.weekendColumn,
1021
- styles.nonInteractive,
1022
- { backgroundColor: theme.colors.weekendBackground },
1023
- { left: dayLeft(dayIndex), width: dayWidth },
1024
- fullHeightStyle,
1025
- ]}
1077
+ {...shadeSlot}
1078
+ style={[shadeSlot.style, fullHeightStyle]}
1026
1079
  />
1027
- ) : null,
1028
- )}
1080
+ );
1081
+ })}
1029
1082
 
1030
1083
  {calendarCellStyle
1031
1084
  ? days.map((day, dayIndex) => {
@@ -1062,18 +1115,19 @@ function TimetablePageInner<T>({
1062
1115
  )
1063
1116
  : null}
1064
1117
 
1065
- {days.map((day, dayIndex) => (
1066
- <Animated.View
1067
- key={`separator-${day.toISOString()}`}
1068
- style={[
1069
- styles.daySeparator,
1070
- styles.nonInteractive,
1071
- { backgroundColor: theme.colors.gridLine },
1072
- { left: dayLeft(dayIndex) },
1073
- fullHeightStyle,
1074
- ]}
1075
- />
1076
- ))}
1118
+ {days.map((day, dayIndex) => {
1119
+ const separatorSlot = slot("daySeparator", {
1120
+ base: [styles.daySeparator, styles.nonInteractive, { left: dayLeft(dayIndex) }],
1121
+ themed: { backgroundColor: theme.colors.gridLine },
1122
+ });
1123
+ return (
1124
+ <Animated.View
1125
+ key={`separator-${day.toISOString()}`}
1126
+ {...separatorSlot}
1127
+ style={[separatorSlot.style, fullHeightStyle]}
1128
+ />
1129
+ );
1130
+ })}
1077
1131
 
1078
1132
  {hoursRange.map((hour) => (
1079
1133
  <HourRow
@@ -1148,17 +1202,7 @@ function TimetablePageInner<T>({
1148
1202
  ) : null}
1149
1203
 
1150
1204
  {createEnabled ? (
1151
- <Animated.View
1152
- style={[
1153
- styles.createGhost,
1154
- { pointerEvents: "none" },
1155
- {
1156
- backgroundColor: theme.colors.eventBackground,
1157
- borderColor: theme.colors.todayBackground,
1158
- },
1159
- createGhostStyle,
1160
- ]}
1161
- />
1205
+ <Animated.View {...ghostSlot} style={[ghostSlot.style, createGhostStyle]} />
1162
1206
  ) : null}
1163
1207
  </Animated.View>
1164
1208
  </Animated.ScrollView>
@@ -1169,8 +1213,38 @@ function TimetablePageInner<T>({
1169
1213
 
1170
1214
  const TimetablePage = memo(TimetablePageInner) as typeof TimetablePageInner;
1171
1215
 
1216
+ /**
1217
+ * The styleable parts of {@link TimeGrid}. Mirrors the dom renderer's slot names
1218
+ * where the structure matches; `columnHeaderDateText`, `weekendShade` and
1219
+ * `daySeparator` are native-only (the dom grid styles those through its
1220
+ * `dayColumn`/`columnHeaderDate` elements). Slots rendered as Reanimated views
1221
+ * (`gridLines` sub-hour dividers, `businessHours`, `weekendShade`,
1222
+ * `daySeparator`, `event`, `nowIndicator`, `createGhost`) always honour the
1223
+ * `styles` map; their `className` reaches the element but needs a Tailwind
1224
+ * runtime that styles Animated components.
1225
+ */
1226
+ export type TimeGridSlot =
1227
+ | "header"
1228
+ | "weekNumber"
1229
+ | "columnHeader"
1230
+ | "columnHeaderWeekday"
1231
+ | "columnHeaderDate"
1232
+ | "columnHeaderDateText"
1233
+ | "allDayLane"
1234
+ | "allDayLabel"
1235
+ | "allDayColumn"
1236
+ | "allDayEvent"
1237
+ | "hourLabel"
1238
+ | "gridLines"
1239
+ | "businessHours"
1240
+ | "weekendShade"
1241
+ | "daySeparator"
1242
+ | "event"
1243
+ | "nowIndicator"
1244
+ | "createGhost";
1245
+
1172
1246
  /** Props for {@link TimeGrid}, the day/week timetable view. */
1173
- export type TimeGridProps<T> = {
1247
+ export type TimeGridProps<T> = SlotStyleProps<TimeGridSlot> & {
1174
1248
  mode: TimeGridMode;
1175
1249
  /** Day columns to show in `custom` mode. Ignored by day/3days/week. Default 1. */
1176
1250
  numberOfDays?: number;
@@ -1314,6 +1388,8 @@ function TimeGridInner<T>({
1314
1388
  onPressDateHeader,
1315
1389
  onChangeDate,
1316
1390
  renderHeader,
1391
+ classNames,
1392
+ styles: styleOverrides,
1317
1393
  }: TimeGridProps<T>): ReactElement {
1318
1394
  // Guard against an inverted/out-of-range window so the grid never collapses.
1319
1395
  const clampedMinHour = Math.max(0, Math.min(minHour, HOURS_PER_DAY - 1));
@@ -1568,6 +1644,10 @@ function TimeGridInner<T>({
1568
1644
  );
1569
1645
  useWebPagerKeys(swipeEnabled, goToPage);
1570
1646
 
1647
+ // Honour the OS "reduce motion" setting: the pager's one animated transition
1648
+ // (the snap-back below) becomes an instant jump when it's on.
1649
+ const reduceMotion = useReducedMotion();
1650
+
1571
1651
  // Optionally snap the pager back to the active page after an empty-cell press
1572
1652
  // (so tapping a far-swiped page returns to the committed date).
1573
1653
  const handlePressCell = useMemo(() => {
@@ -1575,9 +1655,9 @@ function TimeGridInner<T>({
1575
1655
  if (!resetPageOnPressCell) return onPressCell;
1576
1656
  return (cellDate: Date) => {
1577
1657
  onPressCell(cellDate);
1578
- void listRef.current?.scrollToIndex({ index: activeIndex, animated: true });
1658
+ void listRef.current?.scrollToIndex({ index: activeIndex, animated: !reduceMotion });
1579
1659
  };
1580
- }, [onPressCell, resetPageOnPressCell, activeIndex]);
1660
+ }, [onPressCell, resetPageOnPressCell, activeIndex, reduceMotion]);
1581
1661
 
1582
1662
  const snapToIndices = useMemo(() => pageDates.map((_, index) => index), [pageDates]);
1583
1663
  const keyExtractorList = useCallback((item: Date) => item.toISOString(), []);
@@ -1681,67 +1761,69 @@ function TimeGridInner<T>({
1681
1761
  const listExtraData = useMemo(() => ({ events, activeIndex }), [events, activeIndex]);
1682
1762
 
1683
1763
  return (
1684
- <View ref={containerRef} style={styles.container}>
1685
- {renderHeader ? (
1686
- renderHeader(headerDays)
1687
- ) : (
1688
- <DefaultHeader
1689
- days={headerDays}
1690
- mode={mode}
1691
- width={containerWidth}
1692
- hourColumnWidth={hourColumnWidth}
1693
- showWeekNumber={showWeekNumber}
1694
- weekNumberPrefix={weekNumberPrefix}
1695
- weekdayFormat={weekdayFormat}
1696
- locale={locale}
1697
- activeDate={activeDate}
1698
- onPressDateHeader={onPressDateHeader}
1699
- />
1700
- )}
1701
-
1702
- {headerComponent}
1703
-
1704
- <View
1705
- style={styles.pager}
1706
- onLayout={(event) => {
1707
- setPageHeight(event.nativeEvent.layout.height);
1708
- setContainerWidth(event.nativeEvent.layout.width);
1709
- setMeasured(true);
1710
- }}
1711
- >
1712
- <LegendList
1713
- // Remount only on the seed→measured transition (see `measured`), not on
1714
- // every height change, so a day↔week header-height difference resizes the
1715
- // items in place instead of remounting and blanking the page.
1716
- key={measured ? "grid" : "grid-seed"}
1717
- ref={listRef}
1718
- style={isWeb ? [styles.pagerList, styles.webNoScroll] : styles.pagerList}
1719
- data={pageDates}
1720
- extraData={listExtraData}
1721
- horizontal
1722
- recycleItems={false}
1723
- keyExtractor={keyExtractorList}
1724
- getFixedItemSize={getFixedItemSize}
1725
- // On web LegendList ignores these RN scroll props (it leaks them to the
1726
- // DOM as unknown attributes), so omit them there and disable horizontal
1727
- // scroll via `webNoScroll`; paging is driven by the arrow keys instead.
1728
- // Native: paging makes each swipe hard-stop at the adjacent page, while
1729
- // `freeSwipe` lets momentum carry across pages and snap to a boundary.
1730
- {...(isWeb
1731
- ? null
1732
- : {
1733
- scrollEnabled: swipeEnabled,
1734
- pagingEnabled: !freeSwipe,
1735
- snapToIndices: freeSwipe ? snapToIndices : undefined,
1736
- })}
1737
- initialScrollIndex={activeIndex}
1738
- showsHorizontalScrollIndicator={false}
1739
- viewabilityConfig={PAGE_VIEWABILITY}
1740
- onViewableItemsChanged={handleViewableItemsChanged}
1741
- renderItem={renderItem}
1742
- />
1764
+ <SlotStylesProvider classNames={classNames} styles={styleOverrides}>
1765
+ <View ref={containerRef} style={styles.container}>
1766
+ {renderHeader ? (
1767
+ renderHeader(headerDays)
1768
+ ) : (
1769
+ <DefaultHeader
1770
+ days={headerDays}
1771
+ mode={mode}
1772
+ width={containerWidth}
1773
+ hourColumnWidth={hourColumnWidth}
1774
+ showWeekNumber={showWeekNumber}
1775
+ weekNumberPrefix={weekNumberPrefix}
1776
+ weekdayFormat={weekdayFormat}
1777
+ locale={locale}
1778
+ activeDate={activeDate}
1779
+ onPressDateHeader={onPressDateHeader}
1780
+ />
1781
+ )}
1782
+
1783
+ {headerComponent}
1784
+
1785
+ <View
1786
+ style={styles.pager}
1787
+ onLayout={(event) => {
1788
+ setPageHeight(event.nativeEvent.layout.height);
1789
+ setContainerWidth(event.nativeEvent.layout.width);
1790
+ setMeasured(true);
1791
+ }}
1792
+ >
1793
+ <LegendList
1794
+ // Remount only on the seed→measured transition (see `measured`), not on
1795
+ // every height change, so a day↔week header-height difference resizes the
1796
+ // items in place instead of remounting and blanking the page.
1797
+ key={measured ? "grid" : "grid-seed"}
1798
+ ref={listRef}
1799
+ style={isWeb ? [styles.pagerList, styles.webNoScroll] : styles.pagerList}
1800
+ data={pageDates}
1801
+ extraData={listExtraData}
1802
+ horizontal
1803
+ recycleItems={false}
1804
+ keyExtractor={keyExtractorList}
1805
+ getFixedItemSize={getFixedItemSize}
1806
+ // On web LegendList ignores these RN scroll props (it leaks them to the
1807
+ // DOM as unknown attributes), so omit them there and disable horizontal
1808
+ // scroll via `webNoScroll`; paging is driven by the arrow keys instead.
1809
+ // Native: paging makes each swipe hard-stop at the adjacent page, while
1810
+ // `freeSwipe` lets momentum carry across pages and snap to a boundary.
1811
+ {...(isWeb
1812
+ ? null
1813
+ : {
1814
+ scrollEnabled: swipeEnabled,
1815
+ pagingEnabled: !freeSwipe,
1816
+ snapToIndices: freeSwipe ? snapToIndices : undefined,
1817
+ })}
1818
+ initialScrollIndex={activeIndex}
1819
+ showsHorizontalScrollIndicator={false}
1820
+ viewabilityConfig={PAGE_VIEWABILITY}
1821
+ onViewableItemsChanged={handleViewableItemsChanged}
1822
+ renderItem={renderItem}
1823
+ />
1824
+ </View>
1743
1825
  </View>
1744
- </View>
1826
+ </SlotStylesProvider>
1745
1827
  );
1746
1828
  }
1747
1829
 
@@ -1792,18 +1874,29 @@ const DefaultHeader = ({
1792
1874
  onPressDateHeader,
1793
1875
  }: DefaultHeaderProps) => {
1794
1876
  const theme = useCalendarTheme();
1877
+ const slot = useSlots<TimeGridSlot>();
1795
1878
  // Match the grid below: an hour-column spacer, then one column per day.
1796
1879
  const dayWidth = (width - hourColumnWidth) / days.length;
1797
1880
 
1798
1881
  return (
1799
- <View style={[styles.headerRow, { borderBottomColor: theme.colors.gridLine }]}>
1882
+ <View
1883
+ {...slot("header", {
1884
+ base: styles.headerRow,
1885
+ themed: { borderBottomColor: theme.colors.gridLine },
1886
+ })}
1887
+ >
1800
1888
  <View style={[styles.weekNumberGutter, { width: hourColumnWidth }]}>
1801
1889
  {showWeekNumber && hourColumnWidth > 0 && days[0] ? (
1802
1890
  <Text
1803
- style={[theme.text.hourLabel, { color: theme.colors.textMuted }]}
1891
+ {...slot("weekNumber", {
1892
+ themed: [theme.text.hourLabel, { color: theme.colors.textMuted }],
1893
+ })}
1804
1894
  allowFontScaling={false}
1805
1895
  >
1806
- {`${weekNumberPrefix}${getISOWeek(days[0])}`}
1896
+ {/* Reference the visible Thursday: an ISO week is defined by its Thursday,
1897
+ so a Sunday-start week still shows the week number its Mon–Sat body
1898
+ belongs to. */}
1899
+ {`${weekNumberPrefix}${getISOWeek(days.find((d) => d.getDay() === 4) ?? days[0])}`}
1807
1900
  </Text>
1808
1901
  ) : null}
1809
1902
  </View>
@@ -1842,51 +1935,78 @@ const DayHeader = ({
1842
1935
  onPressDateHeader,
1843
1936
  }: DayHeaderProps) => {
1844
1937
  const theme = useCalendarTheme();
1938
+ const slot = useSlots<TimeGridSlot>();
1845
1939
  const isToday = getIsToday(day);
1846
1940
  // Highlight the chosen `activeDate` when supplied, else the real today.
1847
1941
  const isHighlighted = activeDate ? isSameCalendarDay(day, activeDate) : isToday;
1848
1942
 
1849
- return (
1850
- <Pressable
1851
- style={[styles.dayHeader, { width }, theme.containers.columnHeader]}
1852
- onPress={onPressDateHeader ? () => onPressDateHeader(day) : undefined}
1853
- disabled={!onPressDateHeader}
1854
- accessibilityRole={onPressDateHeader ? "button" : undefined}
1855
- // A pressable header announces the full date it opens; a static one lets
1856
- // the child labels speak for themselves.
1857
- accessibilityLabel={onPressDateHeader ? format(day, "EEEE d MMMM", { locale }) : undefined}
1858
- >
1859
- {/* Mirrors the dom renderer's header: the muted weekday label sits above the
1860
- day number, and the number's circle fills for today / the active date.
1861
- Theme text merges after the muted colour so a themed colour wins. */}
1943
+ // One accessible name for the whole header (the weekday + number below are
1944
+ // decorative). `accessible` groups the children so a screen reader announces
1945
+ // this once, not label-by-label.
1946
+ const accessibilityLabel = `${format(day, "EEEE d MMMM", { locale })}${isToday ? ", today" : ""}`;
1947
+ const headerSlot = slot("columnHeader", {
1948
+ base: [styles.dayHeader, { width }],
1949
+ themed: theme.containers.columnHeader,
1950
+ });
1951
+ // Mirrors the dom renderer's header: the muted weekday label sits above the day
1952
+ // number, and the number's circle fills for today / the active date. Theme text
1953
+ // merges after the muted colour so a themed colour wins.
1954
+ const content = (
1955
+ <>
1862
1956
  <Text
1863
- style={[{ color: theme.colors.textMuted }, theme.text.columnHeaderWeekday]}
1957
+ {...slot("columnHeaderWeekday", {
1958
+ themed: [{ color: theme.colors.textMuted }, theme.text.columnHeaderWeekday],
1959
+ })}
1864
1960
  allowFontScaling={false}
1865
1961
  >
1866
1962
  {format(day, weekdayFormatToken(weekdayFormat), { locale })}
1867
1963
  </Text>
1868
1964
  <View
1869
1965
  testID="column-header-badge"
1870
- style={[
1871
- styles.dayHeaderBadge,
1872
- theme.containers.columnHeaderBadge,
1873
- isHighlighted && { backgroundColor: theme.colors.todayBackground },
1874
- ]}
1966
+ {...slot("columnHeaderDate", {
1967
+ base: styles.dayHeaderBadge,
1968
+ themed: [
1969
+ theme.containers.columnHeaderBadge,
1970
+ isHighlighted && { backgroundColor: theme.colors.todayBackground },
1971
+ ],
1972
+ })}
1875
1973
  >
1876
1974
  <Text
1877
- style={[
1878
- theme.text.dayNumber,
1879
- // The state colour stays last so a themed dayNumber can't break the
1880
- // today/active contrast.
1881
- { color: isHighlighted ? theme.colors.todayText : theme.colors.text },
1882
- ]}
1975
+ {...slot("columnHeaderDateText", {
1976
+ themed: [
1977
+ theme.text.dayNumber,
1978
+ // The state colour stays last so a themed dayNumber can't break the
1979
+ // today/active contrast.
1980
+ { color: isHighlighted ? theme.colors.todayText : theme.colors.text },
1981
+ ],
1982
+ })}
1883
1983
  allowFontScaling={false}
1884
- {...(isToday && { accessibilityLabel: `Today, ${day.getDate()}` })}
1885
1984
  >
1886
1985
  {day.getDate()}
1887
1986
  </Text>
1888
1987
  </View>
1988
+ </>
1989
+ );
1990
+ // Interactive → a labelled button. Otherwise a labelled `header`, so screen
1991
+ // readers still perceive (and announce) which day each column is.
1992
+ return onPressDateHeader ? (
1993
+ <Pressable
1994
+ {...headerSlot}
1995
+ onPress={() => onPressDateHeader(day)}
1996
+ accessibilityRole="button"
1997
+ accessibilityLabel={accessibilityLabel}
1998
+ >
1999
+ {content}
1889
2000
  </Pressable>
2001
+ ) : (
2002
+ <View
2003
+ {...headerSlot}
2004
+ accessible
2005
+ accessibilityRole="header"
2006
+ accessibilityLabel={accessibilityLabel}
2007
+ >
2008
+ {content}
2009
+ </View>
1890
2010
  );
1891
2011
  };
1892
2012