Skip to content

Commit 7b0c97b

Browse files
NicolappsConvex, Inc.
authored andcommitted
dash: Add beforeStartTooltip to <Calendar /> (#42105)
This adds a new `beforeStartTooltip` prop to `<Calendar />` that can be used to add a tooltip to buttons that are disabled. The implementation is done by customizing components of react-day-picker v9. I added a test to verify that the implementation behaves correctly. GitOrigin-RevId: 9e4e57d09dee22950ca3b9a56d3501c3152d7542
1 parent a0396f9 commit 7b0c97b

File tree

5 files changed

+221
-24
lines changed

5 files changed

+221
-24
lines changed

npm-packages/dashboard-common/src/elements/Calendar.stories.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ export const RestrictedRange: Story = {
3030
before: rangeStart,
3131
after: rangeEnd,
3232
},
33+
beforeStartTooltip: (
34+
<>
35+
This is <em>too early</em>!
36+
</>
37+
),
3338
},
3439
};
3540

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { render, screen } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { Calendar, CalendarProps } from "./Calendar";
4+
5+
describe("Calendar", () => {
6+
describe("beforeStartTooltip", () => {
7+
const MESSAGE = "Cannot select dates before start";
8+
9+
const currentMonth = new Date(2025, 5); // June 2025
10+
const minDate = new Date(2025, 5, 10);
11+
const maxDate = new Date(2025, 5, 20);
12+
13+
const calendarProps = {
14+
mode: "single",
15+
selected: new Date(2025, 5, 15),
16+
defaultMonth: currentMonth,
17+
startMonth: minDate,
18+
endMonth: maxDate,
19+
disabled: { before: minDate, after: maxDate },
20+
beforeStartTooltip: MESSAGE,
21+
onSelect: () => {},
22+
} satisfies Partial<CalendarProps>;
23+
24+
describe("on day buttons", () => {
25+
it("should show tooltip on dates before the start of allowed dates", async () => {
26+
const user = userEvent.setup();
27+
28+
render(<Calendar {...calendarProps} />);
29+
30+
const day9Button = screen.getByRole("button", {
31+
name: "Monday, June 9th, 2025",
32+
});
33+
34+
expect(day9Button).toBeDisabled();
35+
36+
await user.hover(day9Button);
37+
38+
expect(
39+
screen.getByRole("tooltip", {
40+
name: MESSAGE,
41+
}),
42+
).toBeInTheDocument();
43+
});
44+
45+
it("should show tooltip on dates in the range of allowed dates", async () => {
46+
const user = userEvent.setup();
47+
48+
render(<Calendar {...calendarProps} />);
49+
50+
const day10Button = screen.getByRole("button", {
51+
name: "Tuesday, June 10th, 2025",
52+
});
53+
54+
expect(day10Button).toBeEnabled();
55+
56+
await user.hover(day10Button);
57+
58+
expect(screen.queryByText(MESSAGE)).not.toBeInTheDocument();
59+
});
60+
61+
it("should not show tooltip on dates after the end of allowed dates", async () => {
62+
const user = userEvent.setup();
63+
64+
render(<Calendar {...calendarProps} />);
65+
66+
const day21Button = screen.getByRole("button", {
67+
name: "Saturday, June 21st, 2025",
68+
});
69+
70+
expect(day21Button).toBeDisabled();
71+
72+
await user.hover(day21Button);
73+
expect(screen.queryByText(MESSAGE)).not.toBeInTheDocument();
74+
});
75+
});
76+
77+
describe("on previous month button", () => {
78+
it("should show a tooltip on the previous month button", async () => {
79+
const user = userEvent.setup();
80+
81+
render(<Calendar {...calendarProps} />);
82+
83+
const prevButton = screen.getByRole("button", {
84+
name: "Go to the Previous Month",
85+
});
86+
expect(prevButton).toHaveAttribute("aria-disabled", "true");
87+
88+
// Hover over the disabled button - should show tooltip
89+
await user.hover(prevButton);
90+
91+
expect(
92+
screen.getByRole("tooltip", {
93+
name: MESSAGE,
94+
}),
95+
).toBeInTheDocument();
96+
});
97+
98+
it("should not show the tooltip on the next month button", async () => {
99+
const user = userEvent.setup();
100+
101+
render(<Calendar {...calendarProps} />);
102+
103+
const nextButton = screen.getByRole("button", {
104+
name: "Go to the Next Month",
105+
});
106+
expect(nextButton).toHaveAttribute("aria-disabled", "true");
107+
108+
await user.hover(nextButton);
109+
expect(
110+
screen.queryByRole("tooltip", {
111+
name: MESSAGE,
112+
}),
113+
).not.toBeInTheDocument();
114+
});
115+
});
116+
});
117+
});

npm-packages/dashboard-common/src/elements/Calendar.tsx

Lines changed: 91 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,29 @@
11
"use client";
22

33
import * as React from "react";
4+
import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons";
45
import {
5-
ChevronLeftIcon,
6-
ChevronRightIcon,
7-
ChevronUpIcon,
8-
ChevronDownIcon,
9-
} from "@radix-ui/react-icons";
10-
import { ChevronProps, ClassNames, DayPicker } from "react-day-picker";
6+
ClassNames,
7+
DayButton,
8+
DayButtonProps,
9+
DayPicker,
10+
DayProps,
11+
NextMonthButtonProps,
12+
PreviousMonthButtonProps,
13+
} from "react-day-picker";
1114

1215
import { cn } from "@ui/cn";
16+
import { Tooltip } from "@ui/Tooltip";
1317

14-
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
18+
export type BaseProps = React.ComponentProps<typeof DayPicker>;
19+
export type CalendarProps = BaseProps & {
20+
beforeStartTooltip?: React.ReactNode;
21+
};
1522

16-
export function Calendar({ ...props }: CalendarProps) {
23+
export function Calendar({
24+
beforeStartTooltip,
25+
...calendarProps
26+
}: CalendarProps) {
1727
return (
1828
<DayPicker
1929
classNames={
@@ -55,24 +65,81 @@ export function Calendar({ ...props }: CalendarProps) {
5565
} satisfies Partial<ClassNames>
5666
}
5767
components={{
58-
Chevron: function CustomChevron({ orientation }: ChevronProps) {
59-
if (orientation === "left") {
60-
return <ChevronLeftIcon className="size-4" />;
61-
}
62-
if (orientation === "right") {
63-
return <ChevronRightIcon className="size-4" />;
64-
}
65-
if (orientation === "up") {
66-
return <ChevronUpIcon className="size-4" />;
67-
}
68-
if (orientation === "down") {
69-
return <ChevronDownIcon className="size-4" />;
70-
}
71-
orientation satisfies undefined;
72-
return <span />;
68+
PreviousMonthButton: function CustomPreviousMonthButton({
69+
children: _,
70+
...buttonProps
71+
}: PreviousMonthButtonProps) {
72+
return (
73+
<Tooltip
74+
tip={buttonProps["aria-disabled"] === true && beforeStartTooltip}
75+
wrapsButton
76+
>
77+
{/* eslint-disable-next-line react/forbid-elements, react/button-has-type -- Component managed by react-day-picker */}
78+
<button {...buttonProps}>
79+
<ChevronLeftIcon className="size-4" />
80+
</button>
81+
</Tooltip>
82+
);
83+
},
84+
85+
NextMonthButton: function CustomPreviousMonthButton({
86+
children: _,
87+
...buttonProps
88+
}: NextMonthButtonProps) {
89+
return (
90+
// eslint-disable-next-line react/forbid-elements, react/button-has-type -- Component managed by react-day-picker
91+
<button {...buttonProps}>
92+
<ChevronRightIcon className="size-4" />
93+
</button>
94+
);
95+
},
96+
97+
// Modify `DayButton` to forward ref so that we can wrap it in a tooltip
98+
DayButton: React.forwardRef<HTMLButtonElement, DayButtonProps>(
99+
function CalendarDayButton(
100+
{ day: _, modifiers, ...buttonProps },
101+
ref,
102+
) {
103+
const localRef = React.useRef<HTMLButtonElement>(null);
104+
React.useImperativeHandle(ref, () => localRef.current!);
105+
106+
React.useEffect(() => {
107+
if (modifiers.focused) localRef.current?.focus();
108+
}, [modifiers.focused]);
109+
110+
// eslint-disable-next-line react/forbid-elements, react/button-has-type -- Component managed by react-day-picker
111+
return <button ref={localRef} {...buttonProps} />;
112+
},
113+
) as typeof DayButton, // need `as` here since `DayButton` isn’t
114+
115+
// Modify `Day` to wrap the children in a tooltip when necessary
116+
Day: function CalendarDay({
117+
day,
118+
modifiers,
119+
children,
120+
...tdProps
121+
}: DayProps) {
122+
return (
123+
<td {...tdProps}>
124+
<Tooltip
125+
tip={
126+
beforeStartTooltip &&
127+
modifiers.disabled &&
128+
typeof calendarProps.disabled === "object" &&
129+
"before" in calendarProps.disabled &&
130+
day.date < calendarProps.disabled.before
131+
? beforeStartTooltip
132+
: undefined
133+
}
134+
wrapsButton
135+
>
136+
{children}
137+
</Tooltip>
138+
</td>
139+
);
73140
},
74141
}}
75-
{...props}
142+
{...calendarProps}
76143
/>
77144
);
78145
}

npm-packages/dashboard-common/src/elements/DateRangePicker.stories.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,10 @@ export const RestrictedRange: Story = {
2222
args: {
2323
minDate: new Date(Date.now() - 4 * 7 * 24 * 60 * 60 * 1000),
2424
maxDate: new Date(Date.now() + 4 * 7 * 24 * 60 * 60 * 1000),
25+
beforeMinDateTooltip: (
26+
<>
27+
This is <em>too early</em>!
28+
</>
29+
),
2530
},
2631
};

npm-packages/dashboard-common/src/elements/DateRangePicker.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type DateRangeShortcut = {
2121
export function DateRangePicker({
2222
minDate,
2323
maxDate,
24+
beforeMinDateTooltip,
2425
date,
2526
setDate,
2627
shortcuts,
@@ -31,6 +32,7 @@ export function DateRangePicker({
3132
}: {
3233
minDate?: Date;
3334
maxDate?: Date;
35+
beforeMinDateTooltip?: React.ReactNode;
3436
date: {
3537
from?: Date;
3638
to?: Date;
@@ -156,6 +158,7 @@ export function DateRangePicker({
156158
startMonth={minDate}
157159
endMonth={maxDate}
158160
disabled={disabledFromRange({ minDate, maxDate })}
161+
beforeStartTooltip={beforeMinDateTooltip}
159162
defaultMonth={from || new Date()}
160163
selected={selectedRange}
161164
onSelect={(d) => {

0 commit comments

Comments
 (0)