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
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import dayjs from 'dayjs';
import type { ReactElement } from 'react';
import { describe, expect, it, vi } from 'vitest';
Expand Down Expand Up @@ -589,4 +590,25 @@ describe('DatePicker', () => {
}).not.toThrow();
});
});

describe('open/close on trigger click', () => {
/*
* Regression: Base UI's `Popover.Trigger` toggles open on every trigger
* click. The input's `onFocus` opens the picker, so the same click's
* trigger-press toggled it straight back closed — the popover flickered
* shut on the first click and only stuck open on the second. The hook's
* `onOpenChange` now ignores trigger-press *closes* (see use-picker-popover).
*/
it('opens and stays open on the first click of the input', async () => {
const user = userEvent.setup();
render(<DatePicker />);

await user.click(screen.getByPlaceholderText('Select date'));
await act(async () => {
await new Promise(r => setTimeout(r, 0));
});

expect(screen.queryByRole('dialog')).toBeInTheDocument();
});
});
});
44 changes: 22 additions & 22 deletions packages/raystack/components/calendar/calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,28 +123,28 @@ export const Calendar = function ({
showOutsideDays={showOutsideDays}
timeZone={timeZone}
components={{
Chevron: props => {
const icon =
props.orientation === 'left' ? (
<ChevronLeftIcon />
) : (
<ChevronRightIcon />
);

return (
<IconButton
{...props}
disabled={loadingData}
className={cx(props.className, loadingData && styles.disabled)}
size={3}
aria-label={
props.orientation === 'left' ? 'Previous month' : 'Next month'
}
>
{icon}
</IconButton>
);
},
PreviousMonthButton: ({ children, ...props }) => (
<IconButton
{...props}
disabled={loadingData || props.disabled}
className={cx(props.className, loadingData && styles.disabled)}
Comment thread
rohanchkrabrty marked this conversation as resolved.
size={3}
aria-label='Previous month'
>
<ChevronLeftIcon />
</IconButton>
),
NextMonthButton: ({ children, ...props }) => (
<IconButton
{...props}
disabled={loadingData || props.disabled}
className={cx(props.className, loadingData && styles.disabled)}
size={3}
aria-label='Next month'
>
<ChevronRightIcon />
</IconButton>
),
Dropdown: (props: DropdownProps) => (
<DropDown
{...props}
Expand Down
15 changes: 2 additions & 13 deletions packages/raystack/components/calendar/date-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,9 @@ export function DatePicker({
return (
<Popover
open={isDisabled ? false : popover.isOpen}
onOpenChange={open => {
onOpenChange={(open, eventDetails) => {
if (isDisabled) return;
popover.onOpenChange(open);
popover.onOpenChange(open, eventDetails?.reason);
}}
>
<Popover.Trigger
Expand All @@ -255,18 +255,7 @@ export function DatePicker({
side={popoverProps?.side ?? 'top'}
>
<Calendar
/*
* No `captionLayout` default — 'dropdown' renders Apsara Selects
* inside the popover whose unmount loops ("Maximum update depth").
* Consumers can opt in via `calendarProps.captionLayout`.
*/
{...calendarProps}
/*
* Must stay after spread: `required` is the discriminator for
* RDP's prop union, and a widened value would break the narrowing.
* `required={false}` is intentional — the picker now supports an
* unselected initial state when no value/defaultValue is passed.
*/
required={false}
timeZone={timeZone}
onDropdownOpen={popover.markDropdownOpen}
Expand Down
19 changes: 15 additions & 4 deletions packages/raystack/components/calendar/use-picker-popover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface UsePickerPopoverReturn {
contentRef: React.RefObject<HTMLDivElement | null>;
handleInputFocus: () => void;
handleInputBlur: (event: React.FocusEvent) => void;
onOpenChange: (open?: boolean) => void;
onOpenChange: (open?: boolean, reason?: string) => void;
/*
* Pass as Calendar's `onDropdownOpen` so the year/month dropdown isn't
* treated as an outside click.
Expand Down Expand Up @@ -138,18 +138,29 @@ export function usePickerPopover({
[isElementOutside, engage]
);

const onOpenChange = useCallback((open?: boolean) => {
const onOpenChange = useCallback((open?: boolean, reason?: string) => {
// Year/month dropdown opening inside the popover triggers an open-change
// we don't want; swallow it and consume the flag.
if (isDropdownOpenRef.current) {
isDropdownOpenRef.current = false;
return;
}
/*
* Base UI's `Popover.Trigger` wires `useClick`, which *toggles* the popover
* on every trigger click. The input's `onFocus` already opens the picker, so
* a single click both opens (focus) and then toggles back closed
* (trigger-press) — the popover flickers shut on the first click and only
* sticks open on the second. Ignore trigger-press *closes*: opening stays
* owned by focus (and trigger-press open), while closing is owned by our
* outside-click / blur / Enter / day-select logic — plus Base UI's own
* Escape/outside-press, which still flow through below.
*/
if (reason === 'trigger-press' && open === false) return;
/*
Comment thread
rohanchkrabrty marked this conversation as resolved.
* Suppress only redundant *re-open* events fired by focus/click handlers
* while the picker is already engaged + open. Explicit close requests
* (Escape key, trigger toggle, programmatic) must always go through, or
* users get stuck with no way to close.
* (Escape key, programmatic) must always go through, or users get stuck
* with no way to close.
*/
if (open === true && isEngagedRef.current && isOpenRef.current) return;
setIsOpen(Boolean(open));
Expand Down
Loading