Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 115 additions & 1 deletion lib/compound/DateRangePicker/DateRangePicker.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,14 +189,96 @@ export const SinglePlaceholder: Story = {
},
};

// ── Input-trigger variant ───────────────────────────────────────────────────

const WIDE_CONTAINER_WIDTH = 320;

export const InputTrigger: Story = {
args: {
fromDate: FIXED_FROM_DATE,
toDate: FIXED_TO_DATE,
trigger: 'input',
},
decorators: [
(Story) => (
<div style={{ width: WIDE_CONTAINER_WIDTH }}>
<Story />
</div>
),
],
};

export const InputTriggerEmpty: Story = {
args: {
trigger: 'input',
},
decorators: [
(Story) => (
<div style={{ width: WIDE_CONTAINER_WIDTH }}>
<Story />
</div>
),
],
};

export const SingleInputTrigger: Story = {
args: {
mode: 'single',
value: FIXED_FROM_DATE,
trigger: 'input',
},
decorators: [
(Story) => (
<div style={{ width: WIDE_CONTAINER_WIDTH }}>
<Story />
</div>
),
],
};

export const SingleInputTriggerEmpty: Story = {
args: {
mode: 'single',
trigger: 'input',
placeholder: 'Select due date',
},
decorators: [
(Story) => (
<div style={{ width: WIDE_CONTAINER_WIDTH }}>
<Story />
</div>
),
],
};

export const InputTriggerDisabled: Story = {
args: {
mode: 'single',
value: FIXED_FROM_DATE,
trigger: 'input',
disabled: true,
},
decorators: [
(Story) => (
<div style={{ width: WIDE_CONTAINER_WIDTH }}>
<Story />
</div>
),
],
};

// ── Chromatic visual regression: popover open states ────────────────────────
//
// The Radix popover renders into a portal at document.body, so the opened
// content is not inside `canvasElement`. We wait on `within(document.body)`.

const openPopoverPlay = async ({ canvasElement }: { canvasElement: HTMLElement }) => {
const canvas = within(canvasElement);
const trigger = await canvas.findByRole('button');
// Button variant renders a <button>; input variant renders a <TextField.Root>
// with `role="combobox"` (required for `aria-expanded` to be valid on the
// underlying <input>). Pick whichever exists.
const trigger =
canvas.queryByRole('combobox') ?? (await canvas.findByRole('button'));
await userEvent.click(trigger);

const body = within(document.body);
Expand Down Expand Up @@ -227,3 +309,35 @@ export const SingleOpenPopoverNoSelection: Story = {
},
play: openPopoverPlay,
};

export const InputTriggerOpenPopover: Story = {
args: {
fromDate: FIXED_FROM_DATE,
toDate: FIXED_TO_DATE,
trigger: 'input',
},
decorators: [
(Story) => (
<div style={{ width: WIDE_CONTAINER_WIDTH }}>
<Story />
</div>
),
],
play: openPopoverPlay,
};

export const SingleInputTriggerOpenPopover: Story = {
args: {
mode: 'single',
value: FIXED_FROM_DATE,
trigger: 'input',
},
decorators: [
(Story) => (
<div style={{ width: WIDE_CONTAINER_WIDTH }}>
<Story />
</div>
),
],
play: openPopoverPlay,
};
52 changes: 40 additions & 12 deletions lib/compound/DateRangePicker/DateRangePicker.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
'use client';

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { Button, Flex, Theme } from '@radix-ui/themes';
import { Popover, PopoverContent, PopoverPortal, PopoverTrigger } from '@radix-ui/react-popover';
import { Popover, PopoverContent, PopoverPortal } from '@radix-ui/react-popover';
import { addDays, format } from 'date-fns';
import { CalendarIcon } from '@phosphor-icons/react';
import { type DateRange, type DayPickerProps, DayPicker } from 'react-day-picker';

import styles from './DateRangePicker.module.css';
Expand All @@ -15,6 +14,7 @@ import type {

import { ContentWrapper } from '../../atom';
import DateSelection from './parts/DateSelection';
import DateTrigger from './parts/DateTrigger';
import NextMonthButton from './parts/NextMonthButton';
import PresetsColumn from './parts/PresetsColumn';
import SingleCalendar from './parts/SingleCalendar';
Expand All @@ -41,12 +41,15 @@ const formatDateRangeLabel = (range: DateRange | undefined) => {
return `${format(range.from, fromFormat)} - ${format(range.to, 'LLL dd, y')}`;
};

const RANGE_PLACEHOLDER = 'Pick a date range';

const RangeCalendar = ({
fromDate: fromDateProp,
toDate: toDateProp,
onChange,
disabled,
presets = DEFAULT_DATE_RANGE_PRESETS,
trigger = 'button',
}: Omit<IDateRangePickerRangeProps, 'mode'>) => {
const isControlled = fromDateProp !== undefined || toDateProp !== undefined;

Expand All @@ -62,6 +65,7 @@ const RangeCalendar = ({

const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const popoverContentRef = useRef<HTMLDivElement>(null);
const popoverId = useId();

const displayDate = isControlled && !isPopoverOpen ? controlledDate : draftDate;
const [today, setToday] = useState(() => new Date());
Expand Down Expand Up @@ -109,27 +113,51 @@ const RangeCalendar = ({
setDraftDate(range);
};

// Shared popover-open initialization. Called from both `onOpenChange(true)`
// (button trigger path — Radix-initiated) and `onToggleRequest(true)` (input
// trigger path — externally-initiated via `PopoverAnchor`). Radix does not
// fire `onOpenChange` when the controlled `open` prop is changed from
// outside, so the input variant needs to run this initialization explicitly.
const handleOpenPopover = () => {
setIsPopoverOpen(true);
setToday(new Date());
setDraftDate(isControlled ? controlledDate : draftDate);
};

const handleToggleRequest = (nextOpen: boolean) => {
if (nextOpen) {
handleOpenPopover();
} else {
setIsPopoverOpen(false);
}
};

return (
<Popover
open={isPopoverOpen}
onOpenChange={(open) => {
setIsPopoverOpen(open);
if (open) {
setToday(new Date());
setDraftDate(isControlled ? controlledDate : draftDate);
handleOpenPopover();
} else {
setIsPopoverOpen(false);
}
}}
>
<PopoverTrigger asChild>
<Button type="button" variant="outline" disabled={disabled}>
<CalendarIcon />
{displayDate?.from ? formatDateRangeLabel(displayDate) : <span>Pick a date range</span>}
</Button>
</PopoverTrigger>
<DateTrigger
variant={trigger}
label={formatDateRangeLabel(displayDate)}
placeholder={RANGE_PLACEHOLDER}
disabled={disabled}
ariaLabel="Choose a date range"
isOpen={isPopoverOpen}
onToggleRequest={handleToggleRequest}
popoverId={popoverId}
/>
<PopoverPortal>
<Theme asChild>
<PopoverContent
ref={popoverContentRef}
id={popoverId}
align="start"
sideOffset={4}
collisionPadding={10}
Expand Down
12 changes: 12 additions & 0 deletions lib/compound/DateRangePicker/DateRangePicker.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ export interface IDatePreset {

interface IDateRangePickerCommonProps {
readonly disabled?: boolean;
/**
* Visual variant of the trigger that opens the popover.
* - `'button'` (default): a compact outline button with a leading calendar
* icon and the formatted label.
* - `'input'`: a readonly text-field with a leading calendar icon slot — use
* this when the picker sits alongside other form inputs so the visual
* rhythm lines up.
*
* The popover contents and keyboard/selection semantics are identical across
* both variants.
*/
readonly trigger?: 'button' | 'input';
}

/** Props for {@link DateRangePicker} in range mode (the default). */
Expand Down
129 changes: 129 additions & 0 deletions lib/compound/DateRangePicker/parts/DateTrigger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
'use client';

import { Button, TextField } from '@radix-ui/themes';
import { PopoverAnchor, PopoverTrigger } from '@radix-ui/react-popover';
import { CalendarIcon } from '@phosphor-icons/react';
import { forwardRef, type KeyboardEvent } from 'react';

interface IDateTriggerProps {
/** Variant of the trigger. `'button'` renders a compact outline button; `'input'` renders a readonly Radix `TextField.Root` with a leading calendar icon slot. */
readonly variant: 'button' | 'input';
/** Formatted label for the selected value — rendered inside the trigger when non-empty. */
readonly label: string | null;
/** Placeholder shown when `label` is empty. */
readonly placeholder: string;
/** Whether the trigger is disabled. */
readonly disabled?: boolean;
/** Accessible name announced when focus lands on the trigger. */
readonly ariaLabel: string;
/** Current open state of the parent popover (used only by the input variant). */
readonly isOpen: boolean;
/** Called when the input variant requests the popover to toggle. Click and Enter/Space toggle; ArrowDown only opens. */
readonly onToggleRequest: (nextOpen: boolean) => void;
/** Id of the `PopoverContent`. Used only by the input variant to wire `role="combobox"` + `aria-controls` so `aria-expanded` is valid for axe. */
readonly popoverId: string;
}

/**
* Renders the trigger element that opens the date picker popover.
*
* - **`variant='button'`** — uses `<PopoverTrigger asChild>` with a Radix
* `<Button variant="outline">`; the popover's open state is fully owned by
* the built-in trigger.
* - **`variant='input'`** — uses `<PopoverAnchor asChild>` with a readonly
* `<TextField.Root>` + leading calendar icon slot, and manually wires
* click / keyboard handlers to `onToggleRequest`. Click and Enter/Space
* toggle the popover open/closed to match `PopoverTrigger`'s behaviour;
* ArrowDown only opens. Escape and outside-click are still handled
* natively by `PopoverContent`.
*/
const DateTrigger = forwardRef<HTMLElement, IDateTriggerProps>(
(
{
variant,
label,
placeholder,
disabled,
ariaLabel,
isOpen,
onToggleRequest,
popoverId,
},
ref
) => {
if (variant === 'input') {
const handleToggle = () => {
if (disabled) return;
onToggleRequest(!isOpen);
};

// Slot click fires first and also bubbles to `TextField.Root`'s
// `onClick`. Stop propagation so we don't double-fire the toggle (and
// flip open → closed → open on a single icon click).
const handleSlotClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
handleToggle();
};

const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggleRequest(!isOpen);
return;
}
if (event.key === 'ArrowDown' && !isOpen) {
event.preventDefault();
onToggleRequest(true);
}
};

return (
<PopoverAnchor asChild>
<TextField.Root
ref={ref as React.Ref<HTMLInputElement>}
/*
* `role="combobox"` is required for `aria-expanded` to be valid on
* a plain `<input>` (axe flags it otherwise). `aria-controls`
* pairs the combobox with the dialog that holds the calendar.
*/
role="combobox"
aria-controls={popoverId}
readOnly
value={label ?? ''}
placeholder={placeholder}
disabled={disabled}
aria-label={ariaLabel}
aria-haspopup="dialog"
aria-expanded={isOpen}
onClick={handleToggle}
onKeyDown={handleKeyDown}
>
<TextField.Slot side="left" onClick={handleSlotClick}>
<CalendarIcon />
</TextField.Slot>
</TextField.Root>
</PopoverAnchor>
);
}

return (
<PopoverTrigger asChild>
<Button
ref={ref as React.Ref<HTMLButtonElement>}
type="button"
variant="outline"
disabled={disabled}
aria-label={ariaLabel}
>
<CalendarIcon />
{label ? label : <span>{placeholder}</span>}
</Button>
</PopoverTrigger>
);
}
);

DateTrigger.displayName = 'DateTrigger';

export default DateTrigger;
Loading
Loading