From a32bc10981f3b9002752379ff6fb5f9b609fc662 Mon Sep 17 00:00:00 2001 From: Red J Adaya Date: Tue, 19 Mar 2024 12:44:11 +0800 Subject: [PATCH] Datepicker with dark and light theme support (#397) * datepicker. add electron-rebuild sa dev dependency * comments * use 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 --- public/themes/default.css | 8 + public/themes/light.css | 10 + src/app/common/elements/datepicker.less | 183 ++++++++++ src/app/common/elements/datepicker.tsx | 446 ++++++++++++++++++++++++ src/app/common/elements/index.tsx | 1 + src/app/history/history.tsx | 17 +- wavesrv/pkg/cmdrunner/cmdrunner.go | 3 +- 7 files changed, 656 insertions(+), 12 deletions(-) create mode 100644 src/app/common/elements/datepicker.less create mode 100644 src/app/common/elements/datepicker.tsx diff --git a/public/themes/default.css b/public/themes/default.css index a7c1ceb5..1715df51 100644 --- a/public/themes/default.css +++ b/public/themes/default.css @@ -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 */ } diff --git a/public/themes/light.css b/public/themes/light.css index 70179e14..1d22ccd1 100644 --- a/public/themes/light.css +++ b/public/themes/light.css @@ -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); } diff --git a/src/app/common/elements/datepicker.less b/src/app/common/elements/datepicker.less new file mode 100644 index 00000000..88b3a631 --- /dev/null +++ b/src/app/common/elements/datepicker.less @@ -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); + } +} diff --git a/src/app/common/elements/datepicker.tsx b/src/app/common/elements/datepicker.tsx new file mode 100644 index 00000000..f045eda9 --- /dev/null +++ b/src/app/common/elements/datepicker.tsx @@ -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; +} + +type DatePickerProps = { + selectedDate: Date; + onSelectDate: (date: Date) => void; + format?: string; +}; + +const DatePicker: React.FC = ({ 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(selDate.year()); + const yearRefs = useRef({}); + const wrapperRef = useRef(null); + const modalRef = useRef(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 ( +
+
{ + if (!showYearAccordion) { + setExpandedYear(selDate.year()); // Set expandedYear when opening accordion + } + setShowYearAccordion(!showYearAccordion); + }} + > + {selDate.format("MMMM YYYY")} + +
+ +
+ + +
+
+
+ ); + }; + + const renderDayHeaders = () => { + const daysOfWeek = ["S", "M", "T", "W", "T", "F", "S"]; // First letter of each day + return ( +
+ {daysOfWeek.map((day, i) => ( +
+ {day} +
+ ))} +
+ ); + }; + + 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( +
handleDayClick(dayDate.toDate())} + > + {i} +
+ ); + } + + // 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( +
handleDayClick(currentDate.toDate())} + > + {dayCount} +
+ ); + } + + // 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( +
handleDayClick(dayDate.toDate())} + > + {dayDate.date()} +
+ ); + } + + 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 ( +
+
+ {years.map((year) => ( +
+
setExpandedYear(year === expandedYear ? null : year)} + > + {year} +
+ +
+ {Array.from({ length: 12 }, (_, i) => i + 1).map((month) => ( +
handleMonthYearSelect(month, year)} + > + {dayjs(new Date(year, month - 1)).format("MMM")} +
+ ))} +
+
+
+ ))} +
+
+ ); + }; + + const toggleModal = () => { + setIsOpen((prevIsOpen) => !prevIsOpen); + setShowYearAccordion(false); + }; + + const dayPickerModal = isOpen + ? ReactDOM.createPortal( +
+ {renderHeader()} + {showYearAccordion && renderYearMonthAccordion()} + + <> + {renderDayHeaders()} +
{renderDays()}
+ +
+
, + 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 ( +
+ {formatParts.map((part, index) => { + const inputRef = inputRefs.current[part]; + + return ( + + {index > 0 && {delimiter}} + handleDatePartChange(part, e.target.value)} + onKeyDown={(e) => handleKeyDown(e, part)} + onMouseDown={handleMouseDown} + onFocus={handleFocus} + maxLength={part === "YYYY" ? 4 : 2} + className="date-input" + placeholder={part} + /> + + ); + })} + +
+ ); + }; + + return ( +
+ {renderDatePickerInput()} + {dayPickerModal} +
+ ); +}; + +export { DatePicker }; diff --git a/src/app/common/elements/index.tsx b/src/app/common/elements/index.tsx index ed4210c1..75f99280 100644 --- a/src/app/common/elements/index.tsx +++ b/src/app/common/elements/index.tsx @@ -17,3 +17,4 @@ export { TextField } from "./textfield"; export { Toggle } from "./toggle"; export { Tooltip } from "./tooltip"; export { TabIcon } from "./tabicon"; +export { DatePicker } from "./datepicker"; diff --git a/src/app/history/history.tsx b/src/app/history/history.tsx index 0f12f7e7..779f6ad7 100644 --- a/src/app/history/history.tsx +++ b/src/app/history/history.tsx @@ -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<{}, {}> { />
From: 
-
- -
+
0 { opts.FromTs = int64(fromTs)