Repository: Thinkmill/keystatic
Pull request #607 (2023-08-30 03:00:08)

Keystar calendar components
Calendar widgets for date selection:

- New package "@keystar/ui/calendar" exposes new components, `Calendar` and `RangeCalendar`
- Update to "@keystar/ui/date-time" exposes new components, `DatePicker` and `DateRangePicker`

Related fixes and improvements:

- Truncate `ActionButton` label text
- Support "aria-hidden" on `Heading` and `Text` components

vercel[bot]: [vc]: #dfMJ9pxmenHDpmWmusRehj/v1UawOsIibtiSkSfZKWQ=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJrZXlzdGF0aWMiLCJyb290RGlyZWN0b3J5IjoiZGV2LXByb2plY3RzL25leHQtYXBwIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RoaW5rbWlsbC1sYWJzL2tleXN0YXRpYy9HdEplTFpCaWRKUlMxcTNmWEh4YUdDa3NiZ1RDIiwicHJldmlld1VybCI6ImtleXN0YXRpYy1naXQtZHMtY2FsZW5kYXItdGhpbmttaWxsLWxhYnMudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCJ9LHsibmFtZSI6ImtleXN0YXItdWkiLCJyb290RGlyZWN0b3J5IjoiZGVzaWduLXN5c3RlbS9kb2NzIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RoaW5rbWlsbC1sYWJzL2tleXN0YXItdWkvRXF1emlwR0M2SDVSRDRvU0x3MWtWOEVFc2czWSIsInByZXZpZXdVcmwiOiJrZXlzdGFyLXVpLWdpdC1kcy1jYWxlbmRhci10aGlua21pbGwtbGFicy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn0seyJuYW1lIjoia2V5c3RhdGljLXNpdGUiLCJyb290RGlyZWN0b3J5IjoiZG9jcyIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS90aGlua21pbGwtbGFicy9rZXlzdGF0aWMtc2l0ZS9EWllzUXJzRERTd1Q2RjF6ZThtNXdVUWtjYWRxIiwicHJldmlld1VybCI6ImtleXN0YXRpYy1zaXRlLWdpdC1kcy1jYWxlbmRhci10aGlua21pbGwtbGFicy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Updated (UTC) |
| :--- | :----- | :------ | :------ |
| **keystar-ui** | ✅ Ready ([Inspect](https://vercel.com/thinkmill-labs/keystar-ui/EquzipGC6H5RD4oSLw1kV8EEsg3Y)) | [Visit Preview](https://keystar-ui-git-ds-calendar-thinkmill-labs.vercel.app) | Aug 30, 2023 3:00am |
| **keystatic** | ✅ Ready ([Inspect](https://vercel.com/thinkmill-labs/keystatic/GtJeLZBidJRS1q3fXHxaGCksbgTC)) | [Visit Preview](https://keystatic-git-ds-calendar-thinkmill-labs.vercel.app) | Aug 30, 2023 3:00am |
| **keystatic-site** | ✅ Ready ([Inspect](https://vercel.com/thinkmill-labs/keystatic-site/DZYsQrsDDSwT6F1ze8m5wUQkcadq)) | [Visit Preview](https://keystatic-site-git-ds-calendar-thinkmill-labs.vercel.app) | Aug 30, 2023 3:00am |





Patch:
diff --git a/.changeset/wicked-ties-pull.md b/.changeset/wicked-ties-pull.md
new file mode 100644
index 00000000..e79ffc07
--- /dev/null
+++ b/.changeset/wicked-ties-pull.md
@@ -0,0 +1,15 @@
+---
+'@keystar/ui': minor
+'@keystar/docs': patch
+'@keystatic/core': patch
+---
+
+Calendar widgets for date selection:
+
+- New package "@keystar/ui/calendar" exposes new components, `Calendar` and `RangeCalendar`
+- Update to "@keystar/ui/date-time" exposes new components, `DatePicker` and `DateRangePicker`
+
+Related fixes and improvements:
+
+- Truncate `ActionButton` label text
+- Support "aria-hidden" on `Heading` and `Text` components
diff --git a/design-system/docs/app/layout.tsx b/design-system/docs/app/layout.tsx
index fe56dc73..512dd5aa 100644
--- a/design-system/docs/app/layout.tsx
+++ b/design-system/docs/app/layout.tsx
@@ -26,7 +26,7 @@ export default async function RootLayout({
   children: React.ReactNode;
 }) {
   return (
-    <NextRootProvider fontClassName={inter.variable} locale="en-US">
+    <NextRootProvider fontClassName={inter.variable}>
       <head>{nextRootScript}</head>
       <body>
         <Layout navigation={await getNavigation()}>{children}</Layout>
diff --git a/design-system/docs/package.json b/design-system/docs/package.json
index 8dcd2b0d..03df2b5f 100644
--- a/design-system/docs/package.json
+++ b/design-system/docs/package.json
@@ -18,7 +18,7 @@
   "dependencies": {
     "@babel/core": "^7.18.5",
     "@babel/runtime": "^7.18.3",
-    "@internationalized/date": "^3.3.0",
+    "@internationalized/date": "^3.4.0",
     "@keystar/ui": "^0.2.0",
     "@markdoc/markdoc": "^0.3.0",
     "@react-aria/i18n": "^3.8.0",
diff --git a/design-system/pkg/calendar/package.json b/design-system/pkg/calendar/package.json
new file mode 100644
index 00000000..8a5da8fe
--- /dev/null
+++ b/design-system/pkg/calendar/package.json
@@ -0,0 +1,4 @@
+{
+  "main": "dist/keystar-ui-calendar.cjs.js",
+  "module": "dist/keystar-ui-calendar.esm.js"
+}
diff --git a/design-system/pkg/package.json b/design-system/pkg/package.json
index 34883489..4d93a8a4 100644
--- a/design-system/pkg/package.json
+++ b/design-system/pkg/package.json
@@ -143,6 +143,11 @@
       "module": "./tooltip/dist/keystar-ui-tooltip.esm.js",
       "default": "./tooltip/dist/keystar-ui-tooltip.cjs.js"
     },
+    "./calendar": {
+      "types": "./calendar/dist/keystar-ui-calendar.cjs.js",
+      "module": "./calendar/dist/keystar-ui-calendar.esm.js",
+      "default": "./calendar/dist/keystar-ui-calendar.cjs.js"
+    },
     "./checkbox": {
       "types": "./checkbox/dist/keystar-ui-checkbox.cjs.js",
       "module": "./checkbox/dist/keystar-ui-checkbox.esm.js",
@@ -6129,13 +6134,14 @@
     "@babel/runtime": "^7.18.3",
     "@emotion/css": "^11.9.0",
     "@floating-ui/react": "^0.24.0",
-    "@internationalized/date": "^3.3.0",
+    "@internationalized/date": "^3.4.0",
     "@react-aria/actiongroup": "^3.6.0",
     "@react-aria/breadcrumbs": "^3.5.3",
     "@react-aria/button": "^3.8.0",
+    "@react-aria/calendar": "^3.4.1",
     "@react-aria/checkbox": "^3.9.2",
     "@react-aria/combobox": "^3.6.2",
-    "@react-aria/datepicker": "^3.4.0",
+    "@react-aria/datepicker": "^3.6.0",
     "@react-aria/dialog": "^3.5.3",
     "@react-aria/dnd": "^3.3.0",
     "@react-aria/focus": "^3.13.0",
@@ -6165,11 +6171,12 @@
     "@react-aria/utils": "^3.18.0",
     "@react-aria/virtualizer": "^3.9.0",
     "@react-aria/visually-hidden": "^3.8.2",
+    "@react-stately/calendar": "^3.3.1",
     "@react-stately/checkbox": "^3.4.3",
     "@react-stately/collections": "^3.9.0",
     "@react-stately/combobox": "^3.5.2",
     "@react-stately/data": "^3.10.0",
-    "@react-stately/datepicker": "^3.5.0",
+    "@react-stately/datepicker": "^3.6.0",
     "@react-stately/dnd": "^3.2.2",
     "@react-stately/layout": "^3.12.2",
     "@react-stately/list": "^3.9.0",
@@ -6189,7 +6196,8 @@
     "@react-stately/virtualizer": "^3.6.0",
     "@react-types/actiongroup": "^3.3.5",
     "@react-types/breadcrumbs": "^3.6.0",
-    "@react-types/button": "^3.7.0",
+    "@react-types/button": "^3.7.4",
+    "@react-types/calendar": "^3.3.1",
     "@react-types/combobox": "^3.5.5",
     "@react-types/datepicker": "^3.4.0",
     "@react-types/grid": "^3.1.5",
@@ -6198,7 +6206,7 @@
     "@react-types/overlays": "^3.8.0",
     "@react-types/radio": "^3.3.1",
     "@react-types/select": "^3.6.5",
-    "@react-types/shared": "^3.18.0",
+    "@react-types/shared": "^3.19.0",
     "@react-types/switch": "^3.3.1",
     "@react-types/tabs": "^3.1.5",
     "dedent": "^0.7.0",
diff --git a/design-system/pkg/src/button/FieldButton.tsx b/design-system/pkg/src/button/FieldButton.tsx
index 06c43608..27ada12e 100644
--- a/design-system/pkg/src/button/FieldButton.tsx
+++ b/design-system/pkg/src/button/FieldButton.tsx
@@ -46,11 +46,11 @@ export const FieldButton: ForwardRefExoticComponent<
         {...mergeProps(buttonProps, hoverProps)}
         ref={domRef}
         className={classNames(
-          styleProps.className,
           css({
             justifyContent: 'space-between',
             textAlign: 'start',
-          })
+          }),
+          styleProps.className
         )}
         style={{ ...styleProps.style, boxShadow: 'none' }}
       >
diff --git a/design-system/pkg/src/button/useActionButtonStyles.tsx b/design-system/pkg/src/button/useActionButtonStyles.tsx
index 4787a017..f1d5d1f3 100644
--- a/design-system/pkg/src/button/useActionButtonStyles.tsx
+++ b/design-system/pkg/src/button/useActionButtonStyles.tsx
@@ -63,6 +63,9 @@ export function useActionButtonStyles(
         [actionButtonClassList.childSelector('text')]: {
           fontWeight: 'inherit',
           marginInline: tokenSchema.size.space.small,
+          overflow: 'hidden',
+          textOverflow: 'ellipsis',
+          whiteSpace: 'nowrap',
         },
 
         // FOCUS RING
diff --git a/design-system/pkg/src/calendar/Calendar.tsx b/design-system/pkg/src/calendar/Calendar.tsx
new file mode 100644
index 00000000..e374a579
--- /dev/null
+++ b/design-system/pkg/src/calendar/Calendar.tsx
@@ -0,0 +1,72 @@
+import { createCalendar } from '@internationalized/date';
+import { useCalendar } from '@react-aria/calendar';
+import { useLocale } from '@react-aria/i18n';
+import { useObjectRef } from '@react-aria/utils';
+import { useCalendarState } from '@react-stately/calendar';
+import { DateValue } from '@react-types/calendar';
+import {
+  forwardRef,
+  ForwardedRef,
+  ReactElement,
+  useImperativeHandle,
+  useMemo,
+} from 'react';
+
+import { useProviderProps } from '@keystar/ui/core';
+
+import { CalendarBase } from './CalendarBase';
+import { CalendarProps } from './types';
+
+function Calendar<T extends DateValue>(
+  props: CalendarProps<T>,
+  forwardedRef: ForwardedRef<HTMLDivElement>
+) {
+  props = useProviderProps(props);
+  let { visibleMonths = 1 } = props;
+  visibleMonths = Math.max(visibleMonths, 1);
+  let visibleDuration = useMemo(
+    () => ({ months: visibleMonths }),
+    [visibleMonths]
+  );
+  let { locale } = useLocale();
+  let state = useCalendarState({
+    ...props,
+    locale,
+    visibleDuration,
+    createCalendar,
+  });
+
+  let domRef = useObjectRef(forwardedRef);
+  useImperativeHandle(forwardedRef, () => ({
+    ...domRef.current,
+    focus() {
+      state.setFocused(true);
+    },
+  }));
+
+  let { calendarProps, prevButtonProps, nextButtonProps } = useCalendar(
+    props,
+    state
+  );
+
+  return (
+    <CalendarBase
+      {...props}
+      visibleMonths={visibleMonths}
+      state={state}
+      calendarRef={domRef}
+      calendarProps={calendarProps}
+      prevButtonProps={prevButtonProps}
+      nextButtonProps={nextButtonProps}
+    />
+  );
+}
+
+/**
+ * Calendars display a grid of days in one or more months and allow users to
+ * select a single date.
+ */
+const _Calendar = forwardRef(Calendar) as <T extends DateValue>(
+  props: CalendarProps<T> & { ref?: ForwardedRef<HTMLDivElement> }
+) => ReactElement;
+export { _Calendar as Calendar };
diff --git a/design-system/pkg/src/calendar/CalendarBase.tsx b/design-system/pkg/src/calendar/CalendarBase.tsx
new file mode 100644
index 00000000..6a189a2c
--- /dev/null
+++ b/design-system/pkg/src/calendar/CalendarBase.tsx
@@ -0,0 +1,200 @@
+import { AriaButtonProps } from '@react-types/button';
+import { CalendarPropsBase } from '@react-types/calendar';
+import { CalendarState, RangeCalendarState } from '@react-stately/calendar';
+import { DOMProps } from '@react-types/shared';
+import { useDateFormatter, useLocale } from '@react-aria/i18n';
+import { VisuallyHidden } from '@react-aria/visually-hidden';
+import React, { HTMLAttributes, RefObject } from 'react';
+
+import { ActionButton } from '@keystar/ui/button';
+import { Icon } from '@keystar/ui/icon';
+import { chevronLeftIcon } from '@keystar/ui/icon/icons/chevronLeftIcon';
+import { chevronRightIcon } from '@keystar/ui/icon/icons/chevronRightIcon';
+import {
+  BaseStyleProps,
+  classNames,
+  css,
+  tokenSchema,
+  useStyleProps,
+} from '@keystar/ui/style';
+import { Heading } from '@keystar/ui/typography';
+
+import { CalendarMonth } from './CalendarMonth';
+
+interface CalendarBaseProps<T extends CalendarState | RangeCalendarState>
+  extends CalendarPropsBase,
+    DOMProps,
+    BaseStyleProps {
+  state: T;
+  visibleMonths?: number;
+  calendarProps: HTMLAttributes<HTMLElement>;
+  nextButtonProps: AriaButtonProps;
+  prevButtonProps: AriaButtonProps;
+  calendarRef: RefObject<HTMLDivElement>;
+}
+
+export function CalendarBase<T extends CalendarState | RangeCalendarState>(
+  props: CalendarBaseProps<T>
+) {
+  let {
+    state,
+    calendarProps,
+    nextButtonProps,
+    prevButtonProps,
+    calendarRef: ref,
+    visibleMonths = 1,
+  } = props;
+  let styleProps = useCalendarStyles(props);
+  let { direction } = useLocale();
+  let currentMonth = state.visibleRange.start;
+  let monthDateFormatter = useDateFormatter({
+    month: 'long',
+    year: 'numeric',
+    era:
+      currentMonth.calendar.identifier === 'gregory' &&
+      currentMonth.era === 'BC'
+        ? 'short'
+        : undefined,
+    calendar: currentMonth.calendar.identifier,
+    timeZone: state.timeZone,
+  });
+
+  let titles = [];
+  let calendars = [];
+  for (let i = 0; i < visibleMonths; i++) {
+    let d = currentMonth.add({ months: i });
+    titles.push(
+      <div key={i} {...styleProps.monthHeader}>
+        {i === 0 && (
+          <ActionButton
+            {...prevButtonProps}
+            prominence="low"
+            gridArea="prev"
+            justifySelf="start"
+            UNSAFE_style={{ padding: 0 }}
+          >
+            <Icon
+              src={direction === 'rtl' ? chevronRightIcon : chevronLeftIcon}
+              size="medium"
+            />
+          </ActionButton>
+        )}
+
+        <Heading
+          gridArea="title"
+          elementType="h2"
+          size="small"
+          align="center"
+          // We have a visually hidden heading describing the entire visible range,
+          // and the calendar itself describes the individual month
+          // so we don't need to repeat that here for screen reader users.
+          aria-hidden
+        >
+          {monthDateFormatter.format(d.toDate(state.timeZone))}
+        </Heading>
+
+        {i === visibleMonths - 1 && (
+          <ActionButton
+            {...nextButtonProps}
+            prominence="low"
+            gridArea="next"
+            justifySelf="end"
+            UNSAFE_style={{ padding: 0 }}
+          >
+            <Icon
+              src={direction === 'rtl' ? chevronLeftIcon : chevronRightIcon}
+              size="medium"
+            />
+          </ActionButton>
+        )}
+      </div>
+    );
+
+    calendars.push(
+      <CalendarMonth {...props} key={i} state={state} startDate={d} />
+    );
+  }
+
+  return (
+    <div {...styleProps.root} {...calendarProps} ref={ref}>
+      {/* Add a screen reader only description of the entire visible range rather than
+       * a separate heading above each month grid. This is placed first in the DOM order
+       * so that it is the first thing a touch screen reader user encounters.
+       * In addition, VoiceOver on iOS does not announce the aria-label of the grid
+       * elements, so the aria-label of the Calendar is included here as well. */}
+      <VisuallyHidden elementType="h2">
+        {calendarProps['aria-label']}
+      </VisuallyHidden>
+
+      <div {...styleProps.titles}>{titles}</div>
+      <div {...styleProps.calendars}>{calendars}</div>
+
+      {/* For touch screen readers, add a visually hidden next button after the month grid
+       * so it's easy to navigate after reaching the end without going all the way
+       * back to the start of the month. */}
+      <VisuallyHidden>
+        <button
+          aria-label={nextButtonProps['aria-label']}
+          disabled={nextButtonProps.isDisabled}
+          onClick={() => state.focusNextPage()}
+          tabIndex={-1}
+        />
+      </VisuallyHidden>
+    </div>
+  );
+}
+
+function useCalendarStyles<T extends CalendarState | RangeCalendarState>(
+  props: CalendarBaseProps<T>
+) {
+  let styleProps = useStyleProps(props);
+
+  let root = {
+    ...styleProps,
+    className: classNames(
+      css({
+        boxSizing: 'border-box',
+        maxWidth: '100%',
+        overflow: 'auto',
+        // make space for the focus ring, so it doesn't get cropped
+        padding: `calc(${tokenSchema.size.alias.focusRing} + ${tokenSchema.size.alias.focusRingGap})`,
+        '--calendar-cell-width': tokenSchema.size.element.regular,
+        '--calendar-cell-padding': tokenSchema.size.space.xsmall,
+        '--calendar-width':
+          'calc(var(--calendar-cell-width) * 7 + var(--calendar-cell-padding) * 12)',
+      }),
+      styleProps.className
+    ),
+  };
+  let titles = {
+    className: css({
+      boxSizing: 'border-box',
+      display: 'grid',
+      gap: tokenSchema.size.space.large,
+      gridAutoColumns: '1fr',
+      gridAutoFlow: 'column',
+      paddingInline: 'var(--calendar-cell-padding)',
+      width: '100%',
+    }),
+  };
+  let calendars = {
+    className: css({
+      display: 'grid',
+      gridAutoColumns: '1fr',
+      gridAutoFlow: 'column',
+      alignItems: 'start',
+      gap: tokenSchema.size.space.large,
+    }),
+  };
+  let monthHeader = {
+    className: css({
+      alignItems: 'center',
+      display: 'grid',
+      gridTemplateAreas: `"prev title next"`,
+      gridTemplateColumns: 'minmax(auto, 1fr) auto minmax(auto, 1fr)',
+      width: 'var(--calendar-width)',
+    }),
+  };
+
+  return { calendars, monthHeader, root, titles };
+}
diff --git a/design-system/pkg/src/calendar/CalendarCell.tsx b/design-system/pkg/src/calendar/CalendarCell.tsx
new file mode 100644
index 00000000..28b33d1c
--- /dev/null
+++ b/design-system/pkg/src/calendar/CalendarCell.tsx
@@ -0,0 +1,268 @@
+import {
+  CalendarDate,
+  getDayOfWeek,
+  isSameDay,
+  isSameMonth,
+  isToday,
+} from '@internationalized/date';
+import { AriaCalendarCellProps, useCalendarCell } from '@react-aria/calendar';
+import { useFocusRing } from '@react-aria/focus';
+import { useLocale } from '@react-aria/i18n';
+import { useHover } from '@react-aria/interactions';
+import { mergeProps } from '@react-aria/utils';
+import { CalendarState, RangeCalendarState } from '@react-stately/calendar';
+import React, { useRef } from 'react';
+
+import { css, toDataAttributes, tokenSchema } from '@keystar/ui/style';
+import { Text } from '@keystar/ui/typography';
+
+interface CalendarCellProps extends AriaCalendarCellProps {
+  state: CalendarState | RangeCalendarState;
+  currentMonth: CalendarDate;
+}
+
+export function CalendarCell({
+  currentMonth,
+  state,
+  ...props
+}: CalendarCellProps) {
+  let ref = useRef<HTMLElement>(null);
+  let {
+    buttonProps,
+    cellProps,
+    formattedDate,
+    isDisabled,
+    isFocused,
+    isInvalid,
+    isPressed,
+    isSelected,
+  } = useCalendarCell(
+    {
+      ...props,
+      isDisabled: !isSameMonth(props.date, currentMonth),
+    },
+    state,
+    ref
+  );
+  let isUnavailable = state.isCellUnavailable(props.date) && !isDisabled;
+  let isLastSelectedBeforeDisabled =
+    !isDisabled &&
+    !isInvalid &&
+    state.isCellUnavailable(props.date.add({ days: 1 }));
+  let isFirstSelectedAfterDisabled =
+    !isDisabled &&
+    !isInvalid &&
+    state.isCellUnavailable(props.date.subtract({ days: 1 }));
+  let highlightedRange = 'highlightedRange' in state && state.highlightedRange;
+  let isSelectionStart =
+    isSelected &&
+    highlightedRange &&
+    isSameDay(props.date, highlightedRange.start);
+  let isSelectionEnd =
+    isSelected &&
+    highlightedRange &&
+    isSameDay(props.date, highlightedRange.end);
+  let { locale } = useLocale();
+  let dayOfWeek = getDayOfWeek(props.date, locale);
+  let isRangeStart =
+    isSelected &&
+    (isFirstSelectedAfterDisabled || dayOfWeek === 0 || props.date.day === 1);
+  let isRangeEnd =
+    isSelected &&
+    (isLastSelectedBeforeDisabled ||
+      dayOfWeek === 6 ||
+      props.date.day === currentMonth.calendar.getDaysInMonth(currentMonth));
+  let { focusProps, isFocusVisible } = useFocusRing();
+  let { hoverProps, isHovered } = useHover({
+    isDisabled: isDisabled || isUnavailable || state.isReadOnly,
+  });
+
+  let dayState = {
+    // Style disabled (i.e. out of min/max range), but selected dates as unavailable
+    // since it is more clear than trying to dim the selection.
+    isDisabled: isDisabled && !isInvalid,
+    isFocused: isFocused && isFocusVisible,
+    isHovered: isHovered,
+    isInvalid: isInvalid,
+    isOutsideMonth: !isSameMonth(props.date, currentMonth),
+    isPressed: isPressed && !state.isReadOnly,
+    isRangeEnd: isRangeEnd,
+    isRangeSelection: isSelected && 'highlightedRange' in state,
+    isRangeStart: isRangeStart,
+    isSelected: isSelected,
+    isSelectionEnd: isSelectionEnd,
+    isSelectionStart: isSelectionStart,
+    isToday: isToday(props.date, state.timeZone),
+    isUnavailable: isUnavailable || (isInvalid && isDisabled),
+  };
+  let cellStyleProps = useCellStyles(dayState);
+  let dayStyleProps = useDayStyles(dayState);
+
+  return (
+    <td {...cellStyleProps} {...cellProps}>
+      <span
+        ref={ref}
+        {...mergeProps(buttonProps, hoverProps, focusProps)}
+        {...dayStyleProps}
+      >
+        <Text align="center" color="inherit" trim={false} weight="inherit">
+          {formattedDate}
+        </Text>
+      </span>
+    </td>
+  );
+}
+
+export function useCellStyles(props: Partial<CellStyleProps> = {}) {
+  let cellSize = `var(--calendar-cell-width, ${tokenSchema.size.element.regular})`;
+  let cellPadding = `var(--calendar-cell-padding, ${tokenSchema.size.space.xsmall})`;
+  return {
+    ...toDataAttributes(props, {
+      omitFalsyValues: true,
+      trimBooleanKeys: true,
+    }),
+    className: css({
+      height: cellSize,
+      padding: cellPadding,
+      position: 'relative',
+      textAlign: 'center',
+      width: cellSize,
+
+      '&[data-range-selection]:not([data-outside-month])': {
+        backgroundColor: tokenSchema.color.alias.backgroundSelected,
+        '&[data-invalid]': {
+          backgroundColor: tokenSchema.color.background.critical,
+          color: tokenSchema.color.foreground.critical,
+        },
+      },
+      '&[data-selection-start], &[data-range-start]': {
+        borderStartStartRadius: tokenSchema.size.radius.full,
+        borderEndStartRadius: tokenSchema.size.radius.full,
+      },
+      '&[data-selection-end], &[data-range-end]': {
+        borderStartEndRadius: tokenSchema.size.radius.full,
+        borderEndEndRadius: tokenSchema.size.radius.full,
+      },
+    }),
+  };
+}
+
+type CellStyleProps = {
+  isDisabled: boolean;
+  isFocused: boolean;
+  isHovered: boolean;
+  isInvalid: boolean;
+  isOutsideMonth: boolean;
+  isPressed: boolean;
+  isRangeEnd: boolean;
+  isRangeSelection: boolean;
+  isRangeStart: boolean;
+  isSelected: boolean;
+  isSelectionEnd: boolean;
+  isSelectionStart: boolean;
+  isToday: boolean;
+  isUnavailable: boolean;
+};
+
+function useDayStyles(props: CellStyleProps) {
+  let className = css({
+    alignItems: 'center',
+    borderRadius: tokenSchema.size.radius.full,
+    color: tokenSchema.color.foreground.neutral,
+    cursor: 'default',
+    display: 'flex',
+    inset: tokenSchema.size.space.xsmall,
+    justifyContent: 'center',
+    outline: 0,
+    position: 'absolute',
+
+    // Date specific
+    // -------------------------------------------------------------------------
+
+    // hide dates from other months
+    '&[data-outside-month]': {
+      visibility: 'hidden',
+    },
+
+    // today — indicated by a small underline beneath the date
+    '&[data-today]': {
+      color: tokenSchema.color.foreground.accent,
+      fontWeight: tokenSchema.typography.fontWeight.semibold,
+
+      '&:not([data-unavailable])::before': {
+        backgroundColor: 'currentColor',
+        borderRadius: tokenSchema.size.radius.full,
+        content: '""',
+        height: tokenSchema.size.border.medium,
+        marginInline: 'auto',
+        position: 'absolute',
+        top: `calc(50% + 1ch)`,
+        width: '2ch',
+      },
+    },
+
+    // unavailable — indicated by an angled strike-through over the date
+    '&[data-unavailable]:not([data-selected])': {
+      '::before': {
+        backgroundColor: 'currentColor',
+        borderRadius: tokenSchema.size.radius.full,
+        content: '""',
+        height: tokenSchema.size.border.medium,
+        marginInline: 'auto',
+        position: 'absolute',
+        top: '50%',
+        insetInline: tokenSchema.size.space.small,
+        transform: 'rotate(-16deg)',
+      },
+    },
+
+    // Interaction states
+    // -------------------------------------------------------------------------
+
+    '&[data-hovered]': {
+      backgroundColor: tokenSchema.color.alias.backgroundHovered,
+      color: tokenSchema.color.alias.foregroundHovered,
+    },
+    '&[data-pressed]': {
+      backgroundColor: tokenSchema.color.alias.backgroundPressed,
+    },
+    '&[data-focused]': {
+      outline: `${tokenSchema.size.alias.focusRing} solid ${tokenSchema.color.alias.focusRing}`,
+      outlineOffset: tokenSchema.size.alias.focusRingGap,
+    },
+
+    // Selection states
+    // -------------------------------------------------------------------------
+
+    '&[data-disabled]': {
+      color: tokenSchema.color.alias.foregroundDisabled,
+    },
+    '&[data-selected]:not([data-range-selection], [data-disabled]), &[data-selection-start], &[data-selection-end]':
+      {
+        backgroundColor: tokenSchema.color.background.accentEmphasis,
+        color: tokenSchema.color.foreground.onEmphasis,
+
+        '&[data-invalid]': {
+          backgroundColor: tokenSchema.color.background.criticalEmphasis,
+        },
+      },
+    '&[data-range-selection]:not([data-selection-start], [data-selection-end])':
+      {
+        color: tokenSchema.color.foreground.accent,
+        '&[data-hovered]': {
+          backgroundColor: tokenSchema.color.alias.backgroundSelectedHovered,
+        },
+        '&[data-invalid]': {
+          color: tokenSchema.color.foreground.critical,
+        },
+      },
+  });
+
+  return {
+    ...toDataAttributes(props, {
+      omitFalsyValues: true,
+      trimBooleanKeys: true,
+    }),
+    className,
+  };
+}
diff --git a/design-system/pkg/src/calendar/CalendarMonth.tsx b/design-system/pkg/src/calendar/CalendarMonth.tsx
new file mode 100644
index 00000000..9f5e0d0d
--- /dev/null
+++ b/design-system/pkg/src/calendar/CalendarMonth.tsx
@@ -0,0 +1,81 @@
+import {
+  CalendarDate,
+  endOfMonth,
+  getWeeksInMonth,
+} from '@internationalized/date';
+import { useCalendarGrid } from '@react-aria/calendar';
+import { useLocale } from '@react-aria/i18n';
+import { CalendarState, RangeCalendarState } from '@react-stately/calendar';
+import { CalendarPropsBase } from '@react-types/calendar';
+import { DOMProps } from '@react-types/shared';
+import React from 'react';
+
+import { css } from '@keystar/ui/style';
+import { Text } from '@keystar/ui/typography';
+
+import { CalendarCell, useCellStyles } from './CalendarCell';
+
+interface CalendarMonthProps extends CalendarPropsBase, DOMProps {
+  state: CalendarState | RangeCalendarState;
+  startDate: CalendarDate;
+}
+
+export function CalendarMonth(props: CalendarMonthProps) {
+  let { state, startDate } = props;
+  let { gridProps, headerProps, weekDays } = useCalendarGrid(
+    {
+      ...props,
+      endDate: endOfMonth(startDate),
+    },
+    state
+  );
+
+  let { locale } = useLocale();
+  let weeksInMonth = getWeeksInMonth(startDate, locale);
+  let cellStyleProps = useCellStyles();
+
+  return (
+    <table
+      className={css({
+        borderCollapse: 'collapse',
+        borderSpacing: 0,
+        tableLayout: 'fixed',
+        userSelect: 'none',
+        width: 'var(--calendar-width)',
+      })}
+      {...gridProps}
+    >
+      <thead {...headerProps}>
+        <tr>
+          {weekDays.map((day, index) => (
+            <th key={index} {...cellStyleProps}>
+              <Text align="center" color="neutralTertiary" size="small">
+                {day}
+              </Text>
+            </th>
+          ))}
+        </tr>
+      </thead>
+      <tbody>
+        {[...new Array(weeksInMonth).keys()].map(weekIndex => (
+          <tr key={weekIndex}>
+            {state
+              .getDatesInWeek(weekIndex, startDate)
+              .map((date, i) =>
+                date ? (
+                  <CalendarCell
+                    key={i}
+                    state={state}
+                    date={date}
+                    currentMonth={startDate}
+                  />
+                ) : (
+                  <td key={i} />
+                )
+              )}
+          </tr>
+        ))}
+      </tbody>
+    </table>
+  );
+}
diff --git a/design-system/pkg/src/calendar/RangeCalendar.tsx b/design-system/pkg/src/calendar/RangeCalendar.tsx
new file mode 100644
index 00000000..f834270d
--- /dev/null
+++ b/design-system/pkg/src/calendar/RangeCalendar.tsx
@@ -0,0 +1,73 @@
+import { createCalendar } from '@internationalized/date';
+import { useRangeCalendar } from '@react-aria/calendar';
+import { useLocale } from '@react-aria/i18n';
+import { useObjectRef } from '@react-aria/utils';
+import { useRangeCalendarState } from '@react-stately/calendar';
+import { DateValue } from '@react-types/calendar';
+import {
+  forwardRef,
+  ForwardedRef,
+  ReactElement,
+  useImperativeHandle,
+  useMemo,
+} from 'react';
+
+import { useProviderProps } from '@keystar/ui/core';
+
+import { CalendarBase } from './CalendarBase';
+import { RangeCalendarProps } from './types';
+
+function RangeCalendar<T extends DateValue>(
+  props: RangeCalendarProps<T>,
+  forwardedRef: ForwardedRef<HTMLDivElement>
+) {
+  props = useProviderProps(props);
+  let { visibleMonths = 1 } = props;
+  visibleMonths = Math.max(visibleMonths, 1);
+  let visibleDuration = useMemo(
+    () => ({ months: visibleMonths }),
+    [visibleMonths]
+  );
+  let { locale } = useLocale();
+  let state = useRangeCalendarState({
+    ...props,
+    locale,
+    visibleDuration,
+    createCalendar,
+  });
+
+  let domRef = useObjectRef(forwardedRef);
+  useImperativeHandle(forwardedRef, () => ({
+    ...domRef.current,
+    focus() {
+      state.setFocused(true);
+    },
+  }));
+
+  let { calendarProps, prevButtonProps, nextButtonProps } = useRangeCalendar(
+    props,
+    state,
+    domRef
+  );
+
+  return (
+    <CalendarBase
+      {...props}
+      visibleMonths={visibleMonths}
+      state={state}
+      calendarRef={domRef}
+      calendarProps={calendarProps}
+      prevButtonProps={prevButtonProps}
+      nextButtonProps={nextButtonProps}
+    />
+  );
+}
+
+/**
+ * RangeCalendars display a grid of days in one or more months and allow users
+ * to select a contiguous range of dates.
+ */
+const _RangeCalendar = forwardRef(RangeCalendar) as <T extends DateValue>(
+  props: RangeCalendarProps<T> & { ref?: ForwardedRef<HTMLDivElement> }
+) => ReactElement;
+export { _RangeCalendar as RangeCalendar };
diff --git a/design-system/pkg/src/calendar/index.ts b/design-system/pkg/src/calendar/index.ts
new file mode 100644
index 00000000..56974d52
--- /dev/null
+++ b/design-system/pkg/src/calendar/index.ts
@@ -0,0 +1,6 @@
+'use client';
+
+export { Calendar } from './Calendar';
+export { RangeCalendar } from './RangeCalendar';
+
+export type { CalendarProps, RangeCalendarProps } from './types';
diff --git a/design-system/pkg/src/calendar/stories/Calendar.stories.tsx b/design-system/pkg/src/calendar/stories/Calendar.stories.tsx
new file mode 100644
index 00000000..7928737e
--- /dev/null
+++ b/design-system/pkg/src/calendar/stories/Calendar.stories.tsx
@@ -0,0 +1,119 @@
+import {
+  CalendarDate,
+  getLocalTimeZone,
+  parseDate,
+  today,
+} from '@internationalized/date';
+
+import { action, Parameters, StoryObj } from '@keystar/ui-storybook';
+
+import { Calendar } from '../index';
+
+type Story = StoryObj<typeof Calendar>;
+
+const dateToday = today(getLocalTimeZone());
+
+export default {
+  title: 'Components/Date and Time/Calendar',
+  component: Calendar,
+  args: {
+    onChange: action('onChange'),
+  },
+  argTypes: {
+    onChange: { table: { disable: true } },
+    defaultValue: { table: { disable: true } },
+    minValue: { table: { disable: true } },
+    value: { table: { disable: true } },
+    maxValue: { table: { disable: true } },
+    defaultFocusedValue: { table: { disable: true } },
+    isDisabled: {
+      control: 'boolean',
+    },
+    isReadOnly: {
+      control: 'boolean',
+    },
+    autoFocus: {
+      control: 'boolean',
+    },
+    visibleMonths: {
+      control: 'number',
+    },
+    pageBehavior: {
+      control: 'select',
+      options: [null, 'single', 'visible'],
+    },
+    'aria-label': {
+      control: 'text',
+    },
+  },
+};
+
+export const Default: Story = render();
+
+export const DefaultValue: Story = {
+  ...Default,
+  args: { defaultValue: parseDate('2023-08-25') },
+};
+
+export const ControlledValue: Story = {
+  ...Default,
+  args: { value: parseDate('2023-08-26') },
+};
+
+export const OneWeek: Story = {
+  ...Default,
+  args: {
+    minValue: dateToday,
+    maxValue: dateToday.add({ weeks: 2 }),
+  },
+  name: 'minValue + maxValue',
+};
+
+export const DateUnavailable: Story = {
+  render: args => (
+    <Calendar
+      {...args}
+      defaultValue={dateToday.add({ days: 1 })}
+      isDateUnavailable={date => {
+        const now = dateToday;
+        const disabledIntervals = [
+          [now.add({ days: 1 }), now.add({ weeks: 1 })],
+          [now.add({ weeks: 2 }), now.add({ weeks: 3 })],
+        ];
+        return disabledIntervals.some(
+          interval =>
+            date.compare(interval[0]) > 0 && date.compare(interval[1]) < 0
+        );
+      }}
+    />
+  ),
+  name: 'isDateUnavailable',
+};
+
+export const InvalidSelection: Story = {
+  ...Default,
+  args: {
+    minValue: dateToday,
+    defaultValue: dateToday.subtract({ weeks: 1 }),
+  },
+  name: 'Invalid selection',
+};
+
+export const DefaultFocusedValue: Story = {
+  ...Default,
+  args: { defaultFocusedValue: new CalendarDate(2013, 9, 4) },
+  name: 'defaultFocusedValue',
+};
+
+function render(props = {}) {
+  return {
+    render: (args: Parameters) => (
+      <Calendar
+        {...args}
+        onChange={action('change')}
+        UNSAFE_className="custom-class-name"
+        {...props}
+      />
+    ),
+  };
+}
diff --git a/design-system/pkg/src/calendar/stories/RangeCalendar.stories.tsx b/design-system/pkg/src/calendar/stories/RangeCalendar.stories.tsx
new file mode 100644
index 00000000..c827488b
--- /dev/null
+++ b/design-system/pkg/src/calendar/stories/RangeCalendar.stories.tsx
@@ -0,0 +1,106 @@
+import { CalendarDate, getLocalTimeZone, today } from '@internationalized/date';
+
+import { action, Parameters, StoryObj } from '@keystar/ui-storybook';
+
+import { RangeCalendar } from '../index';
+
+type Story = StoryObj<typeof RangeCalendar>;
+
+const dateToday = today(getLocalTimeZone());
+
+export default {
+  title: 'Components/Date and Time/RangeCalendar',
+  component: RangeCalendar,
+  args: {
+    onChange: action('onChange'),
+  },
+  argTypes: {
+    onChange: { table: { disable: true } },
+    defaultValue: { table: { disable: true } },
+    minValue: { table: { disable: true } },
+    value: { table: { disable: true } },
+    maxValue: { table: { disable: true } },
+    defaultFocusedValue: { table: { disable: true } },
+    isDisabled: {
+      control: 'boolean',
+    },
+    isReadOnly: {
+      control: 'boolean',
+    },
+    autoFocus: {
+      control: 'boolean',
+    },
+    visibleMonths: {
+      control: 'number',
+    },
+    pageBehavior: {
+      control: 'select',
+      options: [null, 'single', 'visible'],
+    },
+    'aria-label': {
+      control: 'text',
+    },
+  },
+};
+
+export const Default: Story = render();
+
+export const DefaultValue: Story = {
+  ...Default,
+  args: {
+    defaultValue: {
+      start: new CalendarDate(2023, 8, 5),
+      end: new CalendarDate(2023, 8, 10),
+    },
+  },
+};
+
+export const ControlledValue: Story = {
+  ...Default,
+  args: {
+    value: {
+      start: new CalendarDate(2023, 8, 5),
+      end: new CalendarDate(2023, 8, 10),
+    },
+  },
+};
+
+export const OneWeek: Story = {
+  ...Default,
+  args: {
+    minValue: dateToday,
+    maxValue: dateToday.add({ weeks: 1 }),
+  },
+  name: 'minValue + maxValue',
+};
+
+export const InvalidSelection: Story = {
+  ...Default,
+  args: {
+    minValue: dateToday,
+    defaultValue: {
+      start: dateToday.subtract({ weeks: 2 }),
+      end: dateToday.subtract({ weeks: 1 }),
+    },
+  },
+  name: 'Invalid selection',
+};
+
+export const DefaultFocusedValue: Story = {
+  ...Default,
+  args: { defaultFocusedValue: new CalendarDate(2013, 9, 4) },
+  name: 'defaultFocusedValue',
+};
+
+function render(props = {}) {
+  return {
+    render: (args: Parameters) => (
+      <RangeCalendar
+        {...args}
+        onChange={action('change')}
+        UNSAFE_className="custom-class-name"
+        {...props}
+      />
+    ),
+  };
+}
diff --git a/design-system/pkg/src/calendar/types.ts b/design-system/pkg/src/calendar/types.ts
new file mode 100644
index 00000000..d456cbe2
--- /dev/null
+++ b/design-system/pkg/src/calendar/types.ts
@@ -0,0 +1,26 @@
+import {
+  AriaCalendarProps,
+  AriaRangeCalendarProps,
+  DateValue,
+} from '@react-types/calendar';
+import { BaseStyleProps } from '@keystar/ui/style';
+
+export interface CalendarProps<T extends DateValue>
+  extends AriaCalendarProps<T>,
+    BaseStyleProps {
+  /**
+   * The number of months to display at once. Up to 3 months are supported.
+   * @default 1
+   */
+  visibleMonths?: number;
+}
+
+export interface RangeCalendarProps<T extends DateValue>
+  extends AriaRangeCalendarProps<T>,
+    BaseStyleProps {
+  /**
+   * The number of months to display at once. Up to 3 months are supported.
+   * @default 1
+   */
+  visibleMonths?: number;
+}
diff --git a/design-system/pkg/src/date-time/DateField.tsx b/design-system/pkg/src/date-time/DateField.tsx
index 1a28325e..0a3a20fb 100644
--- a/design-system/pkg/src/date-time/DateField.tsx
+++ b/design-system/pkg/src/date-time/DateField.tsx
@@ -28,9 +28,15 @@ function DateField<T extends DateValue>(
     createCalendar,
   });
 
-  let inputRef = useRef(null);
-  let { labelProps, fieldProps, descriptionProps, errorMessageProps } =
-    useDateField(props, state, inputRef);
+  let fieldRef = useRef<HTMLDivElement>(null);
+  let inputRef = useRef<HTMLInputElement>(null);
+  let {
+    descriptionProps,
+    errorMessageProps,
+    fieldProps,
+    inputProps,
+    labelProps,
+  } = useDateField({ ...props, inputRef }, state, fieldRef);
 
   // Note: this description is intentionally not passed to useDatePicker.
   // The format help text is unnecessary for screen reader users because each segment already has a label.
@@ -47,13 +53,14 @@ function DateField<T extends DateValue>(
       {...props}
       ref={domRef}
       description={description}
+      labelElementType="span"
       labelProps={labelProps}
       descriptionProps={descriptionProps}
       errorMessageProps={errorMessageProps}
       // validationState={state.validationState}
     >
       <Input
-        ref={inputRef}
+        ref={fieldRef}
         fieldProps={fieldProps}
         isDisabled={isDisabled}
         autoFocus={autoFocus}
@@ -69,6 +76,7 @@ function DateField<T extends DateValue>(
             isRequired={isRequired}
           />
         ))}
+        <input {...inputProps} ref={inputRef} />
       </Input>
     </FieldPrimitive>
   );
diff --git a/design-system/pkg/src/date-time/DatePicker.tsx b/design-system/pkg/src/date-time/DatePicker.tsx
new file mode 100644
index 00000000..0a1afd03
--- /dev/null
+++ b/design-system/pkg/src/date-time/DatePicker.tsx
@@ -0,0 +1,221 @@
+import { useDatePicker } from '@react-aria/datepicker';
+import { useFocusRing } from '@react-aria/focus';
+import { useHover } from '@react-aria/interactions';
+import { mergeProps } from '@react-aria/utils';
+import { useDatePickerState } from '@react-stately/datepicker';
+import { DateValue } from '@react-types/datepicker';
+import React, { ReactElement, Ref, useRef } from 'react';
+
+import { FieldButton } from '@keystar/ui/button';
+import { Calendar } from '@keystar/ui/calendar';
+import { useProviderProps } from '@keystar/ui/core';
+import { FieldPrimitive } from '@keystar/ui/field';
+import { Icon } from '@keystar/ui/icon';
+import { calendarDaysIcon } from '@keystar/ui/icon/icons/calendarDaysIcon';
+import {
+  css,
+  toDataAttributes,
+  tokenSchema,
+  transition,
+} from '@keystar/ui/style';
+
+import { DatePickerField } from './DatePickerField';
+import { DatePickerPopover } from './DatePickerPopover';
+import { Input } from './Input';
+import { DatePickerProps } from './types';
+import {
+  useFocusManagerRef,
+  useFormatHelpText,
+  useVisibleMonths,
+} from './utils';
+
+function DatePicker<T extends DateValue>(
+  props: DatePickerProps<T>,
+  forwardedRef: Ref<HTMLDivElement>
+) {
+  props = useProviderProps(props);
+  let {
+    autoFocus,
+    isDisabled,
+    isReadOnly,
+    maxVisibleMonths = 1,
+    pageBehavior,
+  } = props;
+  let { hoverProps, isHovered } = useHover({ isDisabled });
+
+  let triggerRef = useRef<HTMLDivElement>(null);
+  let domRef = useFocusManagerRef(forwardedRef);
+  let state = useDatePickerState(props);
+  if (props.errorMessage) {
+    state.validationState = 'invalid';
+  }
+  let {
+    buttonProps,
+    calendarProps,
+    descriptionProps,
+    dialogProps,
+    errorMessageProps,
+    fieldProps,
+    groupProps,
+    labelProps,
+  } = useDatePicker(props, state, triggerRef);
+
+  let { isFocused, isFocusVisible, focusProps } = useFocusRing({
+    within: true,
+    isTextInput: true,
+    autoFocus,
+  });
+
+  let { isFocused: isFocusedButton, focusProps: focusPropsButton } =
+    useFocusRing({
+      within: false,
+      isTextInput: false,
+      autoFocus,
+    });
+
+  // Note: this description is intentionally not passed to useDatePicker.
+  // The format help text is unnecessary for screen reader users because each segment already has a label.
+  let description = useFormatHelpText(props);
+  if (description && !props.description) {
+    descriptionProps.id = undefined;
+  }
+
+  let visibleMonths = useVisibleMonths(maxVisibleMonths);
+
+  let styleProps = usePickerStyles({
+    isHovered,
+    isFocused,
+    isFocusVisible: isFocusVisible && !isFocusedButton,
+    isDisabled,
+    isReadOnly,
+    isInvalid: state.validationState === 'invalid',
+  });
+
+  return (
+    <FieldPrimitive
+      {...props}
+      ref={domRef}
+      description={description}
+      labelElementType="span"
+      labelProps={labelProps}
+      descriptionProps={descriptionProps}
+      errorMessageProps={errorMessageProps}
+      // validationState={state.validationState}
+    >
+      <div
+        {...mergeProps(groupProps, hoverProps, focusProps)}
+        {...styleProps.root}
+        ref={triggerRef}
+      >
+        <Input
+          isDisabled={isDisabled}
+          validationState={state.validationState}
+          disableFocusRing
+          {...styleProps.input}
+        >
+          <DatePickerField {...fieldProps} data-testid="date-field" />
+        </Input>
+        <FieldButton
+          {...mergeProps(buttonProps, focusPropsButton)}
+          {...styleProps.button}
+          validationState={state.validationState}
+          isDisabled={isDisabled || isReadOnly}
+        >
+          <Icon src={calendarDaysIcon} />
+        </FieldButton>
+        <DatePickerPopover
+          dialogProps={dialogProps}
+          shouldFlip={props.shouldFlip}
+          state={state}
+          triggerRef={triggerRef}
+        >
+          <Calendar
+            {...calendarProps}
+            visibleMonths={visibleMonths}
+            pageBehavior={pageBehavior}
+          />
+        </DatePickerPopover>
+      </div>
+    </FieldPrimitive>
+  );
+}
+
+// forwardRef doesn't support generic parameters, so cast the result to the correct type
+// https://stackoverflow.com/questions/58469229/react-with-typescript-generics-while-using-react-forwardref
+
+/**
+ * DatePickers combine a DateField and a Calendar popover to allow users to
+ * enter or select a date and time value.
+ */
+const _DatePicker: <T extends DateValue>(
+  props: DatePickerProps<T> & { ref?: Ref<HTMLDivElement> }
+) => ReactElement = React.forwardRef(DatePicker as any) as any;
+export { _DatePicker as DatePicker };
+
+export function usePickerStyles(state: {
+  isHovered: boolean;
+  isFocused: boolean;
+  isFocusVisible: boolean;
+  isDisabled?: boolean;
+  isReadOnly?: boolean;
+  isInvalid?: boolean;
+}) {
+  let root = {
+    ...toDataAttributes(state, {
+      omitFalsyValues: true,
+      trimBooleanKeys: true,
+    }),
+    className: css({
+      borderRadius: tokenSchema.size.radius.regular,
+      display: 'flex',
+      position: 'relative',
+
+      '&::after': {
+        borderRadius: `inherit`,
+        content: '""',
+        inset: tokenSchema.size.border.regular,
+        margin: 0,
+        pointerEvents: 'none',
+        position: 'absolute' as const,
+        transition: transition(['box-shadow', 'margin'], {
+          easing: 'easeOut',
+        }),
+      },
+      '&[data-focus-visible]::after': {
+        boxShadow: `0 0 0 ${tokenSchema.size.alias.focusRing} ${tokenSchema.color.alias.focusRing}`,
+      },
+    }),
+  };
+  let input = {
+    className: css({
+      borderStartEndRadius: 0,
+      borderEndEndRadius: 0,
+      borderInlineEndWidth: 0,
+
+      [`.${root.className}[data-focused] &`]: {
+        borderColor: tokenSchema.color.alias.borderFocused,
+      },
+    }),
+  };
+  let button = {
+    UNSAFE_className: css({
+      borderStartStartRadius: 0,
+      borderEndStartRadius: 0,
+
+      [`.${root.className}[data-read-only] &`]: {
+        borderColor: tokenSchema.color.alias.borderIdle,
+      },
+      [`.${root.className}[data-invalid] &`]: {
+        borderColor: tokenSchema.color.alias.borderInvalid,
+      },
+      [`.${root.className}[data-focused] &`]: {
+        borderColor: tokenSchema.color.alias.borderFocused,
+      },
+      [`.${root.className}[data-disabled] &`]: {
+        borderColor: 'transparent',
+      },
+    }),
+  };
+
+  return { button, input, root };
+}
diff --git a/design-system/pkg/src/date-time/DatePickerField.tsx b/design-system/pkg/src/date-time/DatePickerField.tsx
new file mode 100644
index 00000000..7b1a9a1c
--- /dev/null
+++ b/design-system/pkg/src/date-time/DatePickerField.tsx
@@ -0,0 +1,64 @@
+import { DateValue, createCalendar } from '@internationalized/date';
+import { useDateField } from '@react-aria/datepicker';
+import { useLocale } from '@react-aria/i18n';
+import { useDateFieldState } from '@react-stately/datepicker';
+import { useRef } from 'react';
+
+import { css, tokenSchema } from '@keystar/ui/style';
+
+import { InputSegment } from './InputSegment';
+import { DatePickerProps } from './types';
+
+interface DatePickerFieldProps<T extends DateValue> extends DatePickerProps<T> {
+  maxGranularity?: DatePickerProps<T>['granularity'];
+  rangeFieldType?: 'start' | 'end';
+}
+
+/** @private for internal use only. */
+export function DatePickerField<T extends DateValue>(
+  props: DatePickerFieldProps<T>
+) {
+  let { isDisabled, isReadOnly, isRequired, rangeFieldType } = props;
+
+  let fieldRef = useRef<HTMLDivElement>(null);
+  let inputRef = useRef<HTMLInputElement>(null);
+  let { locale } = useLocale();
+  let state = useDateFieldState({ ...props, locale, createCalendar });
+  let { fieldProps, inputProps } = useDateField(
+    { ...props, inputRef },
+    state,
+    fieldRef
+  );
+
+  return (
+    <div
+      {...fieldProps}
+      // @ts-expect-error
+      data-testid={props['data-testid']}
+      data-range={rangeFieldType}
+      className={css({
+        display: 'flex',
+
+        '&[data-range=start]': {
+          paddingInlineEnd: tokenSchema.size.space.regular,
+        },
+        '&[data-range=end]': {
+          paddingInlineStart: tokenSchema.size.space.regular,
+        },
+      })}
+      ref={fieldRef}
+    >
+      {state.segments.map((segment, i) => (
+        <InputSegment
+          key={i}
+          segment={segment}
+          state={state}
+          isDisabled={isDisabled}
+          isReadOnly={isReadOnly}
+          isRequired={isRequired}
+        />
+      ))}
+      <input {...inputProps} ref={inputRef} />
+    </div>
+  );
+}
diff --git a/design-system/pkg/src/date-time/DatePickerPopover.tsx b/design-system/pkg/src/date-time/DatePickerPopover.tsx
new file mode 100644
index 00000000..88fd4835
--- /dev/null
+++ b/design-system/pkg/src/date-time/DatePickerPopover.tsx
@@ -0,0 +1,76 @@
+import { AriaDialogProps, useDialog } from '@react-aria/dialog';
+import { useLocale } from '@react-aria/i18n';
+import {
+  DatePickerState,
+  DateRangePickerState,
+} from '@react-stately/datepicker';
+import { ReactNode, useRef } from 'react';
+
+import { Popover, Tray } from '@keystar/ui/overlays';
+import { css, tokenSchema, useIsMobileDevice } from '@keystar/ui/style';
+
+export function DatePickerPopover({
+  state,
+  ...props
+}: {
+  children: ReactNode;
+  dialogProps: AriaDialogProps;
+  shouldFlip?: boolean;
+  state: DatePickerState | DateRangePickerState;
+  triggerRef: React.RefObject<HTMLElement>;
+}) {
+  let scrollRef = useRef<HTMLDivElement>(null);
+  let { direction } = useLocale();
+  let isMobile = useIsMobileDevice();
+  let { dialogProps } = useDialog(props.dialogProps, scrollRef);
+
+  let content = (
+    <div
+      ref={scrollRef}
+      className={css({
+        display: 'flex',
+        justifyContent: 'center',
+        maxHeight: 'inherit',
+        outline: 0,
+        overflowY: 'auto',
+        WebkitOverflowScrolling: 'touch',
+      })}
+      {...dialogProps}
+    >
+      <div
+        className={css({
+          paddingInline: tokenSchema.size.space.medium,
+          paddingTop: tokenSchema.size.space.medium,
+          // bottom-padding fix for the scrollable area
+          '&::after': {
+            content: '""',
+            display: 'block',
+            height: tokenSchema.size.space.medium,
+          },
+        })}
+      >
+        {props.children}
+      </div>
+    </div>
+  );
+
+  let overlay;
+  if (isMobile) {
+    overlay = <Tray state={state}>{content}</Tray>;
+  } else {
+    overlay = (
+      <Popover
+        hideArrow
+        placement={direction === 'rtl' ? 'bottom right' : 'bottom left'}
+        scrollRef={scrollRef}
+        shouldFlip={props.shouldFlip}
+        state={state}
+        triggerRef={props.triggerRef}
+      >
+        {content}
+      </Popover>
+    );
+  }
+
+  return overlay;
+}
diff --git a/design-system/pkg/src/date-time/DateRangePicker.tsx b/design-system/pkg/src/date-time/DateRangePicker.tsx
new file mode 100644
index 00000000..ba15bd9c
--- /dev/null
+++ b/design-system/pkg/src/date-time/DateRangePicker.tsx
@@ -0,0 +1,163 @@
+import { useDateRangePicker } from '@react-aria/datepicker';
+import { useFocusRing } from '@react-aria/focus';
+import { useHover } from '@react-aria/interactions';
+import { mergeProps } from '@react-aria/utils';
+import { useDateRangePickerState } from '@react-stately/datepicker';
+import { DateValue } from '@react-types/datepicker';
+import React, { ReactElement, Ref, useRef } from 'react';
+
+import { FieldButton } from '@keystar/ui/button';
+import { RangeCalendar } from '@keystar/ui/calendar';
+import { useProviderProps } from '@keystar/ui/core';
+import { FieldPrimitive } from '@keystar/ui/field';
+import { Icon } from '@keystar/ui/icon';
+import { calendarDaysIcon } from '@keystar/ui/icon/icons/calendarDaysIcon';
+import { Text } from '@keystar/ui/typography';
+
+import { Input } from './Input';
+import { DatePickerField } from './DatePickerField';
+import { usePickerStyles } from './DatePicker';
+import { DatePickerPopover } from './DatePickerPopover';
+import { DateRangePickerProps } from './types';
+import {
+  useFocusManagerRef,
+  useFormatHelpText,
+  useVisibleMonths,
+} from './utils';
+
+function DateRangePicker<T extends DateValue>(
+  props: DateRangePickerProps<T>,
+  forwardedRef: Ref<HTMLDivElement>
+) {
+  props = useProviderProps(props);
+  let {
+    autoFocus,
+    isDisabled,
+    isReadOnly,
+    maxVisibleMonths = 1,
+    pageBehavior,
+  } = props;
+  let { hoverProps, isHovered } = useHover({ isDisabled });
+
+  let triggerRef = useRef<HTMLDivElement>(null);
+  let domRef = useFocusManagerRef(forwardedRef);
+  let state = useDateRangePickerState(props);
+  if (props.errorMessage) {
+    state.validationState = 'invalid';
+  }
+  let {
+    buttonProps,
+    calendarProps,
+    descriptionProps,
+    dialogProps,
+    endFieldProps,
+    errorMessageProps,
+    groupProps,
+    labelProps,
+    startFieldProps,
+  } = useDateRangePicker(props, state, triggerRef);
+
+  let { isFocused, isFocusVisible, focusProps } = useFocusRing({
+    within: true,
+    isTextInput: true,
+    autoFocus,
+  });
+
+  let { isFocused: isFocusedButton, focusProps: focusPropsButton } =
+    useFocusRing({
+      within: false,
+      isTextInput: false,
+      autoFocus,
+    });
+
+  // Note: this description is intentionally not passed to useDateRangePicker.
+  // The format help text is unnecessary for screen reader users because each segment already has a label.
+  let description = useFormatHelpText(props);
+  if (description && !props.description) {
+    descriptionProps.id = undefined;
+  }
+
+  let visibleMonths = useVisibleMonths(maxVisibleMonths);
+
+  let styleProps = usePickerStyles({
+    isHovered,
+    isFocused,
+    isFocusVisible: isFocusVisible && !isFocusedButton,
+    isDisabled,
+    isReadOnly,
+    isInvalid: state.validationState === 'invalid',
+  });
+
+  return (
+    <FieldPrimitive
+      {...props}
+      ref={domRef}
+      description={description}
+      labelElementType="span"
+      labelProps={labelProps}
+      descriptionProps={descriptionProps}
+      errorMessageProps={errorMessageProps}
+      // validationState={state.validationState}
+    >
+      <div
+        {...mergeProps(groupProps, hoverProps, focusProps)}
+        {...styleProps.root}
+        ref={triggerRef}
+      >
+        <Input
+          isDisabled={isDisabled}
+          validationState={state.validationState}
+          disableFocusRing
+          {...styleProps.input}
+        >
+          <DatePickerField
+            rangeFieldType="start"
+            data-testid="start-date"
+            {...startFieldProps}
+          />
+          {/* em—dash */}
+          <Text aria-hidden="true" trim={false}>
+            {'\u2014'}
+          </Text>
+          <DatePickerField
+            rangeFieldType="end"
+            data-testid="end-date"
+            {...endFieldProps}
+          />
+        </Input>
+        <FieldButton
+          {...mergeProps(buttonProps, focusPropsButton)}
+          {...styleProps.button}
+          validationState={state.validationState}
+          isDisabled={isDisabled || isReadOnly}
+        >
+          <Icon src={calendarDaysIcon} />
+        </FieldButton>
+        <DatePickerPopover
+          dialogProps={dialogProps}
+          shouldFlip={props.shouldFlip}
+          state={state}
+          triggerRef={triggerRef}
+        >
+          <RangeCalendar
+            {...calendarProps}
+            visibleMonths={visibleMonths}
+            pageBehavior={pageBehavior}
+          />
+        </DatePickerPopover>
+      </div>
+    </FieldPrimitive>
+  );
+}
+
+// forwardRef doesn't support generic parameters, so cast the result to the correct type
+// https://stackoverflow.com/questions/58469229/react-with-typescript-generics-while-using-react-forwardref
+
+/**
+ * DateRangePickers combine two DateFields and a RangeCalendar popover to allow users
+ * to enter or select a date and time range.
+ */
+const _DateRangePicker: <T extends DateValue>(
+  props: DateRangePickerProps<T> & { ref?: Ref<HTMLDivElement> }
+) => ReactElement = React.forwardRef(DateRangePicker as any) as any;
+export { _DateRangePicker as DateRangePicker };
diff --git a/design-system/pkg/src/date-time/Input.tsx b/design-system/pkg/src/date-time/Input.tsx
index 5a0fba17..70fe354d 100644
--- a/design-system/pkg/src/date-time/Input.tsx
+++ b/design-system/pkg/src/date-time/Input.tsx
@@ -1,6 +1,6 @@
-import { mergeProps, mergeRefs } from '@react-aria/utils';
+import { mergeProps, useObjectRef } from '@react-aria/utils';
 import { useFocusRing } from '@react-aria/focus';
-import { ReactNode, forwardRef, useRef } from 'react';
+import { CSSProperties, DOMAttributes, ReactNode, forwardRef } from 'react';
 
 import {
   classNames,
@@ -14,28 +14,19 @@ type InputProps = {
   children?: ReactNode;
   className?: string;
   disableFocusRing?: boolean;
-  fieldProps?: any;
-  inputClassName?: string;
+  fieldProps?: DOMAttributes<HTMLElement>;
   isDisabled?: boolean;
-  style?: any;
+  style?: CSSProperties;
   validationState?: 'valid' | 'invalid';
 };
 
 export const Input = forwardRef<HTMLDivElement, InputProps>(function Input(
   props,
-  ref
+  forwardedRef
 ) {
-  let inputRef = useRef(null);
-  let {
-    children,
-    className,
-    disableFocusRing,
-    fieldProps,
-    inputClassName,
-    isDisabled,
-    style,
-    validationState,
-  } = props;
+  let inputRef = useObjectRef(forwardedRef);
+  let { children, disableFocusRing, fieldProps, isDisabled, validationState } =
+    props;
 
   let { focusProps, isFocusVisible, isFocused } = useFocusRing({
     isTextInput: true,
@@ -43,7 +34,7 @@ export const Input = forwardRef<HTMLDivElement, InputProps>(function Input(
   });
 
   let isInvalid = validationState === 'invalid' && !isDisabled;
-  let inputStyles = useInputStyles(inputClassName, {
+  let styleProps = useInputStyles(props, {
     isDisabled,
     isInvalid,
     isFocused,
@@ -54,24 +45,21 @@ export const Input = forwardRef<HTMLDivElement, InputProps>(function Input(
     <div
       role="presentation"
       {...mergeProps(fieldProps, focusProps)}
-      className={className}
-      style={style}
+      {...styleProps}
     >
-      <div role="presentation" {...inputStyles}>
-        <div
-          role="presentation"
-          className={css({
-            alignItems: 'center',
-            display: 'flex',
-            height: '100%',
-            overflowX: 'auto',
-            scrollbarWidth: 'none',
-            msOverflowStyle: 'none',
-          })}
-          ref={mergeRefs(ref, inputRef)}
-        >
-          {children}
-        </div>
+      <div
+        role="presentation"
+        className={css({
+          alignItems: 'center',
+          display: 'inline-flex',
+          height: '100%',
+          overflowX: 'auto',
+          scrollbarWidth: 'none',
+          msOverflowStyle: 'none',
+        })}
+        ref={inputRef}
+      >
+        {children}
       </div>
     </div>
   );
@@ -79,18 +67,18 @@ export const Input = forwardRef<HTMLDivElement, InputProps>(function Input(
 
 type InputState = {
   isDisabled?: boolean;
-  isInvalid: boolean;
   isFocused: boolean;
   isFocusVisible: boolean;
+  isInvalid: boolean;
 };
-function useInputStyles(inputClassName: string | undefined, state: InputState) {
-  let { isDisabled, isInvalid, isFocused, isFocusVisible } = state;
+
+function useInputStyles(props: InputProps, state: InputState) {
   let className = classNames(
-    inputClassName,
     css({
       backgroundColor: tokenSchema.color.background.canvas,
       border: `${tokenSchema.size.border.regular} solid ${tokenSchema.color.alias.borderIdle}`,
       borderRadius: tokenSchema.size.radius.regular,
+      cursor: 'text',
       height: tokenSchema.size.element.regular,
       lineHeight: tokenSchema.typography.lineheight.small,
       outline: 0,
@@ -104,26 +92,29 @@ function useInputStyles(inputClassName: string | undefined, state: InputState) {
       minWidth: tokenSchema.size.scale[2000],
       width: '100%',
 
-      '&[data-invalid=true]': {
+      '&[data-invalid]': {
         borderColor: tokenSchema.color.alias.borderInvalid,
       },
-      '&[data-focus]': {
+      '&[data-focused]': {
         borderColor: tokenSchema.color.alias.borderFocused,
+      },
+      '&[data-focus-visible]': {
         boxShadow: `0 0 0 1px ${tokenSchema.color.alias.borderFocused}`,
       },
-      '&[data-disabled=true]': {
-        backgroundColor: tokenSchema.color.background.surfaceSecondary,
+      '&[data-disabled]': {
+        backgroundColor: tokenSchema.color.alias.backgroundDisabled,
         borderColor: 'transparent',
       },
-    })
+    }),
+    props.className
   );
 
   return {
-    ...toDataAttributes({
-      disabled: isDisabled || undefined,
-      invalid: isInvalid || undefined,
-      focus: isFocusVisible ? 'visible' : isFocused || undefined,
+    ...toDataAttributes(state, {
+      omitFalsyValues: true,
+      trimBooleanKeys: true,
     }),
     className,
+    style: props.style,
   };
 }
diff --git a/design-system/pkg/src/date-time/InputSegment.tsx b/design-system/pkg/src/date-time/InputSegment.tsx
index 67d8f84e..7aaf66de 100644
--- a/design-system/pkg/src/date-time/InputSegment.tsx
+++ b/design-system/pkg/src/date-time/InputSegment.tsx
@@ -3,9 +3,17 @@ import { DateFieldState, DateSegment } from '@react-stately/datepicker';
 import { DatePickerBase, DateValue } from '@react-types/datepicker';
 import React, { useRef } from 'react';
 
-import { css, toDataAttributes, tokenSchema } from '@keystar/ui/style';
+import {
+  ClassList,
+  classNames,
+  css,
+  toDataAttributes,
+  tokenSchema,
+} from '@keystar/ui/style';
 import { Text } from '@keystar/ui/typography';
 
+const segmentClassList = new ClassList('InputSegment');
+
 interface InputSegmentProps extends DatePickerBase<DateValue> {
   segment: DateSegment;
   state: DateFieldState;
@@ -29,11 +37,15 @@ function LiteralSegment({ segment }: { segment: DateSegment }) {
     <Text
       elementType="span"
       aria-hidden="true"
-      color="neutralTertiary"
       trim={false}
       UNSAFE_className={css({
+        color: tokenSchema.color.foreground.neutral,
         userSelect: 'none',
         whiteSpace: 'pre',
+
+        [`${segmentClassList.selector('editable')}[data-placeholder] ~ &`]: {
+          color: tokenSchema.color.foreground.neutralTertiary,
+        },
       })}
       data-testid={segment.type === 'literal' ? undefined : segment.type}
     >
@@ -45,50 +57,14 @@ function LiteralSegment({ segment }: { segment: DateSegment }) {
 function EditableSegment({ segment, state }: InputSegmentProps) {
   let ref = useRef<HTMLDivElement>(null);
   let { segmentProps } = useDateSegment(segment, state, ref);
+  let styleProps = useEditableSectionStyles(segment);
 
   return (
     <div
       {...segmentProps}
-      {...toDataAttributes({
-        placeholder: segment.isPlaceholder,
-        readonly: !segment.isEditable,
-      })}
+      {...styleProps}
+      style={{ ...styleProps.style, ...segmentProps.style }}
       ref={ref}
-      className={css({
-        borderRadius: tokenSchema.size.radius.small,
-        color: tokenSchema.color.foreground.neutral,
-        paddingInline: tokenSchema.size.space.xsmall,
-
-        // text styles
-        fontFamily: tokenSchema.typography.fontFamily.base,
-        fontSize: tokenSchema.typography.text.regular.size,
-        fontVariantNumeric: 'tabular-nums',
-        fontWeight: tokenSchema.typography.fontWeight.regular,
-        lineHeight: tokenSchema.typography.lineheight.small,
-        whiteSpace: 'nowrap',
-        MozOsxFontSmoothing: 'auto',
-        WebkitFontSmoothing: 'auto',
-
-        '[dir=ltr] &': { textAlign: 'right' },
-        '[dir=rtl] &': { textAlign: 'left' },
-
-        '&[data-placeholder=true]': {
-          color: tokenSchema.color.foreground.neutralTertiary,
-        },
-
-        '&:focus': {
-          backgroundColor: tokenSchema.color.background.accentEmphasis,
-          color: tokenSchema.color.foreground.onEmphasis,
-          outline: 'none',
-        },
-      })}
-      style={{
-        ...segmentProps.style,
-        minWidth:
-          segment.maxValue != null
-            ? String(segment.maxValue).length + 'ch'
-            : undefined,
-      }}
       data-testid={segment.type}
     >
       <span
@@ -115,3 +91,49 @@ function EditableSegment({ segment, state }: InputSegmentProps) {
     </div>
   );
 }
+
+function useEditableSectionStyles(segment: DateSegment) {
+  return {
+    ...toDataAttributes(
+      { placeholder: segment.isPlaceholder, readonly: !segment.isEditable },
+      { omitFalsyValues: true }
+    ),
+    className: classNames(
+      css({
+        borderRadius: tokenSchema.size.radius.small,
+        color: tokenSchema.color.foreground.neutral,
+        paddingInline: tokenSchema.size.space.xsmall,
+
+        // text styles
+        fontFamily: tokenSchema.typography.fontFamily.base,
+        fontSize: tokenSchema.typography.text.regular.size,
+        fontVariantNumeric: 'tabular-nums',
+        fontWeight: tokenSchema.typography.fontWeight.regular,
+        lineHeight: tokenSchema.typography.lineheight.small,
+        whiteSpace: 'nowrap',
+        MozOsxFontSmoothing: 'auto',
+        WebkitFontSmoothing: 'auto',
+
+        '[dir=ltr] &': { textAlign: 'right' },
+        '[dir=rtl] &': { textAlign: 'left' },
+
+        '&[data-placeholder]': {
+          color: tokenSchema.color.foreground.neutralTertiary,
+        },
+
+        '&:focus': {
+          backgroundColor: tokenSchema.color.background.accentEmphasis,
+          color: tokenSchema.color.foreground.onEmphasis,
+          outline: 'none',
+        },
+      }),
+      segmentClassList.declare('editable')
+    ),
+    style: {
+      minWidth:
+        segment.maxValue != null
+          ? String(segment.maxValue).length + 'ch'
+          : undefined,
+    },
+  };
+}
diff --git a/design-system/pkg/src/date-time/docs/date-picker.md b/design-system/pkg/src/date-time/docs/date-picker.md
new file mode 100644
index 00000000..ec4caadf
--- /dev/null
+++ b/design-system/pkg/src/date-time/docs/date-picker.md
@@ -0,0 +1,431 @@
+---
+title: DatePicker
+description:
+  DatePickers combine a DateField and a Calendar popover to allow users to enter
+  or select a date and time value.
+category: Date and time
+---
+
+## Example
+
+```jsx {% live=true %}
+<DatePicker label="Date" />
+```
+
+## Patterns
+
+### Value
+
+A `DatePicker` displays a placeholder by default. An initial, uncontrolled value
+can be provided to the `DatePicker` using the `defaultValue` prop.
+Alternatively, a controlled value can be provided using the `value` prop.
+
+Date values are provided using objects from the
+[@internationalized/date](https://react-spectrum.adobe.com/internationalized/date/)
+package. This library handles correct international date manipulation across
+calendars, time zones, and other localization concerns. `DatePicker` supports
+values of the following types:
+
+- [CalendarDate](https://react-spectrum.adobe.com/internationalized/date/CalendarDate.html)
+  – a date without any time components. May be parsed from a string
+  representation using the `parseDate` function. Use this type to represent
+  dates where the time is not important, such as a birthday or an all day
+  calendar event.
+- [CalendarDateTime](https://react-spectrum.adobe.com/internationalized/date/CalendarDateTime.html)
+  – a date with a time, but not in any specific time zone. May be parsed from a
+  string representation using the `parseDateTime` function. Use this type to
+  represent times that occur at the same local time regardless of the time zone,
+  such as the time of New Years Eve fireworks which always occur at midnight.
+  Most times are better stored as a `ZonedDateTime`.
+- [ZonedDateTime](https://react-spectrum.adobe.com/internationalized/date/ZonedDateTime.html)
+  – a date with a time in a specific time zone. May be parsed from a string
+  representation using the `parseZonedDateTime`, `parseAbsolute`, or
+  `parseAbsoluteToLocal` functions. Use this type to represent an exact moment
+  in time at a particular location on Earth.
+
+```jsx {% live=true %}
+// import { parseDate } from '@internationalized/date';
+
+let [value, setValue] = React.useState(parseDate('2023-04-14'));
+
+return (
+  <Flex gap="large" wrap>
+    <DatePicker
+      label="Date (uncontrolled)"
+      defaultValue={parseDate('2023-04-14')}
+    />
+    <DatePicker label="Date (controlled)" value={value} onChange={setValue} />
+  </Flex>
+);
+```
+
+### Time zones
+
+`DatePicker` is time zone aware when a
+[ZonedDateTime](https://react-spectrum.adobe.com/internationalized/date/ZonedDateTime.html)
+object is provided as the value. In this case, the time zone abbreviation is
+displayed, and time zone concerns such as daylight saving time are taken into
+account when the value is manipulated.
+
+In most cases, your data will come from and be sent to a server as an
+[ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) formatted string.
+[@internationalized/date](https://react-spectrum.adobe.com/internationalized/date)
+includes functions for parsing strings in multiple formats into `ZonedDateTime`
+objects. Which format you use will depend on what information you need to store.
+
+- `parseZonedDateTime` – This function parses a date with an explicit time zone
+  and optional UTC offset attached (e.g. `"2023-04-14T00:45[Australia/Sydney]"`
+  or `"2023-04-14T00:45+10:00[Australia/Sydney]"`). This format preserves the
+  maximum amount of information. If the exact local time and time zone that a
+  user selected is important, use this format. Storing the time zone and offset
+  that was selected rather than converting to UTC ensures that the local time is
+  correct regardless of daylight saving rule changes (e.g. if a locale abolishes
+  DST). Examples where this applies include calendar events, reminders, and
+  other times that occur in a particular location.
+- `parseAbsolute` – This function parses an absolute date and time that occurs
+  at the same instant at all locations on Earth. It can be represented in UTC
+  (e.g. `"2023-04-14T07:45:00Z"`), or stored with a particular offset (e.g.
+  `"2023-04-14T07:45:00+10:00"`). A time zone identifier, e.g.
+  `Australia/Sydney`, must be passed, and the result will be converted into that
+  time zone. Absolute times are the best way to represent events that occurred
+  in the past, or future events where an exact time is needed, regardless of
+  time zone.
+- `parseAbsoluteToLocal` – This function parses an absolute date and time into
+  the current user's local time zone. It is a shortcut for `parseAbsolute`, and
+  accepts the same formats.
+
+```jsx {% live=true %}
+// import { parseZonedDateTime } from '@internationalized/date';
+
+return (
+  <DatePicker
+    label="Event date"
+    defaultValue={parseZonedDateTime('2023-04-14T07:45[Australia/Sydney]')}
+  />
+);
+```
+
+`DatePicker` displays times in the time zone included in the `ZonedDateTime`
+object. The above example is always displayed in Australian Eastern Standard
+Time because the `Australia/Sydney` time zone identifier is provided.
+[@internationalized/date](https://react-spectrum.adobe.com/internationalized/date/)
+includes functions for converting dates between time zones, or parsing a date
+directly into a specific time zone or the user's local time zone, as shown
+below.
+
+```jsx {% live=true %}
+// import { parseAbsoluteToLocal } from '@internationalized/date';
+
+return (
+  <DatePicker
+    label="Event date"
+    defaultValue={parseAbsoluteToLocal('2023-04-14T07:45:00Z')}
+  />
+);
+```
+
+### Granularity
+
+The `granularity` prop allows you to control the smallest unit that is displayed
+by a `DatePicker`. By default, `CalendarDate` values are displayed with "day"
+granularity (year, month, and day), and `CalendarDateTime` and `ZonedDateTime`
+values are displayed with "minute" granularity. More granular time values can be
+displayed by setting the granularity prop to "second".
+
+In addition, when a value with a time is provided but you wish to only display
+the date, you can set the granularity to `"day"`. This has no effect on the
+actual value (it still has a time component), only on what fields are displayed.
+In the following example, two DatePickers are synchronized with the same value,
+but display different granularities.
+
+```jsx {% live=true %}
+let [date, setDate] = React.useState(
+  parseAbsoluteToLocal('2023-04-14T18:45:22Z')
+);
+
+return (
+  <Flex gap="large" wrap>
+    <DatePicker
+      label="Date and time"
+      granularity="second"
+      value={date}
+      onChange={setDate}
+    />
+    <DatePicker
+      label="Date"
+      granularity="day"
+      value={date}
+      onChange={setDate}
+    />
+  </Flex>
+);
+```
+
+If no `value` or `defaultValue` prop is passed, then the `granularity` prop also
+affects which type of value is emitted from the `onChange` event. Note that by
+default, time values will not have a time zone because none was supplied. You
+can override this by setting the `placeholderValue` prop explicitly. Values
+emitted from `onChange` will use the time zone of the placeholder value.
+
+```jsx {% live=true %}
+// import { now } from '@internationalized/date';
+
+return (
+  <Flex gap="large" wrap>
+    <DatePicker label="Event date" granularity="second" />
+    <DatePicker
+      label="Event date"
+      placeholderValue={now('America/New_York')}
+      granularity="second"
+    />
+  </Flex>
+);
+```
+
+### International calendars
+
+DatePicker supports selecting dates in many calendar systems used around the
+world, including Gregorian, Hebrew, Indian, Islamic, Buddhist, and more. Dates
+are automatically displayed in the appropriate calendar system for the user's
+locale. The calendar system can be overridden using the
+[Unicode calendar locale extension](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/calendar#adding_a_calendar_in_the_locale_string),
+passed to the `Provider` component.
+
+Selected dates passed to `onChange` always use the same calendar system as the
+`value` or `defaultValue` prop. If no `value` or `defaultValue` is provided,
+then dates passed to `onChange` are always in the Gregorian calendar since this
+is the most commonly used. This means that even though the user selects dates in
+their local calendar system, applications are able to deal with dates from all
+users consistently.
+
+The below example displays a `DatePicker` in the Hindi language, using the
+Indian calendar. Dates emitted from `onChange` are in the Gregorian calendar.
+
+```jsx {% live=true %}
+let [date, setDate] = React.useState(null);
+
+return (
+  <VoussoirProvider locale="hi-IN-u-ca-indian">
+    <VStack gap="large">
+      <DatePicker label="Date" value={date} onChange={setDate} />
+      <Text>Selected date: {date?.toString()}</Text>
+    </VStack>
+  </VoussoirProvider>
+);
+```
+
+### Formatting
+
+`DatePicker` accepts an `onChange` prop which is triggered whenever the date is
+edited by the user. The example below uses `onChange` to update a separate
+element with a formatted version of the date in the user's locale and local time
+zone. This is done by converting the date to a native JavaScript `Date` object
+to pass to the formatter.
+
+```jsx {% live=true %}
+// import { getLocalTimeZone } from '@internationalized/date';
+// import { useDateFormatter } from '@react-aria/i18n';
+
+let [date, setDate] = React.useState(parseDate('1987-04-23'));
+let formatter = useDateFormatter({ dateStyle: 'full' });
+
+return (
+  <VStack gap="large">
+    <DatePicker label="Date" value={date} onChange={setDate} />
+    <Text>
+      Selected date:{' '}
+      {date ? formatter.format(date.toDate(getLocalTimeZone())) : '--'}
+    </Text>
+  </VStack>
+);
+```
+
+### HTML Forms
+
+`DatePicker` supports the `name` prop for integration with HTML forms. The value
+will be submitted to the server as an
+[ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) formatted string according to
+the granularity of the value. For example, if the date picker allows selecting
+only a date then a string such as `"2023-04-14"` will be submitted, and if it
+allows selecting a time then a string such as `"2023-04-14T08:45:00"` will be
+submitted. See the [Value](#value) section above for more details about the
+supported value types.
+
+```jsx {% live=true %}
+<DatePicker label="Date" name="date" />
+```
+
+## Props
+
+### Disabled
+
+A date field can be disabled by setting the `isDisabled` prop. This can be used
+to maintain layout continuity and communicate that a field may become available
+later.
+
+```jsx {% live=true %}
+<DatePicker label="Date" isDisabled />
+```
+
+### Read only
+
+The `isReadOnly` prop makes the field's value immutable. Unlike `isDisabled`,
+the field remains focusable.
+
+```jsx {% live=true %}
+<DatePicker label="Date" value={today(getLocalTimeZone())} isReadOnly />
+```
+
+### Description
+
+The `description` communicates a hint or helpful information, such as specific
+requirements for correctly filling out the field.
+
+```jsx {% live=true %}
+<DatePicker
+  label="Publish date"
+  description="The post will visible from this date."
+/>
+```
+
+#### Help text
+
+`DatePicker` also supports displaying the expected date format for the user's
+locale automatically using the `showFormatHelpText` prop.
+
+```jsx {% live=true %}
+<DatePicker label="Date" showFormatHelpText />
+```
+
+### Error message
+
+The `errorMessage` communicates validation errors when field requirements aren’t
+met. Prompting the user to adjust their input.
+
+```jsx {% live=true %}
+// import { today, isWeekend } from '@internationalized/date';
+// import { useLocale } from '@react-aria/i18n';
+
+let [date, setDate] = React.useState(today(getLocalTimeZone()));
+let { locale } = useLocale();
+
+return (
+  <DatePicker
+    label="Appointment date"
+    value={date}
+    onChange={setDate}
+    errorMessage={isWeekend(date, locale) && 'We are closed on weekends.'}
+    description="Select a weekday."
+  />
+);
+```
+
+### Minimum and maximum values
+
+The `minValue` and `maxValue` props can also be used to perform builtin
+validation. This displays an invalid state if the user enters an invalid date.
+The example below only accepts dates after today.
+
+```jsx {% live=true %}
+let minValue = today(getLocalTimeZone());
+
+return (
+  <DatePicker
+    label="Appointment date"
+    minValue={minValue}
+    maxValue={minValue.add({ weeks: 6 })}
+    defaultValue={minValue.subtract({ weeks: 1 })}
+  />
+);
+```
+
+### Unavailable dates
+
+`DatePicker` supports marking certain dates as _unavailable_. These dates cannot
+be selected by the user and are displayed with a crossed out appearance in the
+calendar. In the date field, an invalid state is displayed if a user enters an
+unavailable date. The `isDateUnavailable` prop accepts a callback that is called
+to evaluate whether a date is unavailable.
+
+This example includes multiple unavailable date ranges, e.g. dates when no
+appointments are available. In addition, all weekends are unavailable. The
+`minValue` prop is also used to prevent selecting dates before today.
+
+```jsx {% live=true %}
+// import { isWeekend, today } from '@internationalized/date';
+// import { useLocale } from '@react-aria/i18n';
+
+let { locale } = useLocale();
+
+let dateToday = today(getLocalTimeZone());
+
+let disabledRanges = [
+  [dateToday, dateToday.add({ days: 5 })],
+  [dateToday.add({ days: 14 }), dateToday.add({ days: 16 })],
+  [dateToday.add({ days: 23 }), dateToday.add({ days: 24 })],
+];
+
+let isDateUnavailable = date =>
+  isWeekend(date, locale) ||
+  disabledRanges.some(
+    interval => date.compare(interval[0]) >= 0 && date.compare(interval[1]) <= 0
+  );
+
+return (
+  <DatePicker
+    label="Appointment date"
+    minValue={today(getLocalTimeZone())}
+    isDateUnavailable={isDateUnavailable}
+  />
+);
+```
+
+### Placeholder value
+
+When no value is set, a placeholder is shown. The format of the placeholder is
+influenced by the `granularity` and `placeholderValue` props. `placeholderValue`
+also controls the default values of each segment when the user first interacts
+with them, e.g. using the up and down arrow keys. By default, the
+`placeholderValue` is the current date at midnight, but you can set it to a more
+appropriate value if needed.
+
+```jsx {% live=true %}
+// import { CalendarDate } from '@internationalized/date';
+
+return (
+  <DatePicker
+    label="Birth date"
+    placeholderValue={new CalendarDate(1980, 1, 1)}
+  />
+);
+```
+
+### Hide time zone
+
+When a `ZonedDateTime` object is provided as the value of a `DatePicker`, the
+time zone abbreviation is displayed by default. However, if this is displayed
+elsewhere or implicit based on the usecase, it can be hidden using the
+`hideTimeZone` prop.
+
+```jsx {% live=true %}
+// import { parseZonedDateTime } from '@internationalized/date';
+
+return (
+  <DatePicker
+    label="Appointment time"
+    defaultValue={parseZonedDateTime('2023-04-14T10:45[America/Los_Angeles]')}
+    hideTimeZone
+  />
+);
+```
+
+### Hour cycle
+
+By default, `DatePicker` displays times in either 12 or 24 hour hour format
+depending on the user's locale. However, this can be overridden using the
+`hourCycle` prop if needed for a specific usecase. This example forces the
+`DatePicker` to use 24-hour time, regardless of the locale.
+
+```jsx {% live=true %}
+<DatePicker label="Appointment time" granularity="minute" hourCycle={24} />
+```
diff --git a/design-system/pkg/src/date-time/docs/date-range-picker.md b/design-system/pkg/src/date-time/docs/date-range-picker.md
new file mode 100644
index 00000000..8fe61311
--- /dev/null
+++ b/design-system/pkg/src/date-time/docs/date-range-picker.md
@@ -0,0 +1,480 @@
+---
+title: DateRangePicker
+description:
+  DateRangePickers combine two DateFields and a Calendar popover to allow users
+  to enter or select a date and time range.
+
+category: Date and time
+---
+
+## Example
+
+```jsx {% live=true %}
+<DateRangePicker label="Date range" />
+```
+
+## Patterns
+
+### Value
+
+A `DateRangePicker` displays a placeholder by default. An initial, uncontrolled
+value can be provided to the `DateRangePicker` using the `defaultValue` prop.
+Alternatively, a controlled value can be provided using the `value` prop.
+
+Date values are provided using objects from the
+[@internationalized/date](https://react-spectrum.adobe.com/internationalized/date/)
+package. This library handles correct international date manipulation across
+calendars, time zones, and other localization concerns. `DateRangePicker`
+supports values of the following types:
+
+- [CalendarDate](https://react-spectrum.adobe.com/internationalized/date/CalendarDate.html)
+  – a date without any time components. May be parsed from a string
+  representation using the `parseDate` function. Use this type to represent
+  dates where the time is not important, such as a birthday or an all day
+  calendar event.
+- [CalendarDateTime](https://react-spectrum.adobe.com/internationalized/date/CalendarDateTime.html)
+  – a date with a time, but not in any specific time zone. May be parsed from a
+  string representation using the `parseDateTime` function. Use this type to
+  represent times that occur at the same local time regardless of the time zone,
+  such as the time of New Years Eve fireworks which always occur at midnight.
+  Most times are better stored as a `ZonedDateTime`.
+- [ZonedDateTime](https://react-spectrum.adobe.com/internationalized/date/ZonedDateTime.html)
+  – a date with a time in a specific time zone. May be parsed from a string
+  representation using the `parseZonedDateTime`, `parseAbsolute`, or
+  `parseAbsoluteToLocal` functions. Use this type to represent an exact moment
+  in time at a particular location on Earth.
+
+```jsx {% live=true %}
+// import { parseDate } from '@internationalized/date';
+
+let [value, setValue] = React.useState({
+  start: parseDate('2023-04-14'),
+  end: parseDate('2023-04-19'),
+});
+
+return (
+  <Flex gap="large" wrap>
+    <DateRangePicker
+      label="Date (uncontrolled)"
+      defaultValue={{
+        start: parseDate('2023-04-14'),
+        end: parseDate('2023-04-19'),
+      }}
+    />
+    <DateRangePicker
+      label="Date (controlled)"
+      value={value}
+      onChange={setValue}
+    />
+  </Flex>
+);
+```
+
+### Time zones
+
+`DateRangePicker` is time zone aware when a
+[ZonedDateTime](https://react-spectrum.adobe.com/internationalized/date/ZonedDateTime.html)
+object is provided as the value. In this case, the time zone abbreviation is
+displayed, and time zone concerns such as daylight saving time are taken into
+account when the value is manipulated.
+
+In most cases, your data will come from and be sent to a server as an
+[ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) formatted string.
+[@internationalized/date](https://react-spectrum.adobe.com/internationalized/date)
+includes functions for parsing strings in multiple formats into `ZonedDateTime`
+objects. Which format you use will depend on what information you need to store.
+
+- `parseZonedDateTime` – This function parses a date with an explicit time zone
+  and optional UTC offset attached (e.g. `"2023-04-14T00:45[Australia/Sydney]"`
+  or `"2023-04-14T00:45+10:00[Australia/Sydney]"`). This format preserves the
+  maximum amount of information. If the exact local time and time zone that a
+  user selected is important, use this format. Storing the time zone and offset
+  that was selected rather than converting to UTC ensures that the local time is
+  correct regardless of daylight saving rule changes (e.g. if a locale abolishes
+  DST). Examples where this applies include calendar events, reminders, and
+  other times that occur in a particular location.
+- `parseAbsolute` – This function parses an absolute date and time that occurs
+  at the same instant at all locations on Earth. It can be represented in UTC
+  (e.g. `"2023-04-14T07:45:00Z"`), or stored with a particular offset (e.g.
+  `"2023-04-14T07:45:00+10:00"`). A time zone identifier, e.g.
+  `Australia/Sydney`, must be passed, and the result will be converted into that
+  time zone. Absolute times are the best way to represent events that occurred
+  in the past, or future events where an exact time is needed, regardless of
+  time zone.
+- `parseAbsoluteToLocal` – This function parses an absolute date and time into
+  the current user's local time zone. It is a shortcut for `parseAbsolute`, and
+  accepts the same formats.
+
+```jsx {% live=true %}
+// import { parseZonedDateTime } from '@internationalized/date';
+
+return (
+  <DateRangePicker
+    label="Date range"
+    defaultValue={{
+      start: parseZonedDateTime('2023-04-14T07:45[Australia/Sydney]'),
+      end: parseZonedDateTime('2023-04-19T07:45[Australia/Sydney]'),
+    }}
+  />
+);
+```
+
+`DateRangePicker` displays times in the time zone included in the
+`ZonedDateTime` object. The above example is always displayed in Australian
+Eastern Standard Time because the `Australia/Sydney` time zone identifier is
+provided.
+[@internationalized/date](https://react-spectrum.adobe.com/internationalized/date/)
+includes functions for converting dates between time zones, or parsing a date
+directly into a specific time zone or the user's local time zone, as shown
+below.
+
+```jsx {% live=true %}
+// import { parseAbsoluteToLocal } from '@internationalized/date';
+
+return (
+  <DateRangePicker
+    label="Date range"
+    defaultValue={{
+      start: parseAbsoluteToLocal('2023-04-14T07:45:00Z'),
+      end: parseAbsoluteToLocal('2023-04-19T07:45:00Z'),
+    }}
+  />
+);
+```
+
+### Granularity
+
+The `granularity` prop allows you to control the smallest unit that is displayed
+by a `DateRangePicker`. By default, `CalendarDate` values are displayed with
+"day" granularity (year, month, and day), and `CalendarDateTime` and
+`ZonedDateTime` values are displayed with "minute" granularity. More granular
+time values can be displayed by setting the granularity prop to "second".
+
+In addition, when a value with a time is provided but you wish to only display
+the date, you can set the granularity to `"day"`. This has no effect on the
+actual value (it still has a time component), only on what fields are displayed.
+In the following example, two DateRangePickers are synchronized with the same
+value, but display different granularities.
+
+```jsx {% live=true %}
+let [range, setRange] = React.useState({
+  start: parseAbsoluteToLocal('2023-04-14T18:45:22Z'),
+  end: parseAbsoluteToLocal('2023-04-19T18:45:22Z'),
+});
+
+return (
+  <Flex gap="large" wrap>
+    <DateRangePicker
+      label="Date and time range"
+      granularity="second"
+      value={range}
+      onChange={setRange}
+    />
+    <DateRangePicker
+      label="Date range"
+      granularity="day"
+      value={range}
+      onChange={setRange}
+    />
+  </Flex>
+);
+```
+
+If no `value` or `defaultValue` prop is passed, then the `granularity` prop also
+affects which type of value is emitted from the `onChange` event. Note that by
+default, time values will not have a time zone because none was supplied. You
+can override this by setting the `placeholderValue` prop explicitly. Values
+emitted from `onChange` will use the time zone of the placeholder value.
+
+```jsx {% live=true %}
+// import { now } from '@internationalized/date';
+
+return (
+  <Flex gap="large" wrap>
+    <DateRangePicker label="Date range" granularity="second" />
+    <DateRangePicker
+      label="Date range"
+      placeholderValue={now('America/New_York')}
+      granularity="second"
+    />
+  </Flex>
+);
+```
+
+### International calendars
+
+DateRangePicker supports selecting dates in many calendar systems used around
+the world, including Gregorian, Hebrew, Indian, Islamic, Buddhist, and more.
+Dates are automatically displayed in the appropriate calendar system for the
+user's locale. The calendar system can be overridden using the
+[Unicode calendar locale extension](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/calendar#adding_a_calendar_in_the_locale_string),
+passed to the `Provider` component.
+
+Selected dates passed to `onChange` always use the same calendar system as the
+`value` or `defaultValue` prop. If no `value` or `defaultValue` is provided,
+then dates passed to `onChange` are always in the Gregorian calendar since this
+is the most commonly used. This means that even though the user selects dates in
+their local calendar system, applications are able to deal with dates from all
+users consistently.
+
+The below example displays a `DateRangePicker` in the Hindi language, using the
+Indian calendar. Dates emitted from `onChange` are in the Gregorian calendar.
+
+```jsx {% live=true %}
+let [range, setRange] = React.useState(null);
+
+return (
+  <VoussoirProvider locale="hi-IN-u-ca-indian">
+    <VStack gap="large">
+      <DateRangePicker label="Date range" value={range} onChange={setRange} />
+      <Text>Selected range: {range?.toString()}</Text>
+    </VStack>
+  </VoussoirProvider>
+);
+```
+
+### HTML Forms
+
+`DateRangePicker` supports the `startName` and `endName` props for integration
+with HTML forms. The values will be submitted to the server as
+[ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) formatted strings according
+to the granularity of the value. For example, if the date range picker allows
+selecting only dates then strings such as `"2023-04-14"` will be submitted, and
+if it allows selecting times then strings such as `"2023-04-14T08:45:00"` will
+be submitted. See the [Value](#value) section above for more details about the
+supported value types.
+
+```jsx {% live=true %}
+<DateRangePicker label="Trip dates" startName="startDate" endName="endDate" />
+```
+
+## Props
+
+### Disabled
+
+A date field can be disabled by setting the `isDisabled` prop. This can be used
+to maintain layout continuity and communicate that a field may become available
+later.
+
+```jsx {% live=true %}
+<DateRangePicker label="Date range" isDisabled />
+```
+
+### Read only
+
+The `isReadOnly` prop makes the field's value immutable. Unlike `isDisabled`,
+the field remains focusable.
+
+```jsx {% live=true %}
+<DateRangePicker
+  label="Date range"
+  value={{
+    start: today(getLocalTimeZone()),
+    end: today(getLocalTimeZone()).add({ weeks: 1 }),
+  }}
+  isReadOnly
+/>
+```
+
+### Description
+
+The `description` communicates a hint or helpful information, such as specific
+requirements for correctly filling out the field.
+
+```jsx {% live=true %}
+<DateRangePicker
+  label="Event dates"
+  description="The event will be between these two dates."
+/>
+```
+
+#### Help text
+
+`DateRangePicker` also supports displaying the expected date format for the
+user's locale automatically using the `showFormatHelpText` prop.
+
+```jsx {% live=true %}
+<DateRangePicker label="Date range" showFormatHelpText />
+```
+
+### Error message
+
+The `errorMessage` communicates validation errors when field requirements aren’t
+met. Prompting the user to adjust their input.
+
+```jsx {% live=true %}
+<DateRangePicker
+  label="Trip dates"
+  value={null}
+  errorMessage="This field is required."
+  isRequired
+/>
+```
+
+### Minimum and maximum values
+
+The `minValue` and `maxValue` props can also be used to perform builtin
+validation. This prevents the user from selecting dates outside the valid range
+in the calendar, and displays an invalid state if the user enters an invalid
+date into the date field.
+
+This example only accepts dates after today.
+
+```jsx {% live=true %}
+let minValue = today(getLocalTimeZone());
+
+return (
+  <DateRangePicker
+    label="Trip dates"
+    minValue={minValue}
+    maxValue={minValue.add({ weeks: 6 })}
+    defaultValue={{
+      start: minValue.subtract({ weeks: 1 }),
+      end: minValue.subtract({ weeks: 2 }),
+    }}
+  />
+);
+```
+
+### Unavailable dates
+
+`DatePicker` supports marking certain dates as _unavailable_. These dates cannot
+be selected by the user and are displayed with a crossed out appearance in the
+calendar. In the date field, an invalid state is displayed if a user enters an
+unavailable date. The `isDateUnavailable` prop accepts a callback that is called
+to evaluate whether a date is unavailable.
+
+This example includes multiple unavailable date ranges, e.g. dates when no
+appointments are available. In addition, all weekends are unavailable. The
+`minValue` prop is also used to prevent selecting dates before today.
+
+```jsx {% live=true %}
+// import { isWeekend, today } from '@internationalized/date';
+// import { useLocale } from '@react-aria/i18n';
+
+let { locale } = useLocale();
+
+let dateToday = today(getLocalTimeZone());
+
+let disabledRanges = [
+  [dateToday, dateToday.add({ days: 5 })],
+  [dateToday.add({ days: 14 }), dateToday.add({ days: 16 })],
+  [dateToday.add({ days: 23 }), dateToday.add({ days: 24 })],
+];
+
+let isDateUnavailable = date =>
+  isWeekend(date, locale) ||
+  disabledRanges.some(
+    interval => date.compare(interval[0]) >= 0 && date.compare(interval[1]) <= 0
+  );
+
+return (
+  <DateRangePicker
+    label="Trip dates"
+    minValue={today(getLocalTimeZone())}
+    isDateUnavailable={isDateUnavailable}
+  />
+);
+```
+
+#### Non-contiguous ranges
+
+The `allowsNonContiguousRanges` prop enables a range to be selected even if
+there are unavailable dates in the middle. The value emitted in the `onChange`
+event will still be a single range with a `start` and `end` property, but
+unavailable dates will not be displayed as selected. It is up to the consumer to
+split the full selected range into multiple as needed for business logic.
+
+This example prevents selecting weekends, but allows selecting ranges that span
+multiple weeks.
+
+```jsx {% live=true %}
+// import { isWeekend } from '@internationalized/date';
+// import { useLocale } from '@react-aria/i18n';
+
+let { locale } = useLocale();
+
+return (
+  <DateRangePicker
+    label="Workshop dates"
+    isDateUnavailable={date => isWeekend(date, locale)}
+    allowsNonContiguousRanges
+  />
+);
+```
+
+### Placeholder value
+
+When no value is set, a placeholder is shown. The format of the placeholder is
+influenced by the `granularity` and `placeholderValue` props. `placeholderValue`
+also controls the default values of each segment when the user first interacts
+with them, e.g. using the up and down arrow keys. By default, the
+`placeholderValue` is the current date at midnight, but you can set it to a more
+appropriate value if needed.
+
+```jsx {% live=true %}
+// import { CalendarDate } from '@internationalized/date';
+
+return (
+  <DateRangePicker
+    label="Date range"
+    placeholderValue={new CalendarDate(1980, 1, 1)}
+  />
+);
+```
+
+### Maximum visible months
+
+By default, the calendar popover displays a single month. The `maxVisibleMonths`
+prop allows displaying up to 3 months at a time, if screen space permits.
+
+```jsx {% live=true %}
+<DateRangePicker label="Date range" maxVisibleMonths={2} />
+```
+
+#### Page behavior
+
+By default, when pressing the next or previous buttons, pagination will advance
+by the `maxVisibleMonths` value. This behavior can be changed to page by single
+months instead, by setting `pageBehavior` to single.
+
+```jsx {% live=true %}
+<DateRangePicker
+  label="Date range"
+  maxVisibleMonths={2}
+  pageBehavior="single"
+/>
+```
+
+### Hide time zone
+
+When a `ZonedDateTime` object is provided as the value of a `DateRangePicker`,
+the time zone abbreviation is displayed by default. However, if this is
+displayed elsewhere or implicit based on the usecase, it can be hidden using the
+`hideTimeZone` prop.
+
+```jsx {% live=true %}
+// import { parseZonedDateTime } from '@internationalized/date';
+
+return (
+  <DateRangePicker
+    label="Date range"
+    defaultValue={{
+      start: parseZonedDateTime('2023-04-14T07:45[Australia/Sydney]'),
+      end: parseZonedDateTime('2023-04-19T07:45[Australia/Sydney]'),
+    }}
+    hideTimeZone
+  />
+);
+```
+
+### Hour cycle
+
+By default, `DateRangePicker` displays times in either 12 or 24 hour hour format
+depending on the user's locale. However, this can be overridden using the
+`hourCycle` prop if needed for a specific usecase. This example forces the
+`DateRangePicker` to use 24-hour time, regardless of the locale.
+
+```jsx {% live=true %}
+<DateRangePicker label="Date range" granularity="minute" hourCycle={24} />
+```
diff --git a/design-system/pkg/src/date-time/index.ts b/design-system/pkg/src/date-time/index.ts
index 033a5416..9c926bc3 100644
--- a/design-system/pkg/src/date-time/index.ts
+++ b/design-system/pkg/src/date-time/index.ts
@@ -1,6 +1,13 @@
 'use client';
 
 export { DateField } from './DateField';
+export { DatePicker } from './DatePicker';
+export { DateRangePicker } from './DateRangePicker';
 export { TimeField } from './TimeField';
 
-export type { DateFieldProps, TimeFieldProps } from './types';
+export type {
+  DateFieldProps,
+  DatePickerProps,
+  DateRangePickerProps,
+  TimeFieldProps,
+} from './types';
diff --git a/design-system/pkg/src/date-time/stories/DateField.stories.tsx b/design-system/pkg/src/date-time/stories/DateField.stories.tsx
index 8eacfc5d..15d3290b 100644
--- a/design-system/pkg/src/date-time/stories/DateField.stories.tsx
+++ b/design-system/pkg/src/date-time/stories/DateField.stories.tsx
@@ -161,6 +161,24 @@ Controls.argTypes = {
   hideTimeZone: {
     control: 'boolean',
   },
+  shouldForceLeadingZeros: {
+    control: 'boolean',
+  },
+  isDisabled: {
+    control: 'boolean',
+  },
+  isReadOnly: {
+    control: 'boolean',
+  },
+  isRequired: {
+    control: 'boolean',
+  },
+  description: {
+    control: 'text',
+  },
+  errorMessage: {
+    control: 'text',
+  },
 };
 
 function ControlledExample() {
diff --git a/design-system/pkg/src/date-time/stories/DatePicker.stories.tsx b/design-system/pkg/src/date-time/stories/DatePicker.stories.tsx
new file mode 100644
index 00000000..f41aa900
--- /dev/null
+++ b/design-system/pkg/src/date-time/stories/DatePicker.stories.tsx
@@ -0,0 +1,161 @@
+import {
+  CalendarDate,
+  CalendarDateTime,
+  DateValue,
+  getLocalTimeZone,
+  parseDate,
+  today,
+} from '@internationalized/date';
+import { Parameters, StoryObj, action } from '@keystar/ui-storybook';
+import React from 'react';
+
+import { DatePicker } from '../index';
+
+type DatePickerStory = StoryObj<typeof DatePicker>;
+
+export default {
+  title: 'Components/Date and Time/DatePicker',
+  component: DatePicker,
+  args: {
+    label: 'Date',
+    onChange: action('onChange'),
+  },
+  argTypes: {
+    defaultValue: { table: { disable: true } },
+    isDateUnavailable: { table: { disable: true } },
+    maxValue: { table: { disable: true } },
+    minValue: { table: { disable: true } },
+    onBlur: { table: { disable: true } },
+    onChange: { table: { disable: true } },
+    onFocus: { table: { disable: true } },
+    onFocusChange: { table: { disable: true } },
+    onKeyDown: { table: { disable: true } },
+    onKeyUp: { table: { disable: true } },
+    onOpenChange: { table: { disable: true } },
+    placeholderValue: { table: { disable: true } },
+    value: { table: { disable: true } },
+    label: {
+      control: 'text',
+    },
+    granularity: {
+      control: 'select',
+      options: ['day', 'hour', 'minute', 'second'],
+    },
+    hourCycle: {
+      control: 'select',
+      options: [12, 24],
+    },
+    hideTimeZone: {
+      control: 'boolean',
+    },
+    shouldForceLeadingZeros: {
+      control: 'boolean',
+    },
+    isDisabled: {
+      control: 'boolean',
+    },
+    isReadOnly: {
+      control: 'boolean',
+    },
+    isRequired: {
+      control: 'boolean',
+    },
+    description: {
+      control: 'text',
+    },
+    errorMessage: {
+      control: 'text',
+    },
+    autoFocus: {
+      control: 'boolean',
+    },
+    showFormatHelpText: {
+      control: 'boolean',
+    },
+    'aria-label': {
+      control: 'text',
+    },
+    maxVisibleMonths: {
+      control: 'number',
+    },
+    shouldFlip: {
+      control: 'boolean',
+    },
+    defaultOpen: {
+      control: 'boolean',
+    },
+    isOpen: {
+      control: 'boolean',
+    },
+  },
+} as any;
+
+export const Default: DatePickerStory = render();
+
+export const DefaultValue: DatePickerStory = {
+  ...Default,
+  args: { defaultValue: parseDate('2023-08-25') },
+};
+
+export const MinMaxValue: DatePickerStory = {
+  ...Default,
+  args: {
+    minValue: today(getLocalTimeZone()),
+    maxValue: today(getLocalTimeZone()).add({ weeks: 2 }),
+  },
+  name: 'minValue + maxValue',
+};
+
+export const PlaceholderVal: DatePickerStory = {
+  ...Default,
+  args: { placeholderValue: new CalendarDate(1980, 1, 1) },
+  name: 'placeholder value: 1980/1/1',
+};
+
+export const PlaceholderValTime: DatePickerStory = {
+  ...Default,
+  args: { placeholderValue: new CalendarDateTime(1980, 1, 1, 8) },
+  name: 'placeholder value: 1980/1/1 8AM',
+};
+
+export const DateUnavailable: DatePickerStory = {
+  ...Default,
+  args: {
+    isDateUnavailable: (date: DateValue) => {
+      const disabledIntervals = [
+        [
+          today(getLocalTimeZone()),
+          today(getLocalTimeZone()).add({ weeks: 1 }),
+        ],
+        [
+          today(getLocalTimeZone()).add({ weeks: 2 }),
+          today(getLocalTimeZone()).add({ weeks: 3 }),
+        ],
+      ];
+      return disabledIntervals.some(
+        interval =>
+          date.compare(interval[0]) > 0 && date.compare(interval[1]) < 0
+      );
+    },
+  },
+  name: 'isDateUnavailable',
+};
+
+export const AllEvents: DatePickerStory = {
+  ...Default,
+  args: {
+    onBlur: action('onBlur'),
+    onFocus: action('onFocus'),
+    onFocusChange: action('onFocusChange'),
+    onKeyDown: action('onKeyDown'),
+    onKeyUp: action('onKeyUp'),
+    onOpenChange: action('onOpenChange'),
+  },
+  name: 'all the events',
+};
+
+function render(props = {}) {
+  return {
+    render: (args: Parameters) => <DatePicker {...args} {...props} />,
+  };
+}
diff --git a/design-system/pkg/src/date-time/stories/DateRangePicker.stories.tsx b/design-system/pkg/src/date-time/stories/DateRangePicker.stories.tsx
new file mode 100644
index 00000000..186df4cd
--- /dev/null
+++ b/design-system/pkg/src/date-time/stories/DateRangePicker.stories.tsx
@@ -0,0 +1,179 @@
+import {
+  CalendarDate,
+  CalendarDateTime,
+  DateValue,
+  getLocalTimeZone,
+  isWeekend,
+  parseDate,
+  today,
+} from '@internationalized/date';
+import { useLocale } from '@react-aria/i18n';
+import { Parameters, StoryObj, action } from '@keystar/ui-storybook';
+import React from 'react';
+
+import { DateRangePicker } from '../index';
+
+type DateRangePickerStory = StoryObj<typeof DateRangePicker>;
+
+export default {
+  title: 'Components/Date and Time/DateRangePicker',
+  component: DateRangePicker,
+  args: {
+    label: 'Date',
+    onChange: action('onChange'),
+  },
+  argTypes: {
+    defaultValue: { table: { disable: true } },
+    isDateUnavailable: { table: { disable: true } },
+    maxValue: { table: { disable: true } },
+    minValue: { table: { disable: true } },
+    onBlur: { table: { disable: true } },
+    onChange: { table: { disable: true } },
+    onFocus: { table: { disable: true } },
+    onFocusChange: { table: { disable: true } },
+    onKeyDown: { table: { disable: true } },
+    onKeyUp: { table: { disable: true } },
+    onOpenChange: { table: { disable: true } },
+    placeholderValue: { table: { disable: true } },
+    value: { table: { disable: true } },
+    label: {
+      control: 'text',
+    },
+    granularity: {
+      control: 'select',
+      options: ['day', 'hour', 'minute', 'second'],
+    },
+    hourCycle: {
+      control: 'select',
+      options: [12, 24],
+    },
+    hideTimeZone: {
+      control: 'boolean',
+    },
+    shouldForceLeadingZeros: {
+      control: 'boolean',
+    },
+    isDisabled: {
+      control: 'boolean',
+    },
+    isReadOnly: {
+      control: 'boolean',
+    },
+    isRequired: {
+      control: 'boolean',
+    },
+    description: {
+      control: 'text',
+    },
+    errorMessage: {
+      control: 'text',
+    },
+    autoFocus: {
+      control: 'boolean',
+    },
+    showFormatHelpText: {
+      control: 'boolean',
+    },
+    'aria-label': {
+      control: 'text',
+    },
+    maxVisibleMonths: {
+      control: 'number',
+    },
+    shouldFlip: {
+      control: 'boolean',
+    },
+    defaultOpen: {
+      control: 'boolean',
+    },
+    isOpen: {
+      control: 'boolean',
+    },
+  },
+} as any;
+
+export const Default: DateRangePickerStory = render();
+
+export const DefaultValue: DateRangePickerStory = {
+  ...Default,
+  args: {
+    defaultValue: {
+      start: parseDate('2023-08-25'),
+      end: parseDate('2023-08-30'),
+    },
+  },
+};
+
+export const MinMaxValue: DateRangePickerStory = {
+  ...Default,
+  args: {
+    minValue: today(getLocalTimeZone()),
+    maxValue: today(getLocalTimeZone()).add({ weeks: 2 }),
+  },
+  name: 'minValue + maxValue',
+};
+
+export const PlaceholderVal: DateRangePickerStory = {
+  ...Default,
+  args: { placeholderValue: new CalendarDate(1980, 1, 1) },
+  name: 'placeholder value: 1980/1/1',
+};
+
+export const PlaceholderValTime: DateRangePickerStory = {
+  ...Default,
+  args: { placeholderValue: new CalendarDateTime(1980, 1, 1, 8) },
+  name: 'placeholder value: 1980/1/1 8AM',
+};
+
+export const DateUnavailable: DateRangePickerStory = {
+  ...Default,
+  args: {
+    isDateUnavailable: (date: DateValue) => {
+      const disabledIntervals = [
+        [
+          today(getLocalTimeZone()),
+          today(getLocalTimeZone()).add({ weeks: 1 }),
+        ],
+        [
+          today(getLocalTimeZone()).add({ weeks: 2 }),
+          today(getLocalTimeZone()).add({ weeks: 3 }),
+        ],
+      ];
+      return disabledIntervals.some(
+        interval =>
+          date.compare(interval[0]) > 0 && date.compare(interval[1]) < 0
+      );
+    },
+  },
+  name: 'isDateUnavailable',
+};
+
+export const IsDateAvailableAllowsNonContiguousRanges = () => {
+  let { locale } = useLocale();
+  return render({
+    isDateUnavailable: (date: DateValue) => isWeekend(date, locale),
+    allowsNonContiguousRanges: true,
+  });
+};
+
+IsDateAvailableAllowsNonContiguousRanges.storyName =
+  'isDateAvailable, allowsNonContiguousRanges';
+
+export const AllEvents: DateRangePickerStory = {
+  ...Default,
+  args: {
+    onBlur: action('onBlur'),
+    onFocus: action('onFocus'),
+    onFocusChange: action('onFocusChange'),
+    onKeyDown: action('onKeyDown'),
+    onKeyUp: action('onKeyUp'),
+    onOpenChange: action('onOpenChange'),
+  },
+  name: 'all the events',
+};
+
+function render(props = {}) {
+  return {
+    render: (args: Parameters) => <DateRangePicker {...args} {...props} />,
+  };
+}
diff --git a/design-system/pkg/src/date-time/types.ts b/design-system/pkg/src/date-time/types.ts
index 5c8ebb58..8100526a 100644
--- a/design-system/pkg/src/date-time/types.ts
+++ b/design-system/pkg/src/date-time/types.ts
@@ -1,15 +1,17 @@
 import {
+  AriaDatePickerProps,
+  AriaDateRangePickerProps,
   DateValue,
   Granularity,
   MappedDateValue,
   MappedTimeValue,
   TimeValue,
 } from '@react-types/datepicker';
-import { FocusableProps, ValueBase } from '@react-types/shared';
+import { FocusableProps, RangeValue, ValueBase } from '@react-types/shared';
 
 import { FieldProps } from '@keystar/ui/field';
 
-export type DateFieldProps<T extends DateValue> = {
+type DateFieldBase<T extends DateValue> = {
   /** The minimum allowed date that a user may select. */
   minValue?: DateValue;
   /** The maximum allowed date that a user may select. */
@@ -27,7 +29,15 @@ export type DateFieldProps<T extends DateValue> = {
    * @default false
    */
   hideTimeZone?: boolean;
-} & FocusableProps &
+  /**
+   * Whether to always show leading zeros in the month, day, and hour fields.
+   * By default, this is determined by the user's locale.
+   */
+  shouldForceLeadingZeros?: boolean;
+};
+
+export type DateFieldProps<T extends DateValue> = DateFieldBase<T> &
+  FocusableProps &
   ValueBase<T | null, MappedDateValue<T>> &
   FieldProps;
 
@@ -38,7 +48,7 @@ export type TimeFieldProps<T extends TimeValue> = {
    * Determines the smallest unit that is displayed in the time picker.
    * @default 'minute'
    */
-  granularity?: 'hour' | 'minute' | 'second';
+  granularity?: Exclude<Granularity, 'day'>;
   /** Whether to hide the time zone abbreviation. */
   hideTimeZone?: boolean;
   /**
@@ -53,3 +63,26 @@ export type TimeFieldProps<T extends TimeValue> = {
 } & FocusableProps &
   ValueBase<T | null, MappedTimeValue<T>> &
   FieldProps;
+
+type DatePickerBase<T extends DateValue> = {
+  /**
+   * The maximum number of months to display at once in the calendar popover, if screen space permits.
+   * @default 1
+   */
+  maxVisibleMonths?: number;
+  /**
+   * Whether the calendar popover should automatically flip direction when space is limited.
+   * @default true
+   */
+  shouldFlip?: boolean;
+} & DateFieldBase<T> &
+  FocusableProps &
+  FieldProps;
+
+export type DatePickerProps<T extends DateValue> = DatePickerBase<T> &
+  AriaDatePickerProps<T> &
+  ValueBase<T | null, MappedDateValue<T>>;
+
+export type DateRangePickerProps<T extends DateValue> = DatePickerBase<T> &
+  AriaDateRangePickerProps<T> &
+  ValueBase<RangeValue<T> | null, RangeValue<MappedDateValue<T>>>;
diff --git a/design-system/pkg/src/date-time/utils.ts b/design-system/pkg/src/date-time/utils.ts
index e69e0697..b64ec1a8 100644
--- a/design-system/pkg/src/date-time/utils.ts
+++ b/design-system/pkg/src/date-time/utils.ts
@@ -1,11 +1,9 @@
+import { useDisplayNames } from '@react-aria/datepicker';
 import { createFocusManager } from '@react-aria/focus';
 import { useDateFormatter } from '@react-aria/i18n';
-import { useDisplayNames } from '@react-aria/datepicker';
-import { useLayoutEffect } from '@react-aria/utils';
+import { useLayoutEffect, useObjectRef } from '@react-aria/utils';
 import { SpectrumDatePickerBase } from '@react-types/datepicker';
-import { Ref, useImperativeHandle, useMemo, useRef, useState } from 'react';
-
-import { useProvider } from '@keystar/ui/core';
+import { Ref, useImperativeHandle, useMemo, useState } from 'react';
 
 export function useFormatHelpText(
   props: Pick<SpectrumDatePickerBase, 'description' | 'showFormatHelpText'>
@@ -25,7 +23,6 @@ export function useFormatHelpText(
             return s.value;
           }
 
-          // @ts-expect-error - not sure what's going on here
           return displayNames.of(s.type);
         })
         .join(' ');
@@ -36,37 +33,39 @@ export function useFormatHelpText(
 }
 
 export function useVisibleMonths(maxVisibleMonths: number) {
-  let { scale } = useProvider();
-  let [visibleMonths, setVisibleMonths] = useState(getVisibleMonths(scale));
+  let [visibleMonths, setVisibleMonths] = useState(getVisibleMonths());
   useLayoutEffect(() => {
-    let onResize = () => setVisibleMonths(getVisibleMonths(scale));
+    let onResize = () => setVisibleMonths(getVisibleMonths());
     onResize();
 
     window.addEventListener('resize', onResize);
     return () => {
       window.removeEventListener('resize', onResize);
     };
-  }, [scale]);
+  }, []);
 
   return Math.max(1, Math.min(visibleMonths, maxVisibleMonths, 3));
 }
 
-function getVisibleMonths(scale: 'large' | 'medium') {
+// these calculations are brittle, they depend on styling decisions in both:
+// - the `CalendarBase` component, from "@keystar/ui/calendar"
+// - the `DatePickerPopover` component
+function getVisibleMonths() {
   if (typeof window === 'undefined') {
     return 1;
   }
-  let monthWidth = scale === 'large' ? 336 : 280;
-  let gap = scale === 'large' ? 30 : 24;
-  let popoverPadding = scale === 'large' ? 32 : 48;
+  let monthWidth = 248;
+  let gap = 16;
+  let dialogPadding = 20;
   return Math.floor(
-    (window.innerWidth - popoverPadding * 2) / (monthWidth + gap)
+    (window.innerWidth - dialogPadding * 2) / (monthWidth + gap)
   );
 }
 
 export function useFocusManagerRef(ref: Ref<HTMLDivElement>) {
-  let domRef = useRef<HTMLDivElement>(null);
-  // @ts-expect-error - useImperativeHandle wants all properties from `ref`, wtf???
+  let domRef = useObjectRef<HTMLDivElement>(ref);
   useImperativeHandle(ref, () => ({
+    ...domRef.current,
     focus() {
       createFocusManager(domRef).focusFirst({ tabbable: true });
     },
diff --git a/design-system/pkg/src/field/FieldLabel.tsx b/design-system/pkg/src/field/FieldLabel.tsx
index 7d943908..9897f7c1 100644
--- a/design-system/pkg/src/field/FieldLabel.tsx
+++ b/design-system/pkg/src/field/FieldLabel.tsx
@@ -37,6 +37,7 @@ export const FieldLabel = forwardRefWithAs<FieldLabelProps, 'label'>(
       size: 'regular',
       trim: true,
       weight: 'medium',
+      UNSAFE_className: css({ cursor: 'default' }),
     });
 
     return (
diff --git a/design-system/pkg/src/field/FieldPrimitive.tsx b/design-system/pkg/src/field/FieldPrimitive.tsx
index 1d10e123..4c0bbe8c 100644
--- a/design-system/pkg/src/field/FieldPrimitive.tsx
+++ b/design-system/pkg/src/field/FieldPrimitive.tsx
@@ -1,6 +1,7 @@
 import { Flex } from '@keystar/ui/layout';
 import { BaseStyleProps, useStyleProps } from '@keystar/ui/style';
 import { Text } from '@keystar/ui/typography';
+import { HTMLTag } from '@keystar/ui/utils/ts';
 import {
   forwardRef,
   ForwardRefExoticComponent,
@@ -17,6 +18,7 @@ type FieldPrimitiveProps = {
   children: ReactElement;
   isRequired?: boolean;
   label?: ReactNode;
+  labelElementType?: HTMLTag;
   labelProps?: HTMLAttributes<HTMLElement>;
   description?: ReactNode;
   descriptionProps?: HTMLAttributes<HTMLElement>;
@@ -45,6 +47,7 @@ export const FieldPrimitive: ForwardRefExoticComponent<
     children,
     isRequired,
     label,
+    labelElementType,
     labelProps,
     description,
     descriptionProps,
@@ -65,6 +68,7 @@ export const FieldPrimitive: ForwardRefExoticComponent<
     >
       {label && (
         <FieldLabel
+          elementType={labelElementType}
           isRequired={isRequired}
           supplementRequiredState={supplementRequiredState}
           {...labelProps}
diff --git a/design-system/pkg/src/typography/heading/Heading.tsx b/design-system/pkg/src/typography/heading/Heading.tsx
index ae58009f..b6329159 100644
--- a/design-system/pkg/src/typography/heading/Heading.tsx
+++ b/design-system/pkg/src/typography/heading/Heading.tsx
@@ -8,15 +8,16 @@ import { HeadingContext } from './context';
 import { useHeadingStyles } from './useHeadingStyles';
 import { Truncate } from '../Truncate';
 import { useVisuallyHiddenRange } from '../useVisuallyHiddenRange';
+import { filterDOMProps } from '@react-aria/utils';
 
 const sizeToElement = { small: 'h4', regular: 'h3', medium: 'h2', large: 'h1' };
+const filterOptions = { propNames: new Set(['aria-hidden']) };
 
 /** A typographic device used to communicate levels of hierarchy between text. */
 export const Heading = forwardRefWithAs<HeadingProps, 'h3'>((props, ref) => {
   props = useSlotProps(props, 'heading');
   const {
     children,
-    id,
     size = 'regular',
     truncate,
     elementType: ElementType = sizeToElement[size],
@@ -35,7 +36,12 @@ export const Heading = forwardRefWithAs<HeadingProps, 'h3'>((props, ref) => {
 
   return (
     <HeadingContext.Provider value={headingContext}>
-      <ElementType ref={ref} id={id} {...styleProps} {...visuallyHiddenProps}>
+      <ElementType
+        ref={ref}
+        {...filterDOMProps(otherProps, filterOptions)}
+        {...styleProps}
+        {...visuallyHiddenProps}
+      >
         {content}
       </ElementType>
     </HeadingContext.Provider>
diff --git a/design-system/pkg/src/typography/text/Text.tsx b/design-system/pkg/src/typography/text/Text.tsx
index 4e6464e9..74cf4317 100644
--- a/design-system/pkg/src/typography/text/Text.tsx
+++ b/design-system/pkg/src/typography/text/Text.tsx
@@ -12,6 +12,8 @@ import { Truncate } from '../Truncate';
 import { useHeadingContext } from '../heading';
 import { useVisuallyHiddenRange } from '../useVisuallyHiddenRange';
 
+const filterOptions = { propNames: new Set(['aria-hidden', 'role']) };
+
 export const Text = forwardRefWithAs<TextProps, 'span'>(
   (props, forwardedRef) => {
     props = useSlotProps(props, 'text');
@@ -58,7 +60,7 @@ export const Text = forwardRefWithAs<TextProps, 'span'>(
     const element = (
       <ElementType
         ref={forwardedRef}
-        {...filterDOMProps(otherProps, { propNames: new Set(['role']) })}
+        {...filterDOMProps(otherProps, filterOptions)}
         {...styleProps}
         {...visuallyHiddenProps}
       >
diff --git a/design-system/pkg/src/utils/ts/index.ts b/design-system/pkg/src/utils/ts/index.ts
index 731557bb..942fb999 100644
--- a/design-system/pkg/src/utils/ts/index.ts
+++ b/design-system/pkg/src/utils/ts/index.ts
@@ -1,3 +1,7 @@
 export { forwardRefWithAs } from './forwardRefWithAs';
 
-export type { PropsWithElementType, CompWithAsProp } from './forwardRefWithAs';
+export type {
+  CompWithAsProp,
+  HTMLTag,
+  PropsWithElementType,
+} from './forwardRefWithAs';
diff --git a/packages/keystatic/package.json b/packages/keystatic/package.json
index 1313c424..5d7b355a 100644
--- a/packages/keystatic/package.json
+++ b/packages/keystatic/package.json
@@ -184,7 +184,7 @@
     "@react-stately/list": "^3.9.0",
     "@react-stately/overlays": "^3.6.0",
     "@react-stately/utils": "^3.7.0",
-    "@react-types/shared": "^3.18.0",
+    "@react-types/shared": "^3.19.0",
     "@sindresorhus/slugify": "^1.1.2",
     "@ts-gql/tag": "^0.7.0",
     "@types/node": "16.11.13",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 90f498f4..ec81aed1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -80,7 +80,7 @@ importers:
     specifiers:
       '@babel/core': ^7.18.5
       '@babel/runtime': ^7.18.3
-      '@internationalized/date': ^3.3.0
+      '@internationalized/date': ^3.4.0
       '@keystar/ui': ^0.2.0
       '@markdoc/markdoc': ^0.3.0
       '@preconstruct/next': ^4.0.0
@@ -122,7 +122,7 @@ importers:
     dependencies:
       '@babel/core': 7.21.0
       '@babel/runtime': 7.21.0
-      '@internationalized/date': 3.3.0
+      '@internationalized/date': 3.4.0
       '@keystar/ui': link:../pkg
       '@markdoc/markdoc': 0.3.0_i2bxsyfskhzbpjanbovidbfj7u
       '@react-aria/i18n': 3.8.0_react@18.2.0
@@ -171,15 +171,16 @@ importers:
       '@babel/runtime': ^7.18.3
       '@emotion/css': ^11.9.0
       '@floating-ui/react': ^0.24.0
-      '@internationalized/date': ^3.3.0
+      '@internationalized/date': ^3.4.0
       '@keystar/ui': ^0.2.2
       '@keystar/ui-storybook': ^0.0.1
       '@react-aria/actiongroup': ^3.6.0
       '@react-aria/breadcrumbs': ^3.5.3
       '@react-aria/button': ^3.8.0
+      '@react-aria/calendar': ^3.4.1
       '@react-aria/checkbox': ^3.9.2
       '@react-aria/combobox': ^3.6.2
-      '@react-aria/datepicker': ^3.4.0
+      '@react-aria/datepicker': ^3.6.0
       '@react-aria/dialog': ^3.5.3
       '@react-aria/dnd': ^3.3.0
       '@react-aria/focus': ^3.13.0
@@ -210,11 +211,12 @@ importers:
       '@react-aria/utils': ^3.18.0
       '@react-aria/virtualizer': ^3.9.0
       '@react-aria/visually-hidden': ^3.8.2
+      '@react-stately/calendar': ^3.3.1
       '@react-stately/checkbox': ^3.4.3
       '@react-stately/collections': ^3.9.0
       '@react-stately/combobox': ^3.5.2
       '@react-stately/data': ^3.10.0
-      '@react-stately/datepicker': ^3.5.0
+      '@react-stately/datepicker': ^3.6.0
       '@react-stately/dnd': ^3.2.2
       '@react-stately/layout': ^3.12.2
       '@react-stately/list': ^3.9.0
@@ -234,7 +236,8 @@ importers:
       '@react-stately/virtualizer': ^3.6.0
       '@react-types/actiongroup': ^3.3.5
       '@react-types/breadcrumbs': ^3.6.0
-      '@react-types/button': ^3.7.0
+      '@react-types/button': ^3.7.4
+      '@react-types/calendar': ^3.3.1
       '@react-types/combobox': ^3.5.5
       '@react-types/datepicker': ^3.4.0
       '@react-types/grid': ^3.1.5
@@ -243,7 +246,7 @@ importers:
       '@react-types/overlays': ^3.8.0
       '@react-types/radio': ^3.3.1
       '@react-types/select': ^3.6.5
-      '@react-types/shared': ^3.18.0
+      '@react-types/shared': ^3.19.0
       '@react-types/switch': ^3.3.1
       '@react-types/tabs': ^3.1.5
       '@svgr/core': ^6.5.1
@@ -273,13 +276,14 @@ importers:
       '@babel/runtime': 7.21.0
       '@emotion/css': 11.10.6
       '@floating-ui/react': 0.24.2_biqbaboplfbrettd7655fr4n2y
-      '@internationalized/date': 3.3.0
+      '@internationalized/date': 3.4.0
       '@react-aria/actiongroup': 3.6.0_react@18.2.0
       '@react-aria/breadcrumbs': 3.5.3_react@18.2.0
       '@react-aria/button': 3.8.0_react@18.2.0
+      '@react-aria/calendar': 3.4.1_biqbaboplfbrettd7655fr4n2y
       '@react-aria/checkbox': 3.9.2_react@18.2.0
       '@react-aria/combobox': 3.6.2_biqbaboplfbrettd7655fr4n2y
-      '@react-aria/datepicker': 3.4.0_biqbaboplfbrettd7655fr4n2y
+      '@react-aria/datepicker': 3.6.0_biqbaboplfbrettd7655fr4n2y
       '@react-aria/dialog': 3.5.3_biqbaboplfbrettd7655fr4n2y
       '@react-aria/dnd': 3.3.0_biqbaboplfbrettd7655fr4n2y
       '@react-aria/focus': 3.13.0_react@18.2.0
@@ -309,11 +313,12 @@ importers:
       '@react-aria/utils': 3.18.0_react@18.2.0
       '@react-aria/virtualizer': 3.9.0_biqbaboplfbrettd7655fr4n2y
       '@react-aria/visually-hidden': 3.8.2_react@18.2.0
+      '@react-stately/calendar': 3.3.1_react@18.2.0
       '@react-stately/checkbox': 3.4.3_react@18.2.0
       '@react-stately/collections': 3.9.0_react@18.2.0
       '@react-stately/combobox': 3.5.2_react@18.2.0
       '@react-stately/data': 3.10.0_react@18.2.0
-      '@react-stately/datepicker': 3.5.0_react@18.2.0
+      '@react-stately/datepicker': 3.6.0_react@18.2.0
       '@react-stately/dnd': 3.2.2_react@18.2.0
       '@react-stately/layout': 3.12.2_react@18.2.0
       '@react-stately/list': 3.9.0_react@18.2.0
@@ -333,7 +338,8 @@ importers:
       '@react-stately/virtualizer': 3.6.0_react@18.2.0
       '@react-types/actiongroup': 3.4.2_react@18.2.0
       '@react-types/breadcrumbs': 3.6.0_react@18.2.0
-      '@react-types/button': 3.7.3_react@18.2.0
+      '@react-types/button': 3.7.4_react@18.2.0
+      '@react-types/calendar': 3.3.1_react@18.2.0
       '@react-types/combobox': 3.6.2_react@18.2.0
       '@react-types/datepicker': 3.4.0_react@18.2.0
       '@react-types/grid': 3.1.8_react@18.2.0
@@ -342,7 +348,7 @@ importers:
       '@react-types/overlays': 3.8.0_react@18.2.0
       '@react-types/radio': 3.4.2_react@18.2.0
       '@react-types/select': 3.8.1_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@react-types/switch': 3.3.2_react@18.2.0
       '@react-types/tabs': 3.3.0_react@18.2.0
       dedent: 0.7.0
@@ -701,7 +707,7 @@ importers:
       '@react-stately/list': ^3.9.0
       '@react-stately/overlays': ^3.6.0
       '@react-stately/utils': ^3.7.0
-      '@react-types/shared': ^3.18.0
+      '@react-types/shared': ^3.19.0
       '@sindresorhus/slugify': ^1.1.2
       '@testing-library/user-event': ^14.4.3
       '@ts-gql/compiler': ^0.16.1
@@ -787,7 +793,7 @@ importers:
       '@react-stately/list': 3.9.0_react@18.2.0
       '@react-stately/overlays': 3.6.0_react@18.2.0
       '@react-stately/utils': 3.7.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@sindresorhus/slugify': 1.1.2
       '@ts-gql/tag': 0.7.0_graphql@16.6.0
       '@types/node': 16.11.13
@@ -5250,7 +5256,7 @@ packages:
       graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
     dependencies:
       graphql: 16.6.0
-      tslib: 2.4.1
+      tslib: 2.6.0
     dev: true
 
   /@graphql-tools/relay-operation-optimizer/6.5.17_graphql@16.6.0:
@@ -5261,7 +5267,7 @@ packages:
       '@ardatan/relay-compiler': 12.0.0_graphql@16.6.0
       '@graphql-tools/utils': 9.2.1_graphql@16.6.0
       graphql: 16.6.0
-      tslib: 2.4.1
+      tslib: 2.6.0
     transitivePeerDependencies:
       - encoding
       - supports-color
@@ -5367,8 +5373,8 @@ packages:
   /@humanwhocodes/object-schema/1.2.1:
     resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==}
 
-  /@internationalized/date/3.3.0:
-    resolution: {integrity: sha512-qfRd7jCIgUjabI8RxeAsxhLDRS1u8eUPX96GB5uBp1Tpm6YY6dVveE7YwsTEV6L4QOp5LKFirFHHGsL/XQwJIA==}
+  /@internationalized/date/3.4.0:
+    resolution: {integrity: sha512-QUDSGCsvrEVITVf+kv9VSAraAmCgjQmU5CiXtesUBBhBe374NmnEIIaOFBZ72t29dfGMBP0zF+v6toVnbcc6jg==}
     dependencies:
       '@swc/helpers': 0.5.1
     dev: false
@@ -5744,13 +5750,13 @@ packages:
       '@babel/runtime': 7.21.0
       '@emotion/css': 11.10.6
       '@floating-ui/react': 0.24.2_biqbaboplfbrettd7655fr4n2y
-      '@internationalized/date': 3.3.0
+      '@internationalized/date': 3.4.0
       '@react-aria/actiongroup': 3.6.0_react@18.2.0
       '@react-aria/breadcrumbs': 3.5.3_react@18.2.0
       '@react-aria/button': 3.8.0_react@18.2.0
       '@react-aria/checkbox': 3.9.2_react@18.2.0
       '@react-aria/combobox': 3.6.2_biqbaboplfbrettd7655fr4n2y
-      '@react-aria/datepicker': 3.4.0_biqbaboplfbrettd7655fr4n2y
+      '@react-aria/datepicker': 3.6.0_biqbaboplfbrettd7655fr4n2y
       '@react-aria/dialog': 3.5.3_biqbaboplfbrettd7655fr4n2y
       '@react-aria/dnd': 3.3.0_biqbaboplfbrettd7655fr4n2y
       '@react-aria/focus': 3.13.0_react@18.2.0
@@ -5784,7 +5790,7 @@ packages:
       '@react-stately/collections': 3.9.0_react@18.2.0
       '@react-stately/combobox': 3.5.2_react@18.2.0
       '@react-stately/data': 3.10.0_react@18.2.0
-      '@react-stately/datepicker': 3.5.0_react@18.2.0
+      '@react-stately/datepicker': 3.6.0_react@18.2.0
       '@react-stately/dnd': 3.2.2_react@18.2.0
       '@react-stately/layout': 3.12.2_react@18.2.0
       '@react-stately/list': 3.9.0_react@18.2.0
@@ -5804,7 +5810,7 @@ packages:
       '@react-stately/virtualizer': 3.6.0_react@18.2.0
       '@react-types/actiongroup': 3.4.2_react@18.2.0
       '@react-types/breadcrumbs': 3.6.0_react@18.2.0
-      '@react-types/button': 3.7.3_react@18.2.0
+      '@react-types/button': 3.7.4_react@18.2.0
       '@react-types/combobox': 3.6.2_react@18.2.0
       '@react-types/datepicker': 3.4.0_react@18.2.0
       '@react-types/grid': 3.1.8_react@18.2.0
@@ -5813,7 +5819,7 @@ packages:
       '@react-types/overlays': 3.8.0_react@18.2.0
       '@react-types/radio': 3.4.2_react@18.2.0
       '@react-types/select': 3.8.1_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@react-types/switch': 3.3.2_react@18.2.0
       '@react-types/tabs': 3.3.0_react@18.2.0
       dedent: 0.7.0
@@ -5870,7 +5876,7 @@ packages:
       '@react-stately/list': 3.9.0_react@18.2.0
       '@react-stately/overlays': 3.6.0_react@18.2.0
       '@react-stately/utils': 3.7.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@sindresorhus/slugify': 1.1.2
       '@ts-gql/tag': 0.7.0_graphql@16.6.0
       '@types/node': 16.11.13
@@ -6265,7 +6271,7 @@ packages:
       '@react-stately/collections': 3.9.0_react@18.2.0
       '@react-stately/list': 3.9.0_react@18.2.0
       '@react-types/actiongroup': 3.4.2_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -6280,7 +6286,7 @@ packages:
       '@react-aria/link': 3.5.2_react@18.2.0
       '@react-aria/utils': 3.18.0_react@18.2.0
       '@react-types/breadcrumbs': 3.6.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -6294,10 +6300,30 @@ packages:
       '@react-aria/interactions': 3.16.0_react@18.2.0
       '@react-aria/utils': 3.18.0_react@18.2.0
       '@react-stately/toggle': 3.6.0_react@18.2.0
-      '@react-types/button': 3.7.3_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/button': 3.7.4_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
+      '@swc/helpers': 0.5.1
+      react: 18.2.0
+    dev: false
+
+  /@react-aria/calendar/3.4.1_biqbaboplfbrettd7655fr4n2y:
+    resolution: {integrity: sha512-mXz4v0iSPtPX9SR6LaIzSAE5n2blCujaQ+EiM6G91TM3S7BsHqyELiJcV/ucDD7ncr6ovJpm1JsLFOOAn44YTQ==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
+      react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
+    dependencies:
+      '@internationalized/date': 3.4.0
+      '@react-aria/i18n': 3.8.1_react@18.2.0
+      '@react-aria/interactions': 3.17.0_react@18.2.0
+      '@react-aria/live-announcer': 3.3.1
+      '@react-aria/utils': 3.19.0_react@18.2.0
+      '@react-stately/calendar': 3.3.1_react@18.2.0
+      '@react-types/button': 3.7.4_react@18.2.0
+      '@react-types/calendar': 3.3.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
+      react-dom: 18.2.0_react@18.2.0
     dev: false
 
   /@react-aria/checkbox/3.9.2_react@18.2.0:
@@ -6311,7 +6337,7 @@ packages:
       '@react-stately/checkbox': 3.4.3_react@18.2.0
       '@react-stately/toggle': 3.6.0_react@18.2.0
       '@react-types/checkbox': 3.4.4_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -6334,36 +6360,36 @@ packages:
       '@react-stately/collections': 3.9.0_react@18.2.0
       '@react-stately/combobox': 3.5.2_react@18.2.0
       '@react-stately/layout': 3.12.2_react@18.2.0
-      '@react-types/button': 3.7.3_react@18.2.0
+      '@react-types/button': 3.7.4_react@18.2.0
       '@react-types/combobox': 3.6.2_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
       react-dom: 18.2.0_react@18.2.0
     dev: false
 
-  /@react-aria/datepicker/3.4.0_biqbaboplfbrettd7655fr4n2y:
-    resolution: {integrity: sha512-KhyyeLgWU5eJ+LqpfgOXFm/QBS6SIsX60zOgcQmst+LstsyuYjGQD7oqZX3UIMRWWgWj6urkYHk1yrZtam6doQ==}
+  /@react-aria/datepicker/3.6.0_biqbaboplfbrettd7655fr4n2y:
+    resolution: {integrity: sha512-b6LThZJSF9mboFeATUMboTIxSGgW7MjH2vnDZ7UdRQ/ZHZVNX+fjzQ5uOQQ30wJRP44t273jfvxc9OXEMD9CPQ==}
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
       react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@internationalized/date': 3.3.0
+      '@internationalized/date': 3.4.0
       '@internationalized/number': 3.2.1
       '@internationalized/string': 3.1.1
-      '@react-aria/focus': 3.13.0_react@18.2.0
-      '@react-aria/i18n': 3.8.0_react@18.2.0
-      '@react-aria/interactions': 3.16.0_react@18.2.0
-      '@react-aria/label': 3.6.0_react@18.2.0
-      '@react-aria/spinbutton': 3.5.0_biqbaboplfbrettd7655fr4n2y
-      '@react-aria/utils': 3.18.0_react@18.2.0
-      '@react-stately/datepicker': 3.5.0_react@18.2.0
-      '@react-types/button': 3.7.3_react@18.2.0
-      '@react-types/calendar': 3.3.0_react@18.2.0
-      '@react-types/datepicker': 3.4.0_react@18.2.0
-      '@react-types/dialog': 3.5.3_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
-      '@swc/helpers': 0.4.14
+      '@react-aria/focus': 3.14.0_react@18.2.0
+      '@react-aria/i18n': 3.8.1_react@18.2.0
+      '@react-aria/interactions': 3.17.0_react@18.2.0
+      '@react-aria/label': 3.6.1_react@18.2.0
+      '@react-aria/spinbutton': 3.5.1_biqbaboplfbrettd7655fr4n2y
+      '@react-aria/utils': 3.19.0_react@18.2.0
+      '@react-stately/datepicker': 3.6.0_react@18.2.0
+      '@react-types/button': 3.7.4_react@18.2.0
+      '@react-types/calendar': 3.3.1_react@18.2.0
+      '@react-types/datepicker': 3.5.0_react@18.2.0
+      '@react-types/dialog': 3.5.4_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
+      '@swc/helpers': 0.5.1
       react: 18.2.0
       react-dom: 18.2.0_react@18.2.0
     dev: false
@@ -6378,7 +6404,7 @@ packages:
       '@react-aria/utils': 3.18.0_react@18.2.0
       '@react-stately/overlays': 3.6.0_react@18.2.0
       '@react-types/dialog': 3.5.3_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     transitivePeerDependencies:
@@ -6399,8 +6425,8 @@ packages:
       '@react-aria/utils': 3.18.0_react@18.2.0
       '@react-aria/visually-hidden': 3.8.2_react@18.2.0
       '@react-stately/dnd': 3.2.2_react@18.2.0
-      '@react-types/button': 3.7.3_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/button': 3.7.4_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
       react-dom: 18.2.0_react@18.2.0
@@ -6413,11 +6439,24 @@ packages:
     dependencies:
       '@react-aria/interactions': 3.16.0_react@18.2.0
       '@react-aria/utils': 3.18.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       clsx: 1.2.1
       react: 18.2.0
 
+  /@react-aria/focus/3.14.0_react@18.2.0:
+    resolution: {integrity: sha512-Xw7PxLT0Cqcz22OVtTZ8+HvurDogn9/xntzoIbVjpRFWzhlYe5WHnZL+2+gIiKf7EZ18Ma9/QsCnrVnvrky/Kw==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
+    dependencies:
+      '@react-aria/interactions': 3.17.0_react@18.2.0
+      '@react-aria/utils': 3.19.0_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
+      '@swc/helpers': 0.5.1
+      clsx: 1.2.1
+      react: 18.2.0
+    dev: false
+
   /@react-aria/grid/3.8.0_biqbaboplfbrettd7655fr4n2y:
     resolution: {integrity: sha512-7z1xFAbLPgUPROrXwuJk94STQPQ/K8rCLshhwTAg70uFVCPNnrm3jxQ6vE/lddPB+yss9Ee33GwSCrEXdzJkTw==}
     peerDependencies:
@@ -6436,7 +6475,7 @@ packages:
       '@react-stately/virtualizer': 3.6.0_react@18.2.0
       '@react-types/checkbox': 3.4.4_react@18.2.0
       '@react-types/grid': 3.1.8_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
       react-dom: 18.2.0_react@18.2.0
@@ -6455,7 +6494,7 @@ packages:
       '@react-aria/utils': 3.18.0_react@18.2.0
       '@react-stately/list': 3.9.0_react@18.2.0
       '@react-types/checkbox': 3.4.4_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     transitivePeerDependencies:
@@ -6467,7 +6506,7 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@internationalized/date': 3.3.0
+      '@internationalized/date': 3.4.0
       '@internationalized/message': 3.1.1
       '@internationalized/number': 3.2.1
       '@internationalized/string': 3.1.1
@@ -6478,6 +6517,22 @@ packages:
       react: 18.2.0
     dev: false
 
+  /@react-aria/i18n/3.8.1_react@18.2.0:
+    resolution: {integrity: sha512-ftH3saJlhWaHoHEDb/YjYqP8I4/9t4Ksf0D0kvPDRfRcL98DKUSHZD77+EmbjsmzJInzm76qDeEV0FYl4oj7gg==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
+    dependencies:
+      '@internationalized/date': 3.4.0
+      '@internationalized/message': 3.1.1
+      '@internationalized/number': 3.2.1
+      '@internationalized/string': 3.1.1
+      '@react-aria/ssr': 3.7.1_react@18.2.0
+      '@react-aria/utils': 3.19.0_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
+      '@swc/helpers': 0.5.1
+      react: 18.2.0
+    dev: false
+
   /@react-aria/interactions/3.16.0_react@18.2.0:
     resolution: {integrity: sha512-vXANFKVd6ONqNw8U+ZWbSA8lrduCOXw7cWsYosTa5dZ24ZJfRfbhlvRe8CaAKMhB/rOOmvTLaAwdIPia6JtLDg==}
     peerDependencies:
@@ -6485,9 +6540,21 @@ packages:
     dependencies:
       '@react-aria/ssr': 3.7.0_react@18.2.0
       '@react-aria/utils': 3.18.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
+      '@swc/helpers': 0.5.1
+      react: 18.2.0
+
+  /@react-aria/interactions/3.17.0_react@18.2.0:
+    resolution: {integrity: sha512-v4BI5Nd8gi8s297fHpgjDDXOyufX+FPHJ31rkMwY6X1nR5gtI0+2jNOL4lh7s+cWzszpA0wpwIrKUPGhhLyUjQ==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
+    dependencies:
+      '@react-aria/ssr': 3.7.1_react@18.2.0
+      '@react-aria/utils': 3.19.0_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
+    dev: false
 
   /@react-aria/label/3.6.0_react@18.2.0:
     resolution: {integrity: sha512-o6Z9YAbvywj/b995HOl7fS9vf8FVmhWiJkKwFyCi/M1A7FXBqgtPcdPDNHaaKOhvQcwnLs4iMVMJwZdn/dLVDA==}
@@ -6496,7 +6563,19 @@ packages:
     dependencies:
       '@react-aria/utils': 3.18.0_react@18.2.0
       '@react-types/label': 3.7.4_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
+      '@swc/helpers': 0.5.1
+      react: 18.2.0
+    dev: false
+
+  /@react-aria/label/3.6.1_react@18.2.0:
+    resolution: {integrity: sha512-hR7Qx6q0BjOJi/YG5pI13QTQA/2oaXMYdzDCx4Faz8qaY9CCsLjFpo5pUUwRhNieGmf/nHJq6jiYbJqfaONuTQ==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
+    dependencies:
+      '@react-aria/utils': 3.19.0_react@18.2.0
+      '@react-types/label': 3.7.5_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -6508,7 +6587,7 @@ packages:
     dependencies:
       '@react-aria/focus': 3.13.0_react@18.2.0
       '@react-aria/utils': 3.18.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
       use-sync-external-store: 1.2.0_react@18.2.0
@@ -6522,7 +6601,7 @@ packages:
       '@react-aria/interactions': 3.16.0_react@18.2.0
       '@react-aria/utils': 3.18.0_react@18.2.0
       '@react-types/link': 3.4.3_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -6540,7 +6619,7 @@ packages:
       '@react-stately/collections': 3.9.0_react@18.2.0
       '@react-stately/list': 3.9.0_react@18.2.0
       '@react-types/listbox': 3.4.2_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -6566,9 +6645,9 @@ packages:
       '@react-stately/collections': 3.9.0_react@18.2.0
       '@react-stately/menu': 3.5.3_react@18.2.0
       '@react-stately/tree': 3.7.0_react@18.2.0
-      '@react-types/button': 3.7.3_react@18.2.0
+      '@react-types/button': 3.7.4_react@18.2.0
       '@react-types/menu': 3.9.2_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
       react-dom: 18.2.0_react@18.2.0
@@ -6581,7 +6660,7 @@ packages:
     dependencies:
       '@react-aria/progress': 3.4.3_react@18.2.0
       '@react-types/meter': 3.3.2_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -6599,9 +6678,9 @@ packages:
       '@react-aria/textfield': 3.10.0_react@18.2.0
       '@react-aria/utils': 3.18.0_react@18.2.0
       '@react-stately/numberfield': 3.5.0_react@18.2.0
-      '@react-types/button': 3.7.3_react@18.2.0
+      '@react-types/button': 3.7.4_react@18.2.0
       '@react-types/numberfield': 3.4.2_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@react-types/textfield': 3.7.2_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
@@ -6621,9 +6700,9 @@ packages:
       '@react-aria/utils': 3.18.0_react@18.2.0
       '@react-aria/visually-hidden': 3.8.2_react@18.2.0
       '@react-stately/overlays': 3.6.0_react@18.2.0
-      '@react-types/button': 3.7.3_react@18.2.0
+      '@react-types/button': 3.7.4_react@18.2.0
       '@react-types/overlays': 3.8.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
       react-dom: 18.2.0_react@18.2.0
@@ -6638,7 +6717,7 @@ packages:
       '@react-aria/label': 3.6.0_react@18.2.0
       '@react-aria/utils': 3.18.0_react@18.2.0
       '@react-types/progress': 3.4.1_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -6655,7 +6734,7 @@ packages:
       '@react-aria/utils': 3.18.0_react@18.2.0
       '@react-stately/radio': 3.8.2_react@18.2.0
       '@react-types/radio': 3.4.2_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -6670,9 +6749,9 @@ packages:
       '@react-aria/textfield': 3.10.0_react@18.2.0
       '@react-aria/utils': 3.18.0_react@18.2.0
       '@react-stately/searchfield': 3.4.3_react@18.2.0
-      '@react-types/button': 3.7.3_react@18.2.0
+      '@react-types/button': 3.7.4_react@18.2.0
       '@react-types/searchfield': 3.4.2_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -6692,9 +6771,9 @@ packages:
       '@react-aria/utils': 3.18.0_react@18.2.0
       '@react-aria/visually-hidden': 3.8.2_react@18.2.0
       '@react-stately/select': 3.5.2_react@18.2.0
-      '@react-types/button': 3.7.3_react@18.2.0
+      '@react-types/button': 3.7.4_react@18.2.0
       '@react-types/select': 3.8.1_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
       react-dom: 18.2.0_react@18.2.0
@@ -6711,7 +6790,7 @@ packages:
       '@react-aria/utils': 3.18.0_react@18.2.0
       '@react-stately/collections': 3.9.0_react@18.2.0
       '@react-stately/selection': 3.13.2_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -6722,7 +6801,7 @@ packages:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
       '@react-aria/utils': 3.18.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -6736,8 +6815,24 @@ packages:
       '@react-aria/i18n': 3.8.0_react@18.2.0
       '@react-aria/live-announcer': 3.3.1
       '@react-aria/utils': 3.18.0_react@18.2.0
-      '@react-types/button': 3.7.3_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/button': 3.7.4_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
+      '@swc/helpers': 0.5.1
+      react: 18.2.0
+      react-dom: 18.2.0_react@18.2.0
+    dev: false
+
+  /@react-aria/spinbutton/3.5.1_biqbaboplfbrettd7655fr4n2y:
+    resolution: {integrity: sha512-VUMPxjt7TEw38kSyqE3A20UlQ5/0GvkeV/Q61tcjdef9vcf9Z+EJ7AKCcqbVLd9wIKYlPaJQ0JMHJrFJ9Mc91g==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
+      react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
+    dependencies:
+      '@react-aria/i18n': 3.8.1_react@18.2.0
+      '@react-aria/live-announcer': 3.3.1
+      '@react-aria/utils': 3.19.0_react@18.2.0
+      '@react-types/button': 3.7.4_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
       react-dom: 18.2.0_react@18.2.0
@@ -6751,6 +6846,16 @@ packages:
       '@swc/helpers': 0.5.1
       react: 18.2.0
 
+  /@react-aria/ssr/3.7.1_react@18.2.0:
+    resolution: {integrity: sha512-ovVPSD1WlRpZHt7GI9DqJrWG3OIYS+NXQ9y5HIewMJpSe+jPQmMQfyRmgX4EnvmxSlp0u04Wg/7oItcoSIb/RA==}
+    engines: {node: '>= 12'}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
+    dependencies:
+      '@swc/helpers': 0.5.1
+      react: 18.2.0
+    dev: false
+
   /@react-aria/switch/3.5.2_react@18.2.0:
     resolution: {integrity: sha512-mhV4Ip3t241s7gp4ETDe61AsSDox5TZXkiWt8add65p/LMESYBju9hGtbrxkMNCW62AuYCTAIadHoEOpy9HIIg==}
     peerDependencies:
@@ -6782,7 +6887,7 @@ packages:
       '@react-stately/virtualizer': 3.6.0_react@18.2.0
       '@react-types/checkbox': 3.4.4_react@18.2.0
       '@react-types/grid': 3.1.8_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@react-types/table': 3.7.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
@@ -6801,7 +6906,7 @@ packages:
       '@react-aria/utils': 3.18.0_react@18.2.0
       '@react-stately/list': 3.9.0_react@18.2.0
       '@react-stately/tabs': 3.5.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@react-types/tabs': 3.3.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
@@ -6815,7 +6920,7 @@ packages:
       '@react-aria/focus': 3.13.0_react@18.2.0
       '@react-aria/label': 3.6.0_react@18.2.0
       '@react-aria/utils': 3.18.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@react-types/textfield': 3.7.2_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
@@ -6831,8 +6936,8 @@ packages:
       '@react-aria/landmark': 3.0.0-beta.2_react@18.2.0
       '@react-aria/utils': 3.18.0_react@18.2.0
       '@react-stately/toast': 3.0.0-beta.1_react@18.2.0
-      '@react-types/button': 3.7.3_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/button': 3.7.4_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -6847,7 +6952,7 @@ packages:
       '@react-aria/utils': 3.18.0_react@18.2.0
       '@react-stately/toggle': 3.6.0_react@18.2.0
       '@react-types/checkbox': 3.4.4_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@react-types/switch': 3.3.2_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
@@ -6862,7 +6967,7 @@ packages:
       '@react-aria/interactions': 3.16.0_react@18.2.0
       '@react-aria/utils': 3.18.0_react@18.2.0
       '@react-stately/tooltip': 3.4.2_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@react-types/tooltip': 3.4.2_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
@@ -6875,10 +6980,23 @@ packages:
     dependencies:
       '@react-aria/ssr': 3.7.0_react@18.2.0
       '@react-stately/utils': 3.7.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
+      '@swc/helpers': 0.5.1
+      clsx: 1.2.1
+      react: 18.2.0
+
+  /@react-aria/utils/3.19.0_react@18.2.0:
+    resolution: {integrity: sha512-5GXqTCrUQtr78aiLVHZoeeGPuAxO4lCM+udWbKpSCh5xLfCZ7zFlZV9Q9FS0ea+IQypUcY8ngXCLsf22nSu/yg==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
+    dependencies:
+      '@react-aria/ssr': 3.7.1_react@18.2.0
+      '@react-stately/utils': 3.7.0_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       clsx: 1.2.1
       react: 18.2.0
+    dev: false
 
   /@react-aria/virtualizer/3.9.0_biqbaboplfbrettd7655fr4n2y:
     resolution: {integrity: sha512-kaNSol5OwvtklgEpaeJZuupKDNTsyWZEDtQ78/JcPROjBYf6AU90A6PMHjJMOf2flv2XVRC6cOHpzkDcY+S4qg==}
@@ -6891,7 +7009,7 @@ packages:
       '@react-aria/interactions': 3.16.0_react@18.2.0
       '@react-aria/utils': 3.18.0_react@18.2.0
       '@react-stately/virtualizer': 3.6.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
       react-dom: 18.2.0_react@18.2.0
@@ -6904,12 +7022,26 @@ packages:
     dependencies:
       '@react-aria/interactions': 3.16.0_react@18.2.0
       '@react-aria/utils': 3.18.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       clsx: 1.2.1
       react: 18.2.0
     dev: false
 
+  /@react-stately/calendar/3.3.1_react@18.2.0:
+    resolution: {integrity: sha512-wD5hvdL6Bs8fL2oYkGB/7jGR5Z4ARrrd5uK7T2RwthYguvw95og99A6uUti8ssPGzEkPmJvokds59ov6UmBDdA==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
+    dependencies:
+      '@internationalized/date': 3.4.0
+      '@react-stately/utils': 3.7.0_react@18.2.0
+      '@react-types/calendar': 3.3.1_react@18.2.0
+      '@react-types/datepicker': 3.5.0_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
+      '@swc/helpers': 0.5.1
+      react: 18.2.0
+    dev: false
+
   /@react-stately/checkbox/3.4.3_react@18.2.0:
     resolution: {integrity: sha512-TEd50vrUTHZWt8qO7ySLG2MlWJbsCvyx+pA1VhLJw6hRfjqorAjmCcpV2sEdu3EkLG7hA/Jw+7iBmGPlxmBN6A==}
     peerDependencies:
@@ -6918,7 +7050,7 @@ packages:
       '@react-stately/toggle': 3.6.0_react@18.2.0
       '@react-stately/utils': 3.7.0_react@18.2.0
       '@react-types/checkbox': 3.4.4_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -6928,7 +7060,7 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -6944,7 +7076,7 @@ packages:
       '@react-stately/select': 3.5.2_react@18.2.0
       '@react-stately/utils': 3.7.0_react@18.2.0
       '@react-types/combobox': 3.6.2_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -6954,22 +7086,22 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
 
-  /@react-stately/datepicker/3.5.0_react@18.2.0:
-    resolution: {integrity: sha512-GPscIz4jP9hDa1ChgMAWAt8g8mCpjILmSgfyuIZXegPZfa3ryKuQutYU/JGJrBom1xablAgeHIN1AWpve+4f1w==}
+  /@react-stately/datepicker/3.6.0_react@18.2.0:
+    resolution: {integrity: sha512-NlaZNknzIXj8zjmwtyMaXIWAyCRIk2g6xQVqHuxZKjx8ZA44IEXiHqhqCmJH3KNjhrP1hvNPsE2Jl+kSbYZj/A==}
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@internationalized/date': 3.3.0
+      '@internationalized/date': 3.4.0
       '@internationalized/string': 3.1.1
-      '@react-stately/overlays': 3.6.0_react@18.2.0
+      '@react-stately/overlays': 3.6.1_react@18.2.0
       '@react-stately/utils': 3.7.0_react@18.2.0
-      '@react-types/datepicker': 3.4.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/datepicker': 3.5.0_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -6980,7 +7112,7 @@ packages:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
       '@react-stately/selection': 3.13.2_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -6993,7 +7125,7 @@ packages:
       '@react-stately/collections': 3.9.0_react@18.2.0
       '@react-stately/selection': 3.13.2_react@18.2.0
       '@react-types/grid': 3.1.8_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -7007,7 +7139,7 @@ packages:
       '@react-stately/table': 3.10.0_react@18.2.0
       '@react-stately/virtualizer': 3.6.0_react@18.2.0
       '@react-types/grid': 3.1.8_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@react-types/table': 3.7.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
@@ -7021,7 +7153,7 @@ packages:
       '@react-stately/collections': 3.9.0_react@18.2.0
       '@react-stately/selection': 3.13.2_react@18.2.0
       '@react-stately/utils': 3.7.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -7034,7 +7166,7 @@ packages:
       '@react-stately/overlays': 3.6.0_react@18.2.0
       '@react-stately/utils': 3.7.0_react@18.2.0
       '@react-types/menu': 3.9.2_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -7047,7 +7179,7 @@ packages:
       '@internationalized/number': 3.2.1
       '@react-stately/utils': 3.7.0_react@18.2.0
       '@react-types/numberfield': 3.4.2_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -7063,6 +7195,17 @@ packages:
       react: 18.2.0
     dev: false
 
+  /@react-stately/overlays/3.6.1_react@18.2.0:
+    resolution: {integrity: sha512-c/Mda4ZZmFO4e3XZFd7kqt5wuh6Q/7wYJ+0oG59MfDoQstFwGcJTUnx7S8EUMujbocIOCeOmVPA1eE3DNPC2/A==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
+    dependencies:
+      '@react-stately/utils': 3.7.0_react@18.2.0
+      '@react-types/overlays': 3.8.1_react@18.2.0
+      '@swc/helpers': 0.5.1
+      react: 18.2.0
+    dev: false
+
   /@react-stately/radio/3.8.2_react@18.2.0:
     resolution: {integrity: sha512-tjlXask1IEGzzXwdc495K+wsHhyVhtaMhAeTbrdTD1a1fdg2g/jA0vWhN/KGO/CpnZT4vXGjJcY686Rmlrt9EQ==}
     peerDependencies:
@@ -7070,7 +7213,7 @@ packages:
     dependencies:
       '@react-stately/utils': 3.7.0_react@18.2.0
       '@react-types/radio': 3.4.2_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -7082,7 +7225,7 @@ packages:
     dependencies:
       '@react-stately/utils': 3.7.0_react@18.2.0
       '@react-types/searchfield': 3.4.2_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -7098,7 +7241,7 @@ packages:
       '@react-stately/selection': 3.13.2_react@18.2.0
       '@react-stately/utils': 3.7.0_react@18.2.0
       '@react-types/select': 3.8.1_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -7110,7 +7253,7 @@ packages:
     dependencies:
       '@react-stately/collections': 3.9.0_react@18.2.0
       '@react-stately/utils': 3.7.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -7124,7 +7267,7 @@ packages:
       '@react-stately/grid': 3.7.0_react@18.2.0
       '@react-stately/selection': 3.13.2_react@18.2.0
       '@react-types/grid': 3.1.8_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@react-types/table': 3.7.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
@@ -7137,7 +7280,7 @@ packages:
     dependencies:
       '@react-stately/list': 3.9.0_react@18.2.0
       '@react-stately/utils': 3.7.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@react-types/tabs': 3.3.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
@@ -7160,7 +7303,7 @@ packages:
     dependencies:
       '@react-stately/utils': 3.7.0_react@18.2.0
       '@react-types/checkbox': 3.4.4_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -7185,7 +7328,7 @@ packages:
       '@react-stately/collections': 3.9.0_react@18.2.0
       '@react-stately/selection': 3.13.2_react@18.2.0
       '@react-stately/utils': 3.7.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -7204,7 +7347,7 @@ packages:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
       '@react-aria/utils': 3.18.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@swc/helpers': 0.5.1
       react: 18.2.0
     dev: false
@@ -7214,8 +7357,8 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@react-types/button': 3.7.3_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/button': 3.7.4_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7225,26 +7368,26 @@ packages:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
       '@react-types/link': 3.4.3_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
-  /@react-types/button/3.7.3_react@18.2.0:
-    resolution: {integrity: sha512-Fz1t/kYinHDunmct3tADD2h3UDBPZUfRE+zCzYiymz0g+v/zYHTAqnkWToTF9ptf8HIB5L2Z2VFYpeUHFfpWzg==}
+  /@react-types/button/3.7.4_react@18.2.0:
+    resolution: {integrity: sha512-y1JOnJ3pqg2ezZz/fdwMMToPj+8fgj/He7z1NRWtIy1/I7HP+ilSK6S/MLO2jRsM2QfCq8KSw5MQEZBPiPWsjw==}
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
-  /@react-types/calendar/3.3.0_react@18.2.0:
-    resolution: {integrity: sha512-5Qga+eixj+PembMwzcJmQlxif4XhSJJ54JcoyYHVf6mYLw3aE81Jc52OBi1FEWBJOW9YVOTk7VbWPFFF/oBI8A==}
+  /@react-types/calendar/3.3.1_react@18.2.0:
+    resolution: {integrity: sha512-9pn4M8GK6dCMyCN5oilsGYnphe+tSU5zfHucdiVCOyss3HrOBVxLQnr9eZfDxN/nEqz7fCu8QPIIMFFgOi/YCA==}
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@internationalized/date': 3.3.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@internationalized/date': 3.4.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7253,7 +7396,7 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7262,7 +7405,7 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7271,10 +7414,22 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@internationalized/date': 3.3.0
-      '@react-types/calendar': 3.3.0_react@18.2.0
+      '@internationalized/date': 3.4.0
+      '@react-types/calendar': 3.3.1_react@18.2.0
       '@react-types/overlays': 3.8.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
+      react: 18.2.0
+    dev: false
+
+  /@react-types/datepicker/3.5.0_react@18.2.0:
+    resolution: {integrity: sha512-PQSfLR0CgSaD3T70enZQZH/L4s1+KPAJLRxwtyy8toDekKfrkoIjrnUOP91e0rkajeHCSG9T1kL6w8FtaUvbmg==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
+    dependencies:
+      '@internationalized/date': 3.4.0
+      '@react-types/calendar': 3.3.1_react@18.2.0
+      '@react-types/overlays': 3.8.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7284,7 +7439,17 @@ packages:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
       '@react-types/overlays': 3.8.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
+      react: 18.2.0
+    dev: false
+
+  /@react-types/dialog/3.5.4_react@18.2.0:
+    resolution: {integrity: sha512-WCEkUf93XauGaPaF1efTJ8u04Z5iUgmmzRbFnGLrske7rQJYfryP3+26zCxtKKlOTgeFORq5AHeH6vqaMKOhhg==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
+    dependencies:
+      '@react-types/overlays': 3.8.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7293,7 +7458,7 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7302,7 +7467,16 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
+      react: 18.2.0
+    dev: false
+
+  /@react-types/label/3.7.5_react@18.2.0:
+    resolution: {integrity: sha512-iNO5T1UYK7FPF23cwRLQJ4zth2rqoJWbz27Wikwt8Cw8VbVVzfLBPUBZoUyeBVZ0/zzTvEgZUW75OrmKb4gqhw==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
+    dependencies:
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7312,7 +7486,7 @@ packages:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
       '@react-aria/interactions': 3.16.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7321,7 +7495,7 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7331,7 +7505,7 @@ packages:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
       '@react-types/overlays': 3.8.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7341,7 +7515,7 @@ packages:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
       '@react-types/progress': 3.4.1_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7350,7 +7524,7 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7359,7 +7533,16 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
+      react: 18.2.0
+    dev: false
+
+  /@react-types/overlays/3.8.1_react@18.2.0:
+    resolution: {integrity: sha512-aDI/K3E2XACkey8SCBmAerLhYSUFa8g8tML4SoQbfEJPRj+jJztbHbg9F7b3HKDUk4ZOjcUdQRfz1nFHORdbtQ==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
+    dependencies:
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7368,7 +7551,7 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7377,7 +7560,7 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7386,7 +7569,7 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       '@react-types/textfield': 3.7.2_react@18.2.0
       react: 18.2.0
     dev: false
@@ -7396,7 +7579,7 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7406,6 +7589,14 @@ packages:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
       react: 18.2.0
+    dev: false
+
+  /@react-types/shared/3.19.0_react@18.2.0:
+    resolution: {integrity: sha512-h852l8bWhqUxbXIG8vH3ab7gE19nnP3U1kuWf6SNSMvgmqjiRN9jXKPIFxF/PbfdvnXXm0yZSgSMWfUCARF0Cg==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
+    dependencies:
+      react: 18.2.0
 
   /@react-types/switch/3.3.2_react@18.2.0:
     resolution: {integrity: sha512-L0XF4J43Q7HCAJXqseAk6RMteK6k1jQ0zrG05r6lSCkxaS9fGUlgLTCiFUsf07x0ADH1Xyc7PwpfJjyEr5A4tA==}
@@ -7413,7 +7604,7 @@ packages:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
       '@react-types/checkbox': 3.4.4_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7423,7 +7614,7 @@ packages:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
       '@react-types/grid': 3.1.8_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7432,7 +7623,7 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7441,7 +7632,7 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -7451,7 +7642,7 @@ packages:
       react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
     dependencies:
       '@react-types/overlays': 3.8.0_react@18.2.0
-      '@react-types/shared': 3.18.1_react@18.2.0
+      '@react-types/shared': 3.19.0_react@18.2.0
       react: 18.2.0
     dev: false
 
@@ -8903,12 +9094,6 @@ packages:
       svgo: 2.8.0
     dev: true
 
-  /@swc/helpers/0.4.14:
-    resolution: {integrity: sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==}
-    dependencies:
-      tslib: 2.6.0
-    dev: false
-
   /@swc/helpers/0.5.1:
     resolution: {integrity: sha512-sJ902EfIzn1Fa+qYmjdQqh8tPsoxyBz+8yBKC2HKUxyezKJFwPGOn7pv4WY6QuQW//ySQi5lJjA/ZT9sNWWNTg==}
     dependencies:


Test patch:
diff --git a/design-system/pkg/src/calendar/test/Calendar.test.tsx b/design-system/pkg/src/calendar/test/Calendar.test.tsx
new file mode 100644
index 00000000..5d1bbf25
--- /dev/null
+++ b/design-system/pkg/src/calendar/test/Calendar.test.tsx
@@ -0,0 +1,199 @@
+import { CalendarDate, isWeekend } from '@internationalized/date';
+import '@testing-library/jest-dom';
+
+import { act, fireEvent, firePress, renderWithProvider } from '#test-utils';
+
+import { Calendar } from '../index';
+import { useLocale } from '@react-aria/i18n';
+
+const tmInceptionDate = new CalendarDate(2013, 9, 4);
+const keyCodes = {
+  Enter: 13,
+  ' ': 32,
+  PageUp: 33,
+  PageDown: 34,
+  End: 35,
+  Home: 36,
+  ArrowLeft: 37,
+  ArrowUp: 38,
+  ArrowRight: 39,
+  ArrowDown: 40,
+};
+
+describe('calendar/Calendar', () => {
+  beforeAll(() => {
+    jest.useFakeTimers();
+  });
+  afterEach(() => {
+    act(() => {
+      jest.runAllTimers();
+    });
+  });
+
+  it('renders a calendar with a defaultValue', () => {
+    let { getByLabelText, getByRole, getAllByRole } = renderWithProvider(
+      <Calendar defaultValue={tmInceptionDate} />
+    );
+
+    let heading = getByRole('heading');
+    expect(heading).toHaveTextContent('September 2013');
+
+    let gridCells = getAllByRole('gridcell').filter(
+      cell => cell.getAttribute('aria-disabled') !== 'true'
+    );
+    expect(gridCells.length).toBe(30);
+
+    let selectedDate = getByLabelText('Selected', { exact: false });
+    expect(selectedDate.parentElement).toHaveAttribute('role', 'gridcell');
+    expect(selectedDate.parentElement).toHaveAttribute('aria-selected', 'true');
+    expect(selectedDate).toHaveAttribute(
+      'aria-label',
+      'Wednesday, September 4, 2013 selected'
+    );
+  });
+  it('renders a calendar with a value', () => {
+    let { getByLabelText, getByRole, getAllByRole } = renderWithProvider(
+      <Calendar value={tmInceptionDate} />
+    );
+
+    let heading = getByRole('heading');
+    expect(heading).toHaveTextContent('September 2013');
+
+    let gridCells = getAllByRole('gridcell').filter(
+      cell => cell.getAttribute('aria-disabled') !== 'true'
+    );
+    expect(gridCells.length).toBe(30);
+
+    let selectedDate = getByLabelText('Selected', { exact: false });
+    expect(selectedDate.parentElement).toHaveAttribute('role', 'gridcell');
+    expect(selectedDate.parentElement).toHaveAttribute('aria-selected', 'true');
+    expect(selectedDate).toHaveAttribute(
+      'aria-label',
+      'Wednesday, September 4, 2013 selected'
+    );
+  });
+
+  it('focuses the selected date if `autoFocus` is set', () => {
+    let { getByLabelText, getByRole } = renderWithProvider(
+      <Calendar autoFocus value={tmInceptionDate} />
+    );
+    let cell = getByLabelText('selected', { exact: false });
+
+    let grid = getByRole('grid');
+    expect(cell.parentElement).toHaveAttribute('role', 'gridcell');
+    expect(cell.parentElement).toHaveAttribute('aria-selected', 'true');
+    expect(cell).toHaveFocus();
+    expect(grid).not.toHaveAttribute('aria-activedescendant');
+  });
+
+  it('selects a date with the keyboard', () => {
+    let onChange = jest.fn();
+    let { getByLabelText, getByRole } = renderWithProvider(
+      <Calendar
+        defaultValue={new CalendarDate(2023, 6, 5)}
+        autoFocus
+        onChange={onChange}
+      />
+    );
+
+    let grid = getByRole('grid');
+    let selectedDate = getByLabelText('selected', { exact: false });
+    expect(selectedDate.textContent).toBe('5');
+
+    // Select a new date
+    fireEvent.keyDown(grid, {
+      key: 'ArrowLeft',
+      keyCode: keyCodes.ArrowLeft,
+    });
+    fireEvent.keyDown(grid, { key: 'Enter', keyCode: keyCodes.Enter });
+    selectedDate = getByLabelText('selected', { exact: false });
+    expect(selectedDate.textContent).toBe('4');
+    expect(onChange).toHaveBeenCalledTimes(1);
+    expect(onChange.mock.calls[0][0]).toEqual(new CalendarDate(2023, 6, 4));
+
+    fireEvent.keyDown(grid, {
+      key: 'ArrowLeft',
+      keyCode: keyCodes.ArrowLeft,
+    });
+    fireEvent.keyDown(grid, { key: ' ', keyCode: keyCodes[' '] });
+    selectedDate = getByLabelText('selected', { exact: false });
+    expect(selectedDate.textContent).toBe('3');
+    expect(onChange).toHaveBeenCalledTimes(2);
+    expect(onChange.mock.calls[1][0]).toEqual(new CalendarDate(2023, 6, 3));
+  });
+  it('selects a date on click', () => {
+    let onChange = jest.fn();
+    let { getByLabelText, getByText } = renderWithProvider(
+      <Calendar
+        defaultValue={new CalendarDate(2023, 6, 5)}
+        onChange={onChange}
+      />
+    );
+
+    let newDate = getByText('17');
+    firePress(newDate);
+
+    let selectedDate = getByLabelText('selected', { exact: false });
+    expect(selectedDate.textContent).toBe('17');
+    expect(onChange).toHaveBeenCalledTimes(1);
+    expect(onChange.mock.calls[0][0]).toEqual(new CalendarDate(2023, 6, 17));
+  });
+  it('does not select a date on click if outside the valid date range', () => {
+    let onChange = jest.fn();
+    let { getByLabelText } = renderWithProvider(
+      <Calendar
+        onChange={onChange}
+        defaultValue={new CalendarDate(2023, 2, 8)}
+        minValue={new CalendarDate(2023, 2, 5)}
+        maxValue={new CalendarDate(2023, 2, 15)}
+      />
+    );
+
+    firePress(getByLabelText('Friday, February 3, 2023'));
+
+    let selectedDate = getByLabelText('selected', { exact: false });
+    expect(selectedDate.textContent).toBe('8');
+    expect(onChange).not.toHaveBeenCalled();
+
+    firePress(getByLabelText('Friday, February 17, 2023'));
+
+    selectedDate = getByLabelText('selected', { exact: false });
+    expect(selectedDate.textContent).toBe('8');
+    expect(onChange).not.toHaveBeenCalled();
+
+    firePress(getByLabelText('Sunday, February 5, 2023, First available date'));
+
+    selectedDate = getByLabelText('selected', { exact: false });
+    expect(selectedDate.textContent).toBe('5');
+    expect(onChange).toHaveBeenCalledTimes(1);
+
+    firePress(
+      getByLabelText('Wednesday, February 15, 2023, Last available date')
+    );
+
+    selectedDate = getByLabelText('selected', { exact: false });
+    expect(selectedDate.textContent).toBe('15');
+    expect(onChange).toHaveBeenCalledTimes(2);
+  });
+
+  it('marks selection as invalid using isDateUnavailable', () => {
+    function Example() {
+      let { locale } = useLocale();
+      return (
+        <Calendar
+          defaultValue={new CalendarDate(2022, 3, 5)}
+          isDateUnavailable={date => isWeekend(date, locale)}
+        />
+      );
+    }
+
+    let { getByRole } = renderWithProvider(<Example />);
+
+    let cell = getByRole('button', {
+      name: 'Saturday, March 5, 2022 selected',
+    });
+    expect(cell).toHaveAttribute('aria-invalid', 'true');
+    expect(cell.parentElement).toHaveAttribute('aria-selected', 'true');
+    expect(cell.parentElement).toHaveAttribute('aria-invalid', 'true');
+  });
+});
diff --git a/design-system/pkg/src/calendar/test/RangeCalendar.test.tsx b/design-system/pkg/src/calendar/test/RangeCalendar.test.tsx
new file mode 100644
index 00000000..1c8da81f
--- /dev/null
+++ b/design-system/pkg/src/calendar/test/RangeCalendar.test.tsx
@@ -0,0 +1,290 @@
+import { CalendarDate } from '@internationalized/date';
+import '@testing-library/jest-dom';
+
+import { act, fireEvent, renderWithProvider } from '#test-utils';
+
+import { RangeCalendar } from '../index';
+
+describe('calendar/RangeCalendar', () => {
+  // let user = userEvent.setup();
+
+  beforeAll(() => {
+    jest.useFakeTimers();
+  });
+  afterEach(() => {
+    act(() => {
+      jest.runAllTimers();
+    });
+  });
+
+  it('renders a calendar with a defaultValue', () => {
+    let { getAllByLabelText, getByRole, getAllByRole } = renderWithProvider(
+      <RangeCalendar
+        defaultValue={{
+          start: new CalendarDate(2019, 6, 5),
+          end: new CalendarDate(2019, 6, 10),
+        }}
+      />
+    );
+    let heading = getByRole('heading');
+    expect(heading).toHaveTextContent('June 2019');
+
+    let gridCells = getAllByRole('gridcell').filter(
+      cell => cell.getAttribute('aria-disabled') !== 'true'
+    );
+    expect(gridCells.length).toBe(30);
+
+    let selectedDates = getAllByLabelText('Selected', { exact: false });
+    let labels = [
+      'Selected Range: Wednesday, June 5 to Monday, June 10, 2019, Wednesday, June 5, 2019 selected',
+      'Thursday, June 6, 2019 selected',
+      'Friday, June 7, 2019 selected',
+      'Saturday, June 8, 2019 selected',
+      'Sunday, June 9, 2019 selected',
+      'Selected Range: Wednesday, June 5 to Monday, June 10, 2019, Monday, June 10, 2019 selected',
+    ];
+    expect(selectedDates.length).toBe(6);
+
+    let i = 0;
+    for (let cell of selectedDates) {
+      expect(cell.parentElement).toHaveAttribute('role', 'gridcell');
+      expect(cell.parentElement).toHaveAttribute('aria-selected', 'true');
+      expect(cell).toHaveAttribute('aria-label', labels[i++]);
+    }
+  });
+  it('renders a calendar with a value', () => {
+    let { getAllByLabelText, getByRole, getAllByRole } = renderWithProvider(
+      <RangeCalendar
+        value={{
+          start: new CalendarDate(2019, 6, 5),
+          end: new CalendarDate(2019, 6, 10),
+        }}
+      />
+    );
+    let heading = getByRole('heading');
+    expect(heading).toHaveTextContent('June 2019');
+
+    let gridCells = getAllByRole('gridcell').filter(
+      cell => cell.getAttribute('aria-disabled') !== 'true'
+    );
+    expect(gridCells.length).toBe(30);
+
+    let selectedDates = getAllByLabelText('Selected', { exact: false });
+    let labels = [
+      'Selected Range: Wednesday, June 5 to Monday, June 10, 2019, Wednesday, June 5, 2019 selected',
+      'Thursday, June 6, 2019 selected',
+      'Friday, June 7, 2019 selected',
+      'Saturday, June 8, 2019 selected',
+      'Sunday, June 9, 2019 selected',
+      'Selected Range: Wednesday, June 5 to Monday, June 10, 2019, Monday, June 10, 2019 selected',
+    ];
+    expect(selectedDates.length).toBe(6);
+
+    let i = 0;
+    for (let cell of selectedDates) {
+      expect(cell.parentElement).toHaveAttribute('role', 'gridcell');
+      expect(cell.parentElement).toHaveAttribute('aria-selected', 'true');
+      expect(cell).toHaveAttribute('aria-label', labels[i++]);
+    }
+  });
+
+  it('focuses the selected date if `autoFocus` is set', () => {
+    let { getAllByLabelText, getByRole } = renderWithProvider(
+      <RangeCalendar
+        autoFocus
+        value={{
+          start: new CalendarDate(2019, 6, 5),
+          end: new CalendarDate(2019, 6, 10),
+        }}
+      />
+    );
+    let cells = getAllByLabelText('selected', { exact: false });
+    let grid = getByRole('grid');
+
+    expect(cells[0].parentElement).toHaveAttribute('role', 'gridcell');
+    expect(cells[0].parentElement).toHaveAttribute('aria-selected', 'true');
+    expect(cells[0]).toHaveFocus();
+    expect(grid).not.toHaveAttribute('aria-activedescendant');
+  });
+
+  it('should show selected dates across multiple months', () => {
+    let { getByRole, getByLabelText, getAllByLabelText, getAllByRole } =
+      renderWithProvider(
+        <RangeCalendar
+          value={{
+            start: new CalendarDate(2019, 6, 20),
+            end: new CalendarDate(2019, 7, 10),
+          }}
+        />
+      );
+
+    let heading = getByRole('heading');
+    expect(heading).toHaveTextContent('June 2019');
+
+    let gridCells = getAllByRole('gridcell').filter(
+      cell => cell.getAttribute('aria-disabled') !== 'true'
+    );
+    expect(gridCells.length).toBe(30);
+
+    let selected = getAllByLabelText('selected', { exact: false }).filter(
+      cell => cell.getAttribute('aria-disabled') !== 'true'
+    );
+    expect(selected.length).toBe(11);
+    let juneLabels = [
+      'Selected Range: Thursday, June 20 to Wednesday, July 10, 2019, Thursday, June 20, 2019 selected',
+      'Friday, June 21, 2019 selected',
+      'Saturday, June 22, 2019 selected',
+      'Sunday, June 23, 2019 selected',
+      'Monday, June 24, 2019 selected',
+      'Tuesday, June 25, 2019 selected',
+      'Wednesday, June 26, 2019 selected',
+      'Thursday, June 27, 2019 selected',
+      'Friday, June 28, 2019 selected',
+      'Saturday, June 29, 2019 selected',
+      'Sunday, June 30, 2019 selected',
+    ];
+
+    let i = 0;
+    for (let cell of selected) {
+      expect(cell.parentElement).toHaveAttribute('aria-selected', 'true');
+      expect(cell).toHaveAttribute('aria-label', juneLabels[i++]);
+    }
+
+    let nextButton = getAllByLabelText('Next')[0];
+    fireEvent.click(nextButton);
+
+    selected = getAllByLabelText('selected', { exact: false }).filter(
+      cell => cell.getAttribute('aria-disabled') !== 'true'
+    );
+    expect(selected.length).toBe(10);
+    let julyLabels = [
+      'Monday, July 1, 2019 selected',
+      'Tuesday, July 2, 2019 selected',
+      'Wednesday, July 3, 2019 selected',
+      'Thursday, July 4, 2019 selected',
+      'Friday, July 5, 2019 selected',
+      'Saturday, July 6, 2019 selected',
+      'Sunday, July 7, 2019 selected',
+      'Monday, July 8, 2019 selected',
+      'Tuesday, July 9, 2019 selected',
+      'Selected Range: Thursday, June 20 to Wednesday, July 10, 2019, Wednesday, July 10, 2019 selected',
+    ];
+
+    i = 0;
+    for (let cell of selected) {
+      expect(cell.parentElement).toHaveAttribute('aria-selected', 'true');
+      expect(cell).toHaveAttribute('aria-label', julyLabels[i++]);
+    }
+
+    expect(heading).toHaveTextContent('July 2019');
+    gridCells = getAllByRole('gridcell').filter(
+      cell => cell.getAttribute('aria-disabled') !== 'true'
+    );
+    expect(gridCells.length).toBe(31);
+
+    expect(nextButton).toHaveFocus();
+
+    let prevButton = getByLabelText('Previous');
+    fireEvent.click(prevButton);
+
+    expect(heading).toHaveTextContent('June 2019');
+    gridCells = getAllByRole('gridcell').filter(
+      cell => cell.getAttribute('aria-disabled') !== 'true'
+    );
+    expect(gridCells.length).toBe(30);
+
+    selected = getAllByLabelText('selected', { exact: false }).filter(
+      cell => cell.getAttribute('aria-disabled') !== 'true'
+    );
+    expect(selected.length).toBe(11);
+    i = 0;
+    for (let cell of selected) {
+      expect(cell.parentElement).toHaveAttribute('aria-selected', 'true');
+      expect(cell).toHaveAttribute('aria-label', juneLabels[i++]);
+    }
+
+    expect(prevButton).toHaveFocus();
+  });
+
+  // TODO: selects a range with the keyboard
+
+  it('selects a range by clicking with the mouse', () => {
+    let onChange = jest.fn();
+    let { getAllByLabelText, getByText } = renderWithProvider(
+      <RangeCalendar
+        defaultValue={{
+          start: new CalendarDate(2019, 6, 5),
+          end: new CalendarDate(2019, 6, 10),
+        }}
+        onChange={onChange}
+      />
+    );
+
+    fireEvent.click(getByText('17'));
+
+    let selectedDates = getAllByLabelText('selected', { exact: false });
+    expect(selectedDates[0].textContent).toBe('17');
+    expect(selectedDates[selectedDates.length - 1].textContent).toBe('17');
+    expect(onChange).toHaveBeenCalledTimes(0);
+
+    // hovering updates the highlighted dates
+    fireEvent.pointerEnter(getByText('10'));
+    selectedDates = getAllByLabelText('selected', { exact: false });
+    expect(selectedDates[0].textContent).toBe('10');
+    expect(selectedDates[selectedDates.length - 1].textContent).toBe('17');
+    expect(onChange).toHaveBeenCalledTimes(0);
+
+    fireEvent.click(getByText('7'));
+
+    selectedDates = getAllByLabelText('selected', { exact: false });
+    expect(selectedDates[0].textContent).toBe('7'); // uncontrolled
+    expect(selectedDates[selectedDates.length - 1].textContent).toBe('17');
+    expect(onChange).toHaveBeenCalledTimes(1);
+
+    let { start, end } = onChange.mock.calls[0][0];
+    expect(start).toEqual(new CalendarDate(2019, 6, 7));
+    expect(end).toEqual(new CalendarDate(2019, 6, 17));
+  });
+  it('selects a range by dragging with the mouse', () => {
+    let onChange = jest.fn();
+    let { getAllByLabelText, getByText } = renderWithProvider(
+      <RangeCalendar
+        defaultValue={{
+          start: new CalendarDate(2019, 6, 5),
+          end: new CalendarDate(2019, 6, 10),
+        }}
+        onChange={onChange}
+      />
+    );
+    fireEvent.mouseDown(getByText('17'), { detail: 1 });
+
+    let selectedDates = getAllByLabelText('selected', { exact: false });
+    expect(selectedDates[0].textContent).toBe('17');
+    expect(selectedDates[selectedDates.length - 1].textContent).toBe('17');
+    expect(onChange).toHaveBeenCalledTimes(0);
+
+    // dragging updates the highlighted dates
+    fireEvent.pointerEnter(getByText('18'));
+    selectedDates = getAllByLabelText('selected', { exact: false });
+    expect(selectedDates[0].textContent).toBe('17');
+    expect(selectedDates[selectedDates.length - 1].textContent).toBe('18');
+    expect(onChange).toHaveBeenCalledTimes(0);
+
+    fireEvent.pointerEnter(getByText('23'));
+    selectedDates = getAllByLabelText('selected', { exact: false });
+    expect(selectedDates[0].textContent).toBe('17');
+    expect(selectedDates[selectedDates.length - 1].textContent).toBe('23');
+    expect(onChange).toHaveBeenCalledTimes(0);
+
+    fireEvent.mouseUp(getByText('23'), { detail: 1 });
+
+    selectedDates = getAllByLabelText('selected', { exact: false });
+    expect(selectedDates[0].textContent).toBe('17');
+    expect(selectedDates[selectedDates.length - 1].textContent).toBe('23');
+    expect(onChange).toHaveBeenCalledTimes(1);
+
+    let { start, end } = onChange.mock.calls[0][0];
+    expect(start).toEqual(new CalendarDate(2019, 6, 17));
+    expect(end).toEqual(new CalendarDate(2019, 6, 23));
+  });
+});
diff --git a/design-system/pkg/src/date-time/test/DatePicker.test.tsx b/design-system/pkg/src/date-time/test/DatePicker.test.tsx
new file mode 100644
index 00000000..bb5c7b84
--- /dev/null
+++ b/design-system/pkg/src/date-time/test/DatePicker.test.tsx
@@ -0,0 +1,362 @@
+import { CalendarDate, CalendarDateTime } from '@internationalized/date';
+import '@testing-library/jest-dom';
+import userEvent from '@testing-library/user-event';
+import { createRef } from 'react';
+
+import {
+  act,
+  fireEvent,
+  firePress,
+  renderWithProvider,
+  waitFor,
+} from '#test-utils';
+
+import { DatePicker } from '..';
+
+function getTextValue(el: HTMLElement): string {
+  if (el.attributes.getNamedItem('aria-hidden')?.value === 'true') {
+    return '';
+  }
+
+  return [...el.childNodes]
+    .map(el =>
+      el.nodeType === 3 ? el.textContent : getTextValue(el as HTMLElement)
+    )
+    .join('');
+}
+describe('date-time/DatePicker', () => {
+  it('should render a datepicker with a specified date', function () {
+    let { getAllByRole } = renderWithProvider(
+      <DatePicker label="Date" value={new CalendarDate(2023, 2, 3)} />
+    );
+
+    let group = getAllByRole('group')[0];
+    expect(group).toBeVisible();
+    expect(group).not.toHaveAttribute('aria-disabled');
+    expect(group).not.toHaveAttribute('aria-invalid');
+
+    let segments = getAllByRole('spinbutton');
+    expect(segments.length).toBe(3);
+
+    expect(getTextValue(segments[0])).toBe('2');
+    expect(segments[0].getAttribute('aria-label')).toBe('month, ');
+    expect(segments[0].getAttribute('aria-valuenow')).toBe('2');
+    expect(segments[0].getAttribute('aria-valuetext')).toBe('2 – February');
+    expect(segments[0].getAttribute('aria-valuemin')).toBe('1');
+    expect(segments[0].getAttribute('aria-valuemax')).toBe('12');
+
+    expect(getTextValue(segments[1])).toBe('3');
+    expect(segments[1].getAttribute('aria-label')).toBe('day, ');
+    expect(segments[1].getAttribute('aria-valuenow')).toBe('3');
+    expect(segments[1].getAttribute('aria-valuetext')).toBe('3');
+    expect(segments[1].getAttribute('aria-valuemin')).toBe('1');
+    expect(segments[1].getAttribute('aria-valuemax')).toBe('28');
+
+    expect(getTextValue(segments[2])).toBe('2023');
+    expect(segments[2].getAttribute('aria-label')).toBe('year, ');
+    expect(segments[2].getAttribute('aria-valuenow')).toBe('2023');
+    expect(segments[2].getAttribute('aria-valuetext')).toBe('2023');
+    expect(segments[2].getAttribute('aria-valuemin')).toBe('1');
+    expect(segments[2].getAttribute('aria-valuemax')).toBe('9999');
+  });
+
+  it('should render a datepicker with granularity="second"', function () {
+    let { getAllByRole } = renderWithProvider(
+      <DatePicker
+        label="Date"
+        granularity="second"
+        value={new CalendarDateTime(2023, 2, 3)}
+      />
+    );
+
+    let group = getAllByRole('group')[0];
+    expect(group).toBeVisible();
+    expect(group).not.toHaveAttribute('aria-disabled');
+    expect(group).not.toHaveAttribute('aria-invalid');
+
+    let segments = getAllByRole('spinbutton');
+    expect(segments.length).toBe(7);
+
+    expect(getTextValue(segments[0])).toBe('2');
+    expect(segments[0].getAttribute('aria-label')).toBe('month, ');
+    expect(segments[0].getAttribute('aria-valuenow')).toBe('2');
+    expect(segments[0].getAttribute('aria-valuetext')).toBe('2 – February');
+    expect(segments[0].getAttribute('aria-valuemin')).toBe('1');
+    expect(segments[0].getAttribute('aria-valuemax')).toBe('12');
+
+    expect(getTextValue(segments[1])).toBe('3');
+    expect(segments[1].getAttribute('aria-label')).toBe('day, ');
+    expect(segments[1].getAttribute('aria-valuenow')).toBe('3');
+    expect(segments[1].getAttribute('aria-valuetext')).toBe('3');
+    expect(segments[1].getAttribute('aria-valuemin')).toBe('1');
+    expect(segments[1].getAttribute('aria-valuemax')).toBe('28');
+
+    expect(getTextValue(segments[2])).toBe('2023');
+    expect(segments[2].getAttribute('aria-label')).toBe('year, ');
+    expect(segments[2].getAttribute('aria-valuenow')).toBe('2023');
+    expect(segments[2].getAttribute('aria-valuetext')).toBe('2023');
+    expect(segments[2].getAttribute('aria-valuemin')).toBe('1');
+    expect(segments[2].getAttribute('aria-valuemax')).toBe('9999');
+
+    expect(getTextValue(segments[3])).toBe('12');
+    expect(segments[3].getAttribute('aria-label')).toBe('hour, ');
+    expect(segments[3].getAttribute('aria-valuenow')).toBe('0');
+    expect(segments[3].getAttribute('aria-valuetext')).toBe('12 AM');
+    expect(segments[3].getAttribute('aria-valuemin')).toBe('0');
+    expect(segments[3].getAttribute('aria-valuemax')).toBe('11');
+
+    expect(getTextValue(segments[4])).toBe('00');
+    expect(segments[4].getAttribute('aria-label')).toBe('minute, ');
+    expect(segments[4].getAttribute('aria-valuenow')).toBe('0');
+    expect(segments[4].getAttribute('aria-valuetext')).toBe('00');
+    expect(segments[4].getAttribute('aria-valuemin')).toBe('0');
+    expect(segments[4].getAttribute('aria-valuemax')).toBe('59');
+
+    expect(getTextValue(segments[5])).toBe('00');
+    expect(segments[5].getAttribute('aria-label')).toBe('second, ');
+    expect(segments[5].getAttribute('aria-valuenow')).toBe('0');
+    expect(segments[5].getAttribute('aria-valuetext')).toBe('00');
+    expect(segments[5].getAttribute('aria-valuemin')).toBe('0');
+    expect(segments[5].getAttribute('aria-valuemax')).toBe('59');
+
+    expect(getTextValue(segments[6])).toBe('AM');
+    expect(segments[6].getAttribute('aria-label')).toBe('AM/PM, ');
+    expect(segments[6].getAttribute('aria-valuetext')).toBe('AM');
+  });
+
+  it('should support focusing via a ref', function () {
+    let ref = createRef<HTMLDivElement>();
+    let { getAllByRole } = renderWithProvider(
+      <DatePicker label="Date" ref={ref} />
+    );
+    expect(ref.current).toHaveProperty('focus');
+
+    act(() => ref.current?.focus());
+    expect(document.activeElement).toBe(getAllByRole('spinbutton')[0]);
+  });
+
+  it('should support autoFocus', function () {
+    let { getAllByRole } = renderWithProvider(
+      <DatePicker label="Date" autoFocus />
+    );
+    expect(document.activeElement).toBe(getAllByRole('spinbutton')[0]);
+  });
+
+  it('should pass through data attributes', function () {
+    let { getByTestId } = renderWithProvider(
+      <DatePicker label="Date" data-testid="foo" />
+    );
+    expect(getByTestId('foo')).toHaveAttribute('role', 'group');
+  });
+
+  describe('events', function () {
+    let onBlurSpy = jest.fn();
+    let onFocusChangeSpy = jest.fn();
+    let onFocusSpy = jest.fn();
+    let onKeyDownSpy = jest.fn();
+    let onKeyUpSpy = jest.fn();
+
+    afterEach(() => {
+      onBlurSpy.mockClear();
+      onFocusChangeSpy.mockClear();
+      onFocusSpy.mockClear();
+      onKeyDownSpy.mockClear();
+      onKeyUpSpy.mockClear();
+    });
+
+    it('should focus field, move a segment, and open popover and does not blur', async function () {
+      let { getByRole, getAllByRole } = renderWithProvider(
+        <DatePicker
+          label="Date"
+          onBlur={onBlurSpy}
+          onFocus={onFocusSpy}
+          onFocusChange={onFocusChangeSpy}
+        />
+      );
+      let segments = getAllByRole('spinbutton');
+      let button = getByRole('button');
+
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).not.toHaveBeenCalled();
+      expect(onFocusSpy).not.toHaveBeenCalled();
+
+      await userEvent.tab();
+      expect(segments[0]).toHaveFocus();
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+
+      await userEvent.tab();
+      expect(segments[1]).toHaveFocus();
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+
+      firePress(button);
+      await waitFor(() => {
+        expect(getByRole('dialog')).toBeVisible();
+      });
+
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+    });
+
+    it('should focus field and leave to blur', async function () {
+      let { getAllByRole } = renderWithProvider(
+        <DatePicker
+          label="Date"
+          onBlur={onBlurSpy}
+          onFocus={onFocusSpy}
+          onFocusChange={onFocusChangeSpy}
+        />
+      );
+      let segments = getAllByRole('spinbutton');
+
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).not.toHaveBeenCalled();
+      expect(onFocusSpy).not.toHaveBeenCalled();
+
+      await userEvent.tab();
+      expect(segments[0]).toHaveFocus();
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+
+      await userEvent.click(document.body);
+      expect(document.body).toHaveFocus();
+      expect(onBlurSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(2);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+    });
+
+    it('should open popover and call picker onFocus', async function () {
+      let { getByRole } = renderWithProvider(
+        <DatePicker
+          label="Date"
+          onBlur={onBlurSpy}
+          onFocus={onFocusSpy}
+          onFocusChange={onFocusChangeSpy}
+        />
+      );
+      let button = getByRole('button');
+
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).not.toHaveBeenCalled();
+      expect(onFocusSpy).not.toHaveBeenCalled();
+
+      firePress(button);
+      await waitFor(() => {
+        expect(getByRole('dialog')).toBeVisible();
+      });
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+    });
+
+    it('should open and close popover and only call blur when focus leaves picker', async function () {
+      let { getByRole } = renderWithProvider(
+        <DatePicker
+          label="Date"
+          onBlur={onBlurSpy}
+          onFocus={onFocusSpy}
+          onFocusChange={onFocusChangeSpy}
+        />
+      );
+      let button = getByRole('button');
+
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).not.toHaveBeenCalled();
+      expect(onFocusSpy).not.toHaveBeenCalled();
+
+      firePress(button);
+      let dialog = getByRole('dialog');
+      await waitFor(() => {
+        expect(dialog).toBeVisible();
+      });
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+
+      fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
+      fireEvent.keyUp(document.activeElement!, { key: 'Escape' });
+
+      await waitFor(() => {
+        expect(button).toHaveFocus();
+      });
+
+      expect(dialog).not.toBeVisible();
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+
+      await userEvent.tab();
+      expect(document.body).toHaveFocus();
+      expect(onBlurSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(2);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+    });
+
+    it('should trigger right arrow key event for segment navigation', async function () {
+      let { getAllByRole } = renderWithProvider(
+        <DatePicker
+          label="Date"
+          onKeyDown={onKeyDownSpy}
+          onKeyUp={onKeyUpSpy}
+        />
+      );
+      let segments = getAllByRole('spinbutton');
+
+      expect(onKeyDownSpy).not.toHaveBeenCalled();
+      expect(onKeyUpSpy).not.toHaveBeenCalled();
+
+      await userEvent.tab();
+      expect(segments[0]).toHaveFocus();
+      expect(onKeyDownSpy).not.toHaveBeenCalled();
+      expect(onKeyUpSpy).toHaveBeenCalledTimes(1);
+
+      fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
+      fireEvent.keyUp(document.activeElement!, { key: 'ArrowRight' });
+      expect(segments[1]).toHaveFocus();
+      expect(onKeyDownSpy).toHaveBeenCalledTimes(1);
+      expect(onKeyUpSpy).toHaveBeenCalledTimes(2);
+    });
+
+    it('should trigger key event in popover and focus/blur/key events are not called', async function () {
+      let { getByRole } = renderWithProvider(
+        <DatePicker
+          label="Date"
+          onBlur={onBlurSpy}
+          onFocus={onFocusSpy}
+          onFocusChange={onFocusChangeSpy}
+          onKeyDown={onKeyDownSpy}
+          onKeyUp={onKeyUpSpy}
+        />
+      );
+      let button = getByRole('button');
+
+      expect(onKeyDownSpy).not.toHaveBeenCalled();
+      expect(onKeyUpSpy).not.toHaveBeenCalled();
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).not.toHaveBeenCalled();
+      expect(onFocusSpy).not.toHaveBeenCalled();
+
+      firePress(button);
+
+      await waitFor(() => {
+        expect(getByRole('dialog')).toBeVisible();
+      });
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+
+      fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
+      fireEvent.keyUp(document.activeElement!, { key: 'ArrowRight' });
+      expect(onKeyDownSpy).toHaveBeenCalledTimes(0);
+      expect(onKeyUpSpy).toHaveBeenCalledTimes(0);
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+    });
+  });
+});
diff --git a/design-system/pkg/src/date-time/test/DateRangePicker.test.tsx b/design-system/pkg/src/date-time/test/DateRangePicker.test.tsx
new file mode 100644
index 00000000..c87a9809
--- /dev/null
+++ b/design-system/pkg/src/date-time/test/DateRangePicker.test.tsx
@@ -0,0 +1,438 @@
+import { CalendarDate, CalendarDateTime } from '@internationalized/date';
+import '@testing-library/jest-dom';
+import userEvent from '@testing-library/user-event';
+import { createRef } from 'react';
+
+import {
+  act,
+  fireEvent,
+  firePress,
+  renderWithProvider,
+  waitFor,
+} from '#test-utils';
+
+import { DateRangePicker } from '..';
+
+function getTextValue(el: HTMLElement): string {
+  if (el.attributes.getNamedItem('aria-hidden')?.value === 'true') {
+    return '';
+  }
+
+  return [...el.childNodes]
+    .map(el =>
+      el.nodeType === 3 ? el.textContent : getTextValue(el as HTMLElement)
+    )
+    .join('');
+}
+describe('date-time/DateRangePicker', () => {
+  it('should render a rangepicker with a specified date', function () {
+    let { getAllByRole } = renderWithProvider(
+      <DateRangePicker
+        label="Date"
+        value={{
+          start: new CalendarDate(2023, 2, 3),
+          end: new CalendarDate(2023, 5, 6),
+        }}
+      />
+    );
+
+    let group = getAllByRole('group')[0];
+    expect(group).toBeVisible();
+    expect(group).not.toHaveAttribute('aria-disabled');
+    expect(group).not.toHaveAttribute('aria-invalid');
+
+    let segments = getAllByRole('spinbutton');
+    expect(segments.length).toBe(6);
+
+    expect(getTextValue(segments[0])).toBe('2');
+    expect(segments[0].getAttribute('aria-label')).toBe('month, Start Date, ');
+    expect(segments[0].getAttribute('aria-valuenow')).toBe('2');
+    expect(segments[0].getAttribute('aria-valuetext')).toBe('2 – February');
+    expect(segments[0].getAttribute('aria-valuemin')).toBe('1');
+    expect(segments[0].getAttribute('aria-valuemax')).toBe('12');
+
+    expect(getTextValue(segments[1])).toBe('3');
+    expect(segments[1].getAttribute('aria-label')).toBe('day, Start Date, ');
+    expect(segments[1].getAttribute('aria-valuenow')).toBe('3');
+    expect(segments[1].getAttribute('aria-valuetext')).toBe('3');
+    expect(segments[1].getAttribute('aria-valuemin')).toBe('1');
+    expect(segments[1].getAttribute('aria-valuemax')).toBe('28');
+
+    expect(getTextValue(segments[2])).toBe('2023');
+    expect(segments[2].getAttribute('aria-label')).toBe('year, Start Date, ');
+    expect(segments[2].getAttribute('aria-valuenow')).toBe('2023');
+    expect(segments[2].getAttribute('aria-valuetext')).toBe('2023');
+    expect(segments[2].getAttribute('aria-valuemin')).toBe('1');
+    expect(segments[2].getAttribute('aria-valuemax')).toBe('9999');
+
+    expect(getTextValue(segments[3])).toBe('5');
+    expect(segments[3].getAttribute('aria-label')).toBe('month, End Date, ');
+    expect(segments[3].getAttribute('aria-valuenow')).toBe('5');
+    expect(segments[3].getAttribute('aria-valuetext')).toBe('5 – May');
+    expect(segments[3].getAttribute('aria-valuemin')).toBe('1');
+    expect(segments[3].getAttribute('aria-valuemax')).toBe('12');
+
+    expect(getTextValue(segments[4])).toBe('6');
+    expect(segments[4].getAttribute('aria-label')).toBe('day, End Date, ');
+    expect(segments[4].getAttribute('aria-valuenow')).toBe('6');
+    expect(segments[4].getAttribute('aria-valuetext')).toBe('6');
+    expect(segments[4].getAttribute('aria-valuemin')).toBe('1');
+    expect(segments[4].getAttribute('aria-valuemax')).toBe('31');
+
+    expect(getTextValue(segments[5])).toBe('2023');
+    expect(segments[5].getAttribute('aria-label')).toBe('year, End Date, ');
+    expect(segments[5].getAttribute('aria-valuenow')).toBe('2023');
+    expect(segments[5].getAttribute('aria-valuetext')).toBe('2023');
+    expect(segments[5].getAttribute('aria-valuemin')).toBe('1');
+    expect(segments[5].getAttribute('aria-valuemax')).toBe('9999');
+  });
+
+  it('should render a rangepicker with granularity="second"', function () {
+    let { getAllByRole } = renderWithProvider(
+      <DateRangePicker
+        label="Date"
+        granularity="second"
+        value={{
+          start: new CalendarDateTime(2023, 2, 3),
+          end: new CalendarDateTime(2023, 5, 6),
+        }}
+      />
+    );
+
+    let group = getAllByRole('group')[0];
+    expect(group).toBeVisible();
+    expect(group).not.toHaveAttribute('aria-disabled');
+    expect(group).not.toHaveAttribute('aria-invalid');
+
+    let segments = getAllByRole('spinbutton');
+    expect(segments.length).toBe(14);
+
+    expect(getTextValue(segments[0])).toBe('2');
+    expect(segments[0].getAttribute('aria-label')).toBe('month, Start Date, ');
+    expect(segments[0].getAttribute('aria-valuenow')).toBe('2');
+    expect(segments[0].getAttribute('aria-valuetext')).toBe('2 – February');
+    expect(segments[0].getAttribute('aria-valuemin')).toBe('1');
+    expect(segments[0].getAttribute('aria-valuemax')).toBe('12');
+
+    expect(getTextValue(segments[1])).toBe('3');
+    expect(segments[1].getAttribute('aria-label')).toBe('day, Start Date, ');
+    expect(segments[1].getAttribute('aria-valuenow')).toBe('3');
+    expect(segments[1].getAttribute('aria-valuetext')).toBe('3');
+    expect(segments[1].getAttribute('aria-valuemin')).toBe('1');
+    expect(segments[1].getAttribute('aria-valuemax')).toBe('28');
+
+    expect(getTextValue(segments[2])).toBe('2023');
+    expect(segments[2].getAttribute('aria-label')).toBe('year, Start Date, ');
+    expect(segments[2].getAttribute('aria-valuenow')).toBe('2023');
+    expect(segments[2].getAttribute('aria-valuetext')).toBe('2023');
+    expect(segments[2].getAttribute('aria-valuemin')).toBe('1');
+    expect(segments[2].getAttribute('aria-valuemax')).toBe('9999');
+
+    expect(getTextValue(segments[3])).toBe('12');
+    expect(segments[3].getAttribute('aria-label')).toBe('hour, Start Date, ');
+    expect(segments[3].getAttribute('aria-valuenow')).toBe('0');
+    expect(segments[3].getAttribute('aria-valuetext')).toBe('12 AM');
+    expect(segments[3].getAttribute('aria-valuemin')).toBe('0');
+    expect(segments[3].getAttribute('aria-valuemax')).toBe('11');
+
+    expect(getTextValue(segments[4])).toBe('00');
+    expect(segments[4].getAttribute('aria-label')).toBe('minute, Start Date, ');
+    expect(segments[4].getAttribute('aria-valuenow')).toBe('0');
+    expect(segments[4].getAttribute('aria-valuetext')).toBe('00');
+    expect(segments[4].getAttribute('aria-valuemin')).toBe('0');
+    expect(segments[4].getAttribute('aria-valuemax')).toBe('59');
+
+    expect(getTextValue(segments[5])).toBe('00');
+    expect(segments[5].getAttribute('aria-label')).toBe('second, Start Date, ');
+    expect(segments[5].getAttribute('aria-valuenow')).toBe('0');
+    expect(segments[5].getAttribute('aria-valuetext')).toBe('00');
+    expect(segments[5].getAttribute('aria-valuemin')).toBe('0');
+    expect(segments[5].getAttribute('aria-valuemax')).toBe('59');
+
+    expect(getTextValue(segments[6])).toBe('AM');
+    expect(segments[6].getAttribute('aria-label')).toBe('AM/PM, Start Date, ');
+    expect(segments[6].getAttribute('aria-valuetext')).toBe('AM');
+
+    expect(getTextValue(segments[7])).toBe('5');
+    expect(segments[7].getAttribute('aria-label')).toBe('month, End Date, ');
+    expect(segments[7].getAttribute('aria-valuenow')).toBe('5');
+    expect(segments[7].getAttribute('aria-valuetext')).toBe('5 – May');
+    expect(segments[7].getAttribute('aria-valuemin')).toBe('1');
+    expect(segments[7].getAttribute('aria-valuemax')).toBe('12');
+
+    expect(getTextValue(segments[8])).toBe('6');
+    expect(segments[8].getAttribute('aria-label')).toBe('day, End Date, ');
+    expect(segments[8].getAttribute('aria-valuenow')).toBe('6');
+    expect(segments[8].getAttribute('aria-valuetext')).toBe('6');
+    expect(segments[8].getAttribute('aria-valuemin')).toBe('1');
+    expect(segments[8].getAttribute('aria-valuemax')).toBe('31');
+
+    expect(getTextValue(segments[9])).toBe('2023');
+    expect(segments[9].getAttribute('aria-label')).toBe('year, End Date, ');
+    expect(segments[9].getAttribute('aria-valuenow')).toBe('2023');
+    expect(segments[9].getAttribute('aria-valuetext')).toBe('2023');
+    expect(segments[9].getAttribute('aria-valuemin')).toBe('1');
+    expect(segments[9].getAttribute('aria-valuemax')).toBe('9999');
+
+    expect(getTextValue(segments[10])).toBe('12');
+    expect(segments[10].getAttribute('aria-label')).toBe('hour, End Date, ');
+    expect(segments[10].getAttribute('aria-valuenow')).toBe('0');
+    expect(segments[10].getAttribute('aria-valuetext')).toBe('12 AM');
+    expect(segments[10].getAttribute('aria-valuemin')).toBe('0');
+    expect(segments[10].getAttribute('aria-valuemax')).toBe('11');
+
+    expect(getTextValue(segments[11])).toBe('00');
+    expect(segments[11].getAttribute('aria-label')).toBe('minute, End Date, ');
+    expect(segments[11].getAttribute('aria-valuenow')).toBe('0');
+    expect(segments[11].getAttribute('aria-valuetext')).toBe('00');
+    expect(segments[11].getAttribute('aria-valuemin')).toBe('0');
+    expect(segments[11].getAttribute('aria-valuemax')).toBe('59');
+
+    expect(getTextValue(segments[12])).toBe('00');
+    expect(segments[12].getAttribute('aria-label')).toBe('second, End Date, ');
+    expect(segments[12].getAttribute('aria-valuenow')).toBe('0');
+    expect(segments[12].getAttribute('aria-valuetext')).toBe('00');
+    expect(segments[12].getAttribute('aria-valuemin')).toBe('0');
+    expect(segments[12].getAttribute('aria-valuemax')).toBe('59');
+
+    expect(getTextValue(segments[13])).toBe('AM');
+    expect(segments[13].getAttribute('aria-label')).toBe('AM/PM, End Date, ');
+    expect(segments[13].getAttribute('aria-valuetext')).toBe('AM');
+  });
+
+  it('should support focusing via a ref', function () {
+    let ref = createRef<HTMLDivElement>();
+    let { getAllByRole } = renderWithProvider(
+      <DateRangePicker label="Date" ref={ref} />
+    );
+    expect(ref.current).toHaveProperty('focus');
+
+    act(() => ref.current?.focus());
+    expect(document.activeElement).toBe(getAllByRole('spinbutton')[0]);
+  });
+
+  it('should support autoFocus', function () {
+    let { getAllByRole } = renderWithProvider(
+      <DateRangePicker label="Date" autoFocus />
+    );
+    expect(document.activeElement).toBe(getAllByRole('spinbutton')[0]);
+  });
+
+  it('should pass through data attributes', function () {
+    let { getByTestId } = renderWithProvider(
+      <DateRangePicker label="Date" data-testid="foo" />
+    );
+    expect(getByTestId('foo')).toHaveAttribute('role', 'group');
+  });
+
+  describe('events', function () {
+    let onBlurSpy = jest.fn();
+    let onFocusChangeSpy = jest.fn();
+    let onFocusSpy = jest.fn();
+    let onKeyDownSpy = jest.fn();
+    let onKeyUpSpy = jest.fn();
+
+    afterEach(() => {
+      onBlurSpy.mockClear();
+      onFocusChangeSpy.mockClear();
+      onFocusSpy.mockClear();
+      onKeyDownSpy.mockClear();
+      onKeyUpSpy.mockClear();
+    });
+
+    it('should focus field, move a segment, and open popover and does not blur', async function () {
+      let { getByRole, getAllByRole } = renderWithProvider(
+        <DateRangePicker
+          label="Date"
+          onBlur={onBlurSpy}
+          onFocus={onFocusSpy}
+          onFocusChange={onFocusChangeSpy}
+        />
+      );
+      let segments = getAllByRole('spinbutton');
+      let button = getByRole('button');
+
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).not.toHaveBeenCalled();
+      expect(onFocusSpy).not.toHaveBeenCalled();
+
+      await userEvent.tab();
+      expect(segments[0]).toHaveFocus();
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+
+      await userEvent.tab();
+      expect(segments[1]).toHaveFocus();
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+
+      firePress(button);
+      await waitFor(() => {
+        expect(getByRole('dialog')).toBeVisible();
+      });
+
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+    });
+
+    it('should focus field and leave to blur', async function () {
+      let { getAllByRole } = renderWithProvider(
+        <DateRangePicker
+          label="Date"
+          onBlur={onBlurSpy}
+          onFocus={onFocusSpy}
+          onFocusChange={onFocusChangeSpy}
+        />
+      );
+      let segments = getAllByRole('spinbutton');
+
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).not.toHaveBeenCalled();
+      expect(onFocusSpy).not.toHaveBeenCalled();
+
+      await userEvent.tab();
+      expect(segments[0]).toHaveFocus();
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+
+      await userEvent.click(document.body);
+      expect(document.body).toHaveFocus();
+      expect(onBlurSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(2);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+    });
+
+    it('should open popover and call picker onFocus', async function () {
+      let { getByRole } = renderWithProvider(
+        <DateRangePicker
+          label="Date"
+          onBlur={onBlurSpy}
+          onFocus={onFocusSpy}
+          onFocusChange={onFocusChangeSpy}
+        />
+      );
+      let button = getByRole('button');
+
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).not.toHaveBeenCalled();
+      expect(onFocusSpy).not.toHaveBeenCalled();
+
+      firePress(button);
+      await waitFor(() => {
+        expect(getByRole('dialog')).toBeVisible();
+      });
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+    });
+
+    it('should open and close popover and only call blur when focus leaves picker', async function () {
+      let { getByRole } = renderWithProvider(
+        <DateRangePicker
+          label="Date"
+          onBlur={onBlurSpy}
+          onFocus={onFocusSpy}
+          onFocusChange={onFocusChangeSpy}
+        />
+      );
+      let button = getByRole('button');
+
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).not.toHaveBeenCalled();
+      expect(onFocusSpy).not.toHaveBeenCalled();
+
+      firePress(button);
+      let dialog = getByRole('dialog');
+      await waitFor(() => {
+        expect(dialog).toBeVisible();
+      });
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+
+      fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
+      fireEvent.keyUp(document.activeElement!, { key: 'Escape' });
+
+      await waitFor(() => {
+        expect(button).toHaveFocus();
+      });
+
+      expect(dialog).not.toBeVisible();
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+
+      await userEvent.tab();
+      expect(document.body).toHaveFocus();
+      expect(onBlurSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(2);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+    });
+
+    it('should trigger right arrow key event for segment navigation', async function () {
+      let { getAllByRole } = renderWithProvider(
+        <DateRangePicker
+          label="Date"
+          onKeyDown={onKeyDownSpy}
+          onKeyUp={onKeyUpSpy}
+        />
+      );
+      let segments = getAllByRole('spinbutton');
+
+      expect(onKeyDownSpy).not.toHaveBeenCalled();
+      expect(onKeyUpSpy).not.toHaveBeenCalled();
+
+      await userEvent.tab();
+      expect(segments[0]).toHaveFocus();
+      expect(onKeyDownSpy).not.toHaveBeenCalled();
+      expect(onKeyUpSpy).toHaveBeenCalledTimes(1);
+
+      fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
+      fireEvent.keyUp(document.activeElement!, { key: 'ArrowRight' });
+      expect(segments[1]).toHaveFocus();
+      expect(onKeyDownSpy).toHaveBeenCalledTimes(1);
+      expect(onKeyUpSpy).toHaveBeenCalledTimes(2);
+    });
+
+    it('should trigger key event in popover and focus/blur/key events are not called', async function () {
+      let { getByRole } = renderWithProvider(
+        <DateRangePicker
+          label="Date"
+          onBlur={onBlurSpy}
+          onFocus={onFocusSpy}
+          onFocusChange={onFocusChangeSpy}
+          onKeyDown={onKeyDownSpy}
+          onKeyUp={onKeyUpSpy}
+        />
+      );
+      let button = getByRole('button');
+
+      expect(onKeyDownSpy).not.toHaveBeenCalled();
+      expect(onKeyUpSpy).not.toHaveBeenCalled();
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).not.toHaveBeenCalled();
+      expect(onFocusSpy).not.toHaveBeenCalled();
+
+      firePress(button);
+
+      await waitFor(() => {
+        expect(getByRole('dialog')).toBeVisible();
+      });
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+
+      fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
+      fireEvent.keyUp(document.activeElement!, { key: 'ArrowRight' });
+      expect(onKeyDownSpy).toHaveBeenCalledTimes(0);
+      expect(onKeyUpSpy).toHaveBeenCalledTimes(0);
+      expect(onBlurSpy).not.toHaveBeenCalled();
+      expect(onFocusChangeSpy).toHaveBeenCalledTimes(1);
+      expect(onFocusSpy).toHaveBeenCalledTimes(1);
+    });
+  });
+});