Datepicker with dark and light theme support (#397)

* datepicker. add electron-rebuild sa dev dependency

* comments

* use <If> component for conditional renders

* invoke callback on month change

* individual inputs for date parts

* keyboard support for input

* more on keyboard support

* dynamic setting of input width based on their text value

* remove error if fromts is out of range

* format is optional

* undo electron-rebuild
This commit is contained in:
Red J Adaya 2024-03-19 12:44:11 +08:00 committed by GitHub
parent 631b9b89b9
commit a32bc10981
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 656 additions and 12 deletions

View File

@ -223,4 +223,12 @@
--modal-header-bottom-border-color: rgba(241, 246, 243, 0.15);
--logo-button-hover-bg-color: var(--app-accent-bg-color);
--xterm-viewport-border-color: rgba(241, 246, 243, 0.15);
--datepicker-cell-hover-bg-color: rgba(255, 255, 255, 0.06);
--datepicker-cell-other-text-color: rgba(255, 255, 255, 0.3);
--datepicker-header-fade-color: rgba(255, 255, 255, 0.4);
--datepicker-year-header-bg-color: rgba(255, 255, 255, 0.2); /* Light grey background */
--datepicker-year-header-border-color: rgba(241, 246, 243, 0.15); /* Light grey border */
}

View File

@ -71,6 +71,16 @@
--line-actions-inactive-color: rgba(0, 0, 0, 0.3);
--line-actions-active-color: rgba(0, 0, 0, 1);
--logo-button-hover-bg-color: #f0f0f0;
--xterm-viewport-border-color: rgba(0, 0, 0, 0.3);
--datepicker-cell-hover-bg-color: #f0f0f0;
--datepicker-cell-other-text-color: rgba(0, 0, 0, 0.3);
--datepicker-header-fade-color: rgba(0, 0, 0, 0.4);
--datepicker-year-header-bg-color: #f5f5f5;
--datepicker-year-header-border-color: #dcdcdc;
/* toggle colors */
--toggle-thumb-color: var(--app-bg-color);
}

View File

@ -0,0 +1,183 @@
.day-picker-modal {
background-color: var(--form-element-bg-color);
z-index: 1000;
width: 250px;
height: 293px;
font-size: 13px;
border: 1px solid var(--form-element-border-color);
border-radius: 4px;
color: var(--form-element-text-color);
}
.day-picker-input {
cursor: default;
border: 1px solid var(--form-element-border-color) !important;
height: 34px;
user-select: none;
display: flex;
border-radius: 4px;
justify-content: space-between;
align-items: center;
padding: 0 15px !important;
display: flex;
align-items: center;
justify-content: center;
input {
outline: none !important;
border: none !important;
height: 100%;
padding: 0 !important;
cursor: default !important;
}
.fa-calendar {
margin-left: 10px;
cursor: pointer;
}
}
.day-picker-header {
color: var(--form-element-text-color);
padding: 10px 10px 0;
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
position: sticky;
div {
cursor: default;
font-weight: bold;
user-select: none;
&.fade {
color: var(--datepicker-header-fade-color);
}
}
.arrows {
display: flex;
.wave-button {
padding: 5px !important;
font-size: 18px !important;
cursor: default !important;
}
}
}
.day-header {
display: flex;
color: var(--form-element-text-color);
padding: 0 10px;
.day-header-cell {
flex: 1;
padding: 10px;
color: var(--form-element-text-color);
text-align: center;
font-weight: bold;
user-select: none;
}
}
.day-picker {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0;
padding: 0 10px 10px;
.day {
padding: 10px 4px;
color: var(--form-element-text-color);
text-align: center;
cursor: default;
border-radius: 3px;
&:hover {
background-color: var(--datepicker-cell-hover-bg-color);
}
&.selected {
background-color: var(--form-element-primary-color);
font-weight: bold;
}
}
.other-month {
color: var(--datepicker-cell-other-text-color);
}
}
.year-month-accordion-wrapper {
overflow-y: auto;
height: 243px;
margin-top: 8px;
padding: 0 10px 10px;
.year-month-accordion {
color: var(--form-element-text-color);
}
.year-header {
padding: 5px 10px;
background-color: var(--datepicker-year-header-bg-color);
border-bottom: 1px solid var(--datepicker-year-header-border-color);
margin-bottom: 5px;
font-weight: bold;
text-align: center;
cursor: default;
color: var(--form-element-text-color);
user-select: none;
&:last-child {
margin-bottom: 0;
}
}
.month-container {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 5px;
padding: 10px;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
cursor: default;
}
.month {
padding: 5px;
text-align: center;
border-radius: 3px;
color: var(--form-element-text-color);
&:hover {
background-color: var(--datepicker-cell-hover-bg-color);
}
&.selected {
background-color: var(--form-element-primary-color);
}
}
.expanded {
max-height: 500px;
}
}
.dropdown-arrow {
display: inline-block;
margin-left: 5px;
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid var(--form-element-text-color);
vertical-align: middle;
user-select: none;
&.fade {
border-top: 5px solid var(--datepicker-header-fade-color);
}
}

View File

@ -0,0 +1,446 @@
import React, { useState, useEffect, useRef, createRef } from "react";
import ReactDOM from "react-dom";
import dayjs from "dayjs";
import cs from "classnames";
import { Button } from "@/elements";
import { If } from "tsx-control-statements/components";
import "./datepicker.less";
interface YearRefs {
[key: number]: React.RefObject<HTMLDivElement>;
}
type DatePickerProps = {
selectedDate: Date;
onSelectDate: (date: Date) => void;
format?: string;
};
const DatePicker: React.FC<DatePickerProps> = ({ selectedDate, format = "MM/DD/YYYY", onSelectDate }) => {
const [isOpen, setIsOpen] = useState(false);
const [selDate, setSelDate] = useState(dayjs(selectedDate)); // Initialize with dayjs object
const [showYearAccordion, setShowYearAccordion] = useState(false);
const [expandedYear, setExpandedYear] = useState<number | null>(selDate.year());
const yearRefs = useRef<YearRefs>({});
const wrapperRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const calendarIconRef = useRef(null);
const inputRefs = useRef({ YYYY: null, MM: null, DD: null });
// Extract delimiter using regex
const delimiter = format.replace(/[0-9YMD]/g, "")[0] || "/";
// Split format and create state for each part
const formatParts = format.split(delimiter);
const [dateParts, setDateParts] = useState({
YYYY: selDate.format("YYYY"),
MM: selDate.format("MM"),
DD: selDate.format("DD"),
});
useEffect(() => {
inputRefs.current = {
YYYY: createRef(),
MM: createRef(),
DD: createRef(),
};
}, []);
useEffect(() => {
if (showYearAccordion && expandedYear && yearRefs.current[expandedYear]) {
yearRefs.current[expandedYear].current?.scrollIntoView({
block: "nearest",
});
}
}, [showYearAccordion, expandedYear]);
useEffect(() => {
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
useEffect(() => {
setDateParts({
YYYY: selDate.format("YYYY"),
MM: selDate.format("MM"),
DD: selDate.format("DD"),
});
}, [selDate]);
const handleClickOutside = (event: MouseEvent) => {
// Check if the click is on the calendar icon
if (calendarIconRef.current && calendarIconRef.current.contains(event.target as Node)) {
// Click is on the calendar icon, do nothing
return;
}
// Check if the click is outside the modal
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
setIsOpen(false); // Close the modal
}
};
const handleDayClick = (date: Date) => {
const newDate = dayjs(date);
setSelDate(newDate); // Update selDate with the new dayjs object
onSelectDate && onSelectDate(date); // Call parent's onSelectDate
setIsOpen(false); // Close the picker
};
const changeMonth = (delta: number) => {
const newDate = selDate.add(delta, "month");
setSelDate(newDate);
onSelectDate && onSelectDate(newDate.toDate());
};
const renderHeader = () => {
return (
<div className="day-picker-header">
<div
className={cs({ fade: showYearAccordion })}
onClick={() => {
if (!showYearAccordion) {
setExpandedYear(selDate.year()); // Set expandedYear when opening accordion
}
setShowYearAccordion(!showYearAccordion);
}}
>
{selDate.format("MMMM YYYY")}
<span className={cs("dropdown-arrow", { fade: showYearAccordion })}></span>
</div>
<If condition={!showYearAccordion}>
<div className="arrows">
<Button className="secondary ghost" onClick={() => changeMonth(-1)}>
&uarr;
</Button>
<Button className="secondary ghost" onClick={() => changeMonth(1)}>
&darr;
</Button>
</div>
</If>
</div>
);
};
const renderDayHeaders = () => {
const daysOfWeek = ["S", "M", "T", "W", "T", "F", "S"]; // First letter of each day
return (
<div className="day-header">
{daysOfWeek.map((day, i) => (
<div key={`${day}-${i}`} className="day-header-cell">
{day}
</div>
))}
</div>
);
};
const renderDays = () => {
const days = [];
const startDay = selDate.startOf("month");
const endDay = selDate.endOf("month");
const startDate = startDay.day(); // 0 for Sunday, 1 for Monday, ..., 6 for Saturday
// Previous month's filler days
const previousMonth = startDay.subtract(1, "month");
const daysInPreviousMonth = previousMonth.daysInMonth();
for (let i = daysInPreviousMonth - startDate + 1; i <= daysInPreviousMonth; i++) {
const dayDate = previousMonth.date(i);
days.push(
<div
key={`prev-month-day-${i}`}
className="day other-month"
onClick={() => handleDayClick(dayDate.toDate())}
>
{i}
</div>
);
}
// Current month's days
for (
let dayCount = 1;
startDay.add(dayCount - 1, "day").isBefore(endDay) ||
startDay.add(dayCount - 1, "day").isSame(endDay, "day");
dayCount++
) {
const currentDate = startDay.add(dayCount - 1, "day");
days.push(
<div
key={dayCount}
className={`day ${selDate.isSame(currentDate, "day") ? "selected" : ""}`}
onClick={() => handleDayClick(currentDate.toDate())}
>
{dayCount}
</div>
);
}
// Next month's filler days
while (days.length < 42) {
const fillerDayCount = days.length - daysInPreviousMonth - endDay.date();
const dayDate = endDay.add(fillerDayCount + 1, "day");
days.push(
<div
key={`next-month-day-${dayDate.format("YYYY-MM-DD")}`}
className="day other-month"
onClick={() => handleDayClick(dayDate.toDate())}
>
{dayDate.date()}
</div>
);
}
return days;
};
const calculatePosition = (): React.CSSProperties => {
if (wrapperRef.current) {
const rect = wrapperRef.current.getBoundingClientRect();
return {
position: "absolute",
top: `${rect.bottom + window.scrollY + 2}px`,
left: `${rect.left + window.scrollX}px`,
};
}
return {};
};
const populateYears = () => {
const currentYear = dayjs().year();
const startYear = currentYear - 10;
const endYear = currentYear + 10;
const yearsRange = [];
for (let year = startYear; year <= endYear; year++) {
yearsRange.push(year);
yearRefs.current[year] = React.createRef();
}
return yearsRange;
};
const handleMonthYearSelect = (month: number, year: number) => {
const newDate = dayjs(new Date(year, month - 1));
setSelDate(newDate);
setShowYearAccordion(false); // Close accordion
onSelectDate && onSelectDate(newDate.toDate());
};
const renderYearMonthAccordion = () => {
const years = populateYears();
const currentYear = selDate.year();
return (
<div className="year-month-accordion-wrapper">
<div className="year-month-accordion">
{years.map((year) => (
<div key={year} ref={yearRefs.current[year]}>
<div
className="year-header"
data-year={year}
onClick={() => setExpandedYear(year === expandedYear ? null : year)}
>
{year}
</div>
<If condition={expandedYear === year}>
<div
className={cs("month-container", {
expanded: expandedYear === year,
})}
>
{Array.from({ length: 12 }, (_, i) => i + 1).map((month) => (
<div
key={month}
className={cs("month", {
selected: year === currentYear && month === selDate.month() + 1,
})}
onClick={() => handleMonthYearSelect(month, year)}
>
{dayjs(new Date(year, month - 1)).format("MMM")}
</div>
))}
</div>
</If>
</div>
))}
</div>
</div>
);
};
const toggleModal = () => {
setIsOpen((prevIsOpen) => !prevIsOpen);
setShowYearAccordion(false);
};
const dayPickerModal = isOpen
? ReactDOM.createPortal(
<div ref={modalRef} className="day-picker-modal" style={calculatePosition()}>
{renderHeader()}
{showYearAccordion && renderYearMonthAccordion()}
<If condition={!showYearAccordion}>
<>
{renderDayHeaders()}
<div className="day-picker">{renderDays()}</div>
</>
</If>
</div>,
document.getElementById("app")!
)
: null;
const handleDatePartChange = (part, value) => {
const newDateParts = { ...dateParts, [part]: value };
setDateParts(newDateParts);
// Construct a new date from the updated parts
const newDate = dayjs(`${newDateParts.YYYY}-${newDateParts.MM}-${newDateParts.DD}`);
if (newDate.isValid()) {
onSelectDate(newDate.toDate()); // Call onSelectDate with the new date
}
};
const handleArrowNavigation = (event, currentPart) => {
const currentIndex = formatParts.indexOf(currentPart);
let targetInput;
if (event.key === "ArrowLeft" && currentIndex > 0) {
targetInput = inputRefs.current[formatParts[currentIndex - 1]].current;
} else if (event.key === "ArrowRight" && currentIndex < formatParts.length - 1) {
targetInput = inputRefs.current[formatParts[currentIndex + 1]].current;
}
if (targetInput) {
targetInput.focus();
}
};
const handleKeyDown = (event, currentPart) => {
const key = event.key;
if (key === "ArrowLeft" || key === "ArrowRight") {
// Handle arrow navigation without selecting text
handleArrowNavigation(event, currentPart);
return;
}
if (key === " ") {
// Handle spacebar press to toggle the modal
toggleModal();
return;
}
if (key.match(/[0-9]/)) {
// Handle numeric keys
event.preventDefault();
const maxLength = currentPart === "YYYY" ? 4 : 2;
const newValue = event.target.value.length < maxLength ? event.target.value + key : key;
let selectionTimeoutId = null;
handleDatePartChange(currentPart, newValue);
// Clear any existing timeout
if (selectionTimeoutId !== null) {
clearTimeout(selectionTimeoutId);
}
// Re-focus and select the input after state update
selectionTimeoutId = setTimeout(() => {
event.target.focus();
event.target.select();
}, 0);
}
};
const handleFocus = (event) => {
event.target.select();
};
// Prevent use from selecting text in the input
const handleMouseDown = (event) => {
event.preventDefault();
handleFocus(event);
};
const handleIconKeyDown = (event) => {
if (event.key === "Enter") {
toggleModal();
}
};
const setInputWidth = (inputRef, value) => {
const span = document.createElement("span");
document.body.appendChild(span);
span.style.font = "inherit";
span.style.visibility = "hidden";
span.style.position = "absolute";
span.textContent = value;
const textWidth = span.offsetWidth;
document.body.removeChild(span);
if (inputRef.current) {
inputRef.current.style.width = `${textWidth}px`;
}
};
useEffect(() => {
// This timeout ensures that the effect runs after the DOM updates
const timeoutId = setTimeout(() => {
formatParts.forEach((part) => {
const inputRef = inputRefs.current[part];
if (inputRef && inputRef.current) {
setInputWidth(inputRef, dateParts[part]);
}
});
}, 0);
return () => clearTimeout(timeoutId); // Cleanup timeout on unmount
}, []);
const renderDatePickerInput = () => {
return (
<div className="day-picker-input">
{formatParts.map((part, index) => {
const inputRef = inputRefs.current[part];
return (
<React.Fragment key={part}>
{index > 0 && <span>{delimiter}</span>}
<input
readOnly
ref={inputRef}
type="text"
value={dateParts[part]}
onChange={(e) => handleDatePartChange(part, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, part)}
onMouseDown={handleMouseDown}
onFocus={handleFocus}
maxLength={part === "YYYY" ? 4 : 2}
className="date-input"
placeholder={part}
/>
</React.Fragment>
);
})}
<i
ref={calendarIconRef}
className="fa-sharp fa-regular fa-calendar"
onClick={toggleModal}
onKeyDown={handleIconKeyDown}
tabIndex={0} // Makes the icon focusable
role="button" // Semantic role for accessibility
aria-label="Toggle date picker" // Accessible label for screen readers
/>
</div>
);
};
return (
<div ref={wrapperRef}>
{renderDatePickerInput()}
{dayPickerModal}
</div>
);
};
export { DatePicker };

View File

@ -17,3 +17,4 @@ export { TextField } from "./textfield";
export { Toggle } from "./toggle";
export { Tooltip } from "./tooltip";
export { TabIcon } from "./tabicon";
export { DatePicker } from "./datepicker";

View File

@ -14,7 +14,7 @@ import localizedFormat from "dayjs/plugin/localizedFormat";
import customParseFormat from "dayjs/plugin/customParseFormat";
import { Line } from "@/app/line/linecomps";
import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "@/util/keyutil";
import { TextField, Dropdown, Button } from "@/elements";
import { TextField, Dropdown, Button, DatePicker } from "@/elements";
import { ReactComponent as AngleDownIcon } from "@/assets/icons/history/angle-down.svg";
import { ReactComponent as ChevronLeftIcon } from "@/assets/icons/history/chevron-left.svg";
@ -286,15 +286,16 @@ class HistoryView extends React.Component<{}, {}> {
}
@boundMethod
handleFromTsChange(e: any): void {
handleFromTsChange(date: Date): void {
let hvm = GlobalModel.historyViewModel;
let newDate = e.target.value;
let newDate = dayjs(date).format("YYYY-MM-DD");
let today = dayjs().format("YYYY-MM-DD");
if (newDate == "" || newDate == today) {
hvm.setFromDate(null);
return;
}
hvm.setFromDate(e.target.value);
console.log;
hvm.setFromDate(newDate);
}
@boundMethod
@ -472,13 +473,7 @@ class HistoryView extends React.Component<{}, {}> {
/>
<div className="fromts">
<div className="fromts-text">From:&nbsp;</div>
<div className="hoverEffect">
<input
type="date"
onChange={this.handleFromTsChange}
value={this.searchFromTsInputValue()}
/>
</div>
<DatePicker selectedDate={new Date()} onSelectDate={this.handleFromTsChange} />
</div>
<div
className="filter-cmds search-checkbox hoverEffect"

View File

@ -3825,7 +3825,8 @@ func HistoryViewAllCommand(ctx context.Context, pk *scpacket.FeCommandPacketType
if pk.Kwargs["fromts"] != "" {
fromTs, err := resolvePosInt(pk.Kwargs["fromts"], 0)
if err != nil {
return nil, fmt.Errorf("invalid fromts (must be unixtime (milliseconds): %v", err)
// no error here anymore (otherwise it jams up the frontend, just ignore and set to 0)
opts.FromTs = 0
}
if fromTs > 0 {
opts.FromTs = int64(fromTs)