Skip to content

Commit 768206c

Browse files
samejrclaudegithub-actions[bot]ericallam
authored
feat(dashboard): Upgrade to the dateTime filter UI (#2864)
UI/UX improvement to the date/time picker: - You can now choose a custom duration - Adds a new DateTimePicker.tsx component, using a new shadcn Calendar.tsx component - Clear UI separation between the 2 actions, choosing a duration or choosing date range - Adds new quick select options for picking a date range quickly https://github.com/user-attachments/assets/6b59b49d-2a56-4354-ad72-d8426437e56e --------- Co-authored-by: Claude <[email protected]> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Eric Allam <[email protected]>
1 parent 2c30c2d commit 768206c

File tree

6 files changed

+980
-290
lines changed

6 files changed

+980
-290
lines changed
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
5+
import { format } from "date-fns";
6+
import { DayPicker, useDayPicker } from "react-day-picker";
7+
import { cn } from "~/utils/cn";
8+
9+
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
10+
11+
const navButtonClass =
12+
"size-7 rounded-[3px] bg-secondary border border-charcoal-600 text-text-bright hover:bg-charcoal-600 hover:border-charcoal-550 transition inline-flex items-center justify-center";
13+
14+
function CustomMonthCaption({ calendarMonth }: { calendarMonth: { date: Date } }) {
15+
const { goToMonth, nextMonth, previousMonth } = useDayPicker();
16+
17+
return (
18+
<div className="flex w-full items-center justify-between px-1">
19+
<button
20+
type="button"
21+
className={navButtonClass}
22+
disabled={!previousMonth}
23+
onClick={() => previousMonth && goToMonth(previousMonth)}
24+
aria-label="Go to previous month"
25+
>
26+
<ChevronLeftIcon className="size-4" />
27+
</button>
28+
<div className="flex items-center gap-2">
29+
<select
30+
className="rounded border border-charcoal-600 bg-charcoal-750 px-2 py-1 text-sm text-text-bright focus:border-charcoal-500 focus:outline-none"
31+
value={calendarMonth.date.getMonth()}
32+
onChange={(e) => {
33+
const newDate = new Date(calendarMonth.date);
34+
newDate.setMonth(parseInt(e.target.value));
35+
goToMonth(newDate);
36+
}}
37+
>
38+
{Array.from({ length: 12 }, (_, i) => (
39+
<option key={i} value={i}>
40+
{format(new Date(2000, i), "MMM")}
41+
</option>
42+
))}
43+
</select>
44+
<select
45+
className="rounded border border-charcoal-600 bg-charcoal-750 px-2 py-1 text-sm text-text-bright focus:border-charcoal-500 focus:outline-none"
46+
value={calendarMonth.date.getFullYear()}
47+
onChange={(e) => {
48+
const newDate = new Date(calendarMonth.date);
49+
newDate.setFullYear(parseInt(e.target.value));
50+
goToMonth(newDate);
51+
}}
52+
>
53+
{Array.from({ length: 100 }, (_, i) => {
54+
const year = new Date().getFullYear() - 50 + i;
55+
return (
56+
<option key={year} value={year}>
57+
{year}
58+
</option>
59+
);
60+
})}
61+
</select>
62+
</div>
63+
<button
64+
type="button"
65+
className={navButtonClass}
66+
disabled={!nextMonth}
67+
onClick={() => nextMonth && goToMonth(nextMonth)}
68+
aria-label="Go to next month"
69+
>
70+
<ChevronRightIcon className="size-4" />
71+
</button>
72+
</div>
73+
);
74+
}
75+
76+
export function Calendar({
77+
className,
78+
classNames,
79+
showOutsideDays = true,
80+
...props
81+
}: CalendarProps) {
82+
return (
83+
<DayPicker
84+
showOutsideDays={showOutsideDays}
85+
weekStartsOn={1}
86+
className={cn("p-3", className)}
87+
classNames={{
88+
months: "flex flex-col sm:flex-row gap-2",
89+
month: "flex flex-col gap-4",
90+
month_caption: "flex justify-center pt-1 relative items-center w-full",
91+
caption_label: "sr-only",
92+
nav: "hidden",
93+
month_grid: "w-full border-collapse",
94+
weekdays: "flex",
95+
weekday: "text-text-dimmed rounded-md w-8 font-normal text-[0.8rem]",
96+
week: "flex w-full mt-2",
97+
day: "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-charcoal-700 [&:has([aria-selected].day-outside)]:bg-charcoal-700/50 [&:has([aria-selected].day-range-end)]:rounded-r-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md",
98+
day_button: cn(
99+
"size-8 p-0 font-normal text-text-bright rounded-md",
100+
"hover:bg-charcoal-700 hover:text-text-bright",
101+
"focus:bg-charcoal-700 focus:text-text-bright focus:outline-none",
102+
"aria-selected:opacity-100"
103+
),
104+
range_start: "day-range-start rounded-l-md",
105+
range_end: "day-range-end rounded-r-md",
106+
selected:
107+
"bg-indigo-600 text-text-bright hover:bg-indigo-600 hover:text-text-bright focus:bg-indigo-600 focus:text-text-bright rounded-md",
108+
today: "bg-charcoal-700 text-text-bright rounded-md",
109+
outside:
110+
"day-outside text-text-dimmed opacity-50 aria-selected:bg-charcoal-700/50 aria-selected:text-text-dimmed aria-selected:opacity-30",
111+
disabled: "text-text-dimmed opacity-50",
112+
range_middle: "aria-selected:bg-charcoal-700 aria-selected:text-text-bright",
113+
hidden: "invisible",
114+
dropdowns: "flex gap-2 items-center justify-center",
115+
dropdown:
116+
"bg-charcoal-750 border border-charcoal-600 rounded px-2 py-1 text-sm text-text-bright focus:outline-none focus:border-charcoal-500",
117+
...classNames,
118+
}}
119+
components={{
120+
MonthCaption: CustomMonthCaption,
121+
}}
122+
{...props}
123+
/>
124+
);
125+
}
126+
Calendar.displayName = "Calendar";

apps/webapp/app/components/primitives/DateField.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { BellAlertIcon, XMarkIcon } from "@heroicons/react/20/solid";
21
import { CalendarDateTime, createCalendar } from "@internationalized/date";
32
import { useDateField, useDateSegment } from "@react-aria/datepicker";
4-
import type { DateFieldState, DateSegment } from "@react-stately/datepicker";
5-
import { useDateFieldState } from "@react-stately/datepicker";
6-
import { Granularity } from "@react-types/datepicker";
3+
import {
4+
useDateFieldState,
5+
type DateFieldState,
6+
type DateSegment,
7+
} from "@react-stately/datepicker";
8+
import { type Granularity } from "@react-types/datepicker";
79
import { useEffect, useRef, useState } from "react";
810
import { cn } from "~/utils/cn";
911
import { Button } from "./Buttons";
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import { ChevronUpDownIcon } from "@heroicons/react/20/solid";
5+
import { format } from "date-fns";
6+
import { Calendar } from "./Calendar";
7+
import { Popover, PopoverContent, PopoverTrigger } from "./Popover";
8+
import { Button } from "./Buttons";
9+
import { cn } from "~/utils/cn";
10+
import { SimpleTooltip } from "./Tooltip";
11+
import { XIcon } from "lucide-react";
12+
13+
type DateTimePickerProps = {
14+
label: string;
15+
value?: Date;
16+
onChange?: (date: Date | undefined) => void;
17+
showSeconds?: boolean;
18+
showNowButton?: boolean;
19+
showClearButton?: boolean;
20+
showInlineLabel?: boolean;
21+
className?: string;
22+
};
23+
24+
export function DateTimePicker({
25+
label,
26+
value,
27+
onChange,
28+
showSeconds = true,
29+
showNowButton = false,
30+
showClearButton = false,
31+
showInlineLabel = false,
32+
className,
33+
}: DateTimePickerProps) {
34+
const [open, setOpen] = React.useState(false);
35+
36+
// Extract time parts from value
37+
const hours = value ? value.getHours().toString().padStart(2, "0") : "";
38+
const minutes = value ? value.getMinutes().toString().padStart(2, "0") : "";
39+
const seconds = value ? value.getSeconds().toString().padStart(2, "0") : "";
40+
const timeValue = showSeconds ? `${hours}:${minutes}:${seconds}` : `${hours}:${minutes}`;
41+
42+
const handleDateSelect = (date: Date | undefined) => {
43+
if (date) {
44+
// Preserve the time from the current value if it exists
45+
if (value) {
46+
date.setHours(value.getHours());
47+
date.setMinutes(value.getMinutes());
48+
date.setSeconds(value.getSeconds());
49+
}
50+
onChange?.(date);
51+
} else {
52+
onChange?.(undefined);
53+
}
54+
setOpen(false);
55+
};
56+
57+
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
58+
const timeString = e.target.value;
59+
if (!timeString) return;
60+
61+
const [h, m, s] = timeString.split(":").map(Number);
62+
const newDate = value ? new Date(value) : new Date();
63+
newDate.setHours(h || 0);
64+
newDate.setMinutes(m || 0);
65+
newDate.setSeconds(s || 0);
66+
onChange?.(newDate);
67+
};
68+
69+
const handleNowClick = () => {
70+
onChange?.(new Date());
71+
};
72+
73+
const handleClearClick = () => {
74+
onChange?.(undefined);
75+
};
76+
77+
return (
78+
<div className={cn("flex items-center gap-2", className)}>
79+
{showInlineLabel && (
80+
<span className="w-6 shrink-0 text-right text-xxs text-charcoal-500">{label}</span>
81+
)}
82+
<Popover open={open} onOpenChange={setOpen}>
83+
<PopoverTrigger asChild>
84+
<button
85+
type="button"
86+
className={cn(
87+
"flex h-[1.8rem] w-full items-center justify-between gap-2 whitespace-nowrap rounded border border-charcoal-650 bg-charcoal-750 px-2 text-xs tabular-nums transition hover:border-charcoal-600",
88+
value ? "text-text-bright" : "text-text-dimmed"
89+
)}
90+
>
91+
{value ? format(value, "yyyy/MM/dd") : "Select date"}
92+
<ChevronUpDownIcon className="size-3.5 text-text-dimmed" />
93+
</button>
94+
</PopoverTrigger>
95+
<PopoverContent className="w-auto p-0" align="start">
96+
<Calendar
97+
mode="single"
98+
selected={value}
99+
onSelect={handleDateSelect}
100+
captionLayout="dropdown"
101+
/>
102+
</PopoverContent>
103+
</Popover>
104+
<input
105+
type="time"
106+
step={showSeconds ? "1" : "60"}
107+
value={value ? timeValue : ""}
108+
onChange={handleTimeChange}
109+
className={cn(
110+
"h-[1.8rem] rounded border border-charcoal-650 bg-charcoal-750 px-2 text-xs tabular-nums transition hover:border-charcoal-600",
111+
value ? "text-text-bright" : "text-text-dimmed",
112+
"focus:border-charcoal-500 focus:outline-none",
113+
"[&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
114+
)}
115+
aria-label={`${label} time`}
116+
/>
117+
{showNowButton && (
118+
<Button
119+
type="button"
120+
variant="secondary/small"
121+
className="h-[1.8rem]"
122+
onClick={handleNowClick}
123+
>
124+
Now
125+
</Button>
126+
)}
127+
{showClearButton && (
128+
<SimpleTooltip
129+
button={
130+
<button
131+
type="button"
132+
className="flex h-[1.8rem] items-center justify-center px-1 text-text-dimmed transition hover:text-text-bright"
133+
onClick={handleClearClick}
134+
>
135+
<XIcon className="size-3.5" />
136+
</button>
137+
}
138+
content="Clear"
139+
disableHoverableContent
140+
asChild
141+
/>
142+
)}
143+
</div>
144+
);
145+
}

0 commit comments

Comments
 (0)