From 19d8cc7f9add25f06c1cee67d12552dd30053f10 Mon Sep 17 00:00:00 2001 From: Filip Hallqvist Date: Thu, 2 Oct 2025 12:28:23 +0200 Subject: [PATCH] feat: Add date range picker --- packages/component-library/package.json | 1 + .../src/DateRangeCalendar/index.module.scss | 189 +++++++++ .../src/DateRangeCalendar/index.stories.tsx | 65 +++ .../src/DateRangeCalendar/index.tsx | 63 +++ .../src/DateRangePicker/index.module.scss | 211 ++++++++++ .../src/DateRangePicker/index.stories.tsx | 104 +++++ .../src/DateRangePicker/index.tsx | 374 ++++++++++++++++++ .../src/TimeField/index.module.scss | 100 +++++ .../src/TimeField/index.stories.tsx | 71 ++++ .../component-library/src/TimeField/index.tsx | 58 +++ pnpm-lock.yaml | 53 ++- pnpm-workspace.yaml | 1 + 12 files changed, 1270 insertions(+), 20 deletions(-) create mode 100644 packages/component-library/src/DateRangeCalendar/index.module.scss create mode 100644 packages/component-library/src/DateRangeCalendar/index.stories.tsx create mode 100644 packages/component-library/src/DateRangeCalendar/index.tsx create mode 100644 packages/component-library/src/DateRangePicker/index.module.scss create mode 100644 packages/component-library/src/DateRangePicker/index.stories.tsx create mode 100644 packages/component-library/src/DateRangePicker/index.tsx create mode 100644 packages/component-library/src/TimeField/index.module.scss create mode 100644 packages/component-library/src/TimeField/index.stories.tsx create mode 100644 packages/component-library/src/TimeField/index.tsx diff --git a/packages/component-library/package.json b/packages/component-library/package.json index 2e54abadb6..26da6a484f 100644 --- a/packages/component-library/package.json +++ b/packages/component-library/package.json @@ -44,6 +44,7 @@ "@amplitude/analytics-browser": "catalog:", "@amplitude/plugin-autocapture-browser": "catalog:", "@axe-core/react": "catalog:", + "@internationalized/date": "catalog:", "@next/third-parties": "catalog:", "ag-grid-community": "catalog:", "ag-grid-react": "catalog:", diff --git a/packages/component-library/src/DateRangeCalendar/index.module.scss b/packages/component-library/src/DateRangeCalendar/index.module.scss new file mode 100644 index 0000000000..cdd2e88880 --- /dev/null +++ b/packages/component-library/src/DateRangeCalendar/index.module.scss @@ -0,0 +1,189 @@ +@use "../theme"; + +.dateRangeCalendar { + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(4); + color: theme.color("paragraph"); + font-size: theme.font-size("sm"); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: theme.spacing(2); +} + +.heading { + font-size: theme.font-size("lg"); + font-weight: theme.font-weight("semibold"); + text-align: center; + flex: 1; +} + +.navButton { + display: flex; + align-items: center; + justify-content: center; + width: theme.spacing(8); + height: theme.spacing(8); + border-radius: theme.border-radius("lg"); + background-color: transparent; + border: none; + cursor: pointer; + color: theme.color("paragraph"); + transition-property: background-color; + transition-duration: 100ms; + transition-timing-function: linear; + font-size: theme.spacing(4); + -webkit-tap-highlight-color: transparent; + + &[data-hovered] { + background-color: theme.color("button", "outline", "background", "hover"); + } + + &[data-pressed] { + background-color: theme.color("button", "outline", "background", "active"); + } + + &[data-disabled] { + cursor: not-allowed; + } +} + +.calendars { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: theme.spacing(6); +} + +.calendar { + display: table; + border-collapse: collapse; + border-spacing: 0; + + td { + padding: theme.spacing(0.5) 0; + } + + td:first-child .cell { + border-top-left-radius: theme.border-radius("lg"); + border-bottom-left-radius: theme.border-radius("lg"); + } + + td:last-child .cell { + border-top-right-radius: theme.border-radius("lg"); + border-bottom-right-radius: theme.border-radius("lg"); + } +} + +.calendarHeader { + display: table-header-group; +} + +.dayHeader { + display: table-cell; + text-align: center; + font-size: theme.font-size("xs"); + font-weight: theme.font-weight("medium"); + color: theme.color("muted"); + padding: theme.spacing(2); + width: theme.spacing(10); +} + +.calendarBody { + display: table-row-group; +} + +.cell { + display: table-cell; + text-align: center; + cursor: pointer; + outline: none; + color: theme.color("button", "outline", "foreground"); + padding: theme.spacing(1); + + .cellContent { + border-radius: theme.border-radius("lg"); + } + + &[data-outside-month] { + display: none; + } + + &[data-today] { + .cellContent { + border: 1px solid theme.color("button", "outline", "border"); + } + } + + &[data-outside-month] { + .cellContent { + color: theme.color("muted"); + } + } + + &[data-disabled] { + .cellContent { + background: theme.color("button", "disabled", "background"); + color: theme.color("button", "disabled", "foreground"); + cursor: not-allowed; + } + } + + &[data-unavailable] { + .cellContent { + text-decoration: line-through; + color: theme.color("states", "error", "normal"); + cursor: not-allowed; + } + } + + &[data-selected] { + background-color: theme.color("background", "card-highlight"); + } + + &[data-hovered] { + .cellContent { + background-color: theme.color("button", "outline", "background", "hover"); + } + } + + &[data-selection-start], + &[data-selection-end] { + .cellContent { + background-color: theme.color("button", "primary", "background", "normal"); + color: theme.color("button", "primary", "foreground"); + } + } + + &[data-selection-start] { + border-top-left-radius: theme.border-radius("lg"); + border-bottom-left-radius: theme.border-radius("lg"); + } + + &[data-selection-end] { + border-top-right-radius: theme.border-radius("lg"); + border-bottom-right-radius: theme.border-radius("lg"); + } + + &[data-focused] { + .cellContent { + outline: 2px solid theme.color("focus"); + outline-offset: 2px; + border-radius: theme.border-radius("lg"); + } + } +} + +.cellContent { + display: flex; + align-items: center; + justify-content: center; + width: theme.spacing(9); + height: theme.spacing(9); + transition: 100ms; +} + + diff --git a/packages/component-library/src/DateRangeCalendar/index.stories.tsx b/packages/component-library/src/DateRangeCalendar/index.stories.tsx new file mode 100644 index 0000000000..55efcd7420 --- /dev/null +++ b/packages/component-library/src/DateRangeCalendar/index.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { parseDate } from "@internationalized/date"; + +import { DateRangeCalendar as DateRangeCalendarComponent } from "./index.jsx"; + +const meta = { + component: DateRangeCalendarComponent, + argTypes: { + isDisabled: { + control: "boolean", + table: { + category: "Behavior", + }, + }, + isReadOnly: { + control: "boolean", + table: { + category: "Behavior", + }, + }, + isInvalid: { + control: "boolean", + table: { + category: "Behavior", + }, + }, + }, +} satisfies Meta; +export default meta; + +export const Default = { + args: { + isDisabled: false, + isReadOnly: false, + isInvalid: false, + defaultValue: { + start: parseDate("2025-05-01"), + end: parseDate("2025-05-13"), + }, + }, +} satisfies StoryObj; + +export const SingleDay = { + args: { + isDisabled: false, + isReadOnly: false, + defaultValue: { + start: parseDate("2025-05-13"), + end: parseDate("2025-05-13"), + }, + }, +} satisfies StoryObj; + +export const ReadOnly = { + args: { + isDisabled: false, + isReadOnly: true, + defaultValue: { + start: parseDate("2025-05-01"), + end: parseDate("2025-05-13"), + }, + }, +} satisfies StoryObj; + + diff --git a/packages/component-library/src/DateRangeCalendar/index.tsx b/packages/component-library/src/DateRangeCalendar/index.tsx new file mode 100644 index 0000000000..85eca1cf6e --- /dev/null +++ b/packages/component-library/src/DateRangeCalendar/index.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { CaretLeft } from "@phosphor-icons/react/dist/ssr/CaretLeft"; +import { CaretRight } from "@phosphor-icons/react/dist/ssr/CaretRight"; +import clsx from "clsx"; +import type { ComponentProps } from "react"; +import { + RangeCalendar, + Heading, + CalendarGrid, + CalendarGridBody, + CalendarCell, +} from "react-aria-components"; + +import styles from "./index.module.scss"; +import { Button as UnstyledButton } from "../unstyled/Button/index.jsx"; + +type Props = Omit< + ComponentProps, + "children" | "visibleDuration" +>; + +export const DateRangeCalendar = ({ className, ...props }: Props) => ( + +
+ + + + + + + +
+
+ + + {(date) => ( + + {({ formattedDate }) => ( +
{formattedDate}
+ )} +
+ )} +
+
+ + + {(date) => ( + + {({ formattedDate }) => ( +
{formattedDate}
+ )} +
+ )} +
+
+
+
+); diff --git a/packages/component-library/src/DateRangePicker/index.module.scss b/packages/component-library/src/DateRangePicker/index.module.scss new file mode 100644 index 0000000000..e88ed8f1f5 --- /dev/null +++ b/packages/component-library/src/DateRangePicker/index.module.scss @@ -0,0 +1,211 @@ +@use "../theme"; + +.dateRangePicker { + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(1); +} + +.labelText { + font-size: theme.font-size("sm"); + font-weight: theme.font-weight("medium"); + color: theme.color("paragraph"); + + &[data-hidden] { + @include theme.sr-only; + } +} + +.popover { + min-width: max-content; + background-color: theme.color("background", "modal"); + border-radius: theme.border-radius("xl"); + border: 1px solid theme.color("border"); + color: theme.color("paragraph"); + box-shadow: + 0 4px 6px -4px rgb(from black r g b / 10%), + 0 10px 15px -3px rgb(from black r g b / 10%); + outline: none; + + &[data-placement="top"] { + --origin: translateY(8px); + + transform-origin: bottom; + } + + &[data-placement="bottom"] { + --origin: translateY(-8px); + + transform-origin: top; + } + + &[data-entering] { + animation: popover-slide 200ms; + } + + &[data-exiting] { + animation: popover-slide 200ms reverse ease-in; + } +} + +.dialog { + display: flex; + gap: 0; + outline: none; + padding: 0; +} + +.presets { + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(1); + padding: theme.spacing(4); + border-right: 1px solid theme.color("border"); + min-width: theme.spacing(48); + background-color: theme.color("background", "secondary"); + border-top-left-radius: theme.border-radius("xl"); + border-bottom-left-radius: theme.border-radius("xl"); +} + +.presetsLabel { + font-size: theme.font-size("xxs"); + font-weight: theme.font-weight("medium"); + color: theme.color("muted"); + padding: theme.spacing(2) theme.spacing(3); + line-height: theme.spacing(4); +} + +.presetButton { + display: flex; + align-items: center; + justify-content: space-between; + padding: theme.spacing(2) theme.spacing(3); + border-radius: theme.border-radius("lg"); + background-color: transparent; + border: none; + cursor: pointer; + font-size: theme.font-size("sm"); + color: theme.color("paragraph"); + transition-property: background-color; + transition-duration: 100ms; + transition-timing-function: linear; + text-align: left; + gap: theme.spacing(2); + -webkit-tap-highlight-color: transparent; + + &[data-hovered] { + background-color: theme.color("button", "outline", "background", "hover"); + } + + &[data-pressed] { + background-color: theme.color("button", "outline", "background", "active"); + } + + &[data-selected] { + background-color: theme.color("button", "outline", "background", "hover"); + } +} + +.checkIcon { + width: theme.spacing(4); + height: theme.spacing(4); + color: theme.color("button", "primary", "background", "normal"); + flex-shrink: 0; +} + +.content { + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(4); + padding: theme.spacing(4); + padding-top: theme.spacing(5); +} + +.footer { + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(4); +} + +.includeTimeSwitch { + display: flex; + align-items: center; + gap: theme.spacing(2); + cursor: pointer; + -webkit-tap-highlight-color: transparent; + outline: none; + + &[data-focused] .switchIndicator { + outline: 2px solid theme.color("focus"); + outline-offset: 2px; + } +} + +.switchIndicator { + width: theme.spacing(11); + height: theme.spacing(6); + border-radius: theme.border-radius("full"); + background-color: theme.color("border"); + position: relative; + transition: background-color 200ms; + + &::before { + content: ""; + position: absolute; + top: theme.spacing(0.5); + left: theme.spacing(0.5); + width: theme.spacing(5); + height: theme.spacing(5); + border-radius: theme.border-radius("full"); + background-color: theme.color("background", "modal"); + transition: transform 200ms; + } + + .includeTimeSwitch[data-selected] & { + background-color: theme.color("button", "primary", "background", "normal"); + + &::before { + transform: translateX(theme.spacing(5)); + } + } +} + +.switchLabel { + font-size: theme.font-size("sm"); + color: theme.color("paragraph"); + user-select: none; +} + +.timeFields { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: theme.spacing(3); + align-items: end; +} + +.nowButton { + height: theme.spacing(10); + margin-bottom: 0; +} + +.actions { + display: flex; + gap: theme.spacing(2); + justify-content: flex-end; + padding-top: theme.spacing(2); + border-top: 1px solid theme.color("border"); +} + +@keyframes popover-slide { + from { + transform: var(--origin); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + + diff --git a/packages/component-library/src/DateRangePicker/index.stories.tsx b/packages/component-library/src/DateRangePicker/index.stories.tsx new file mode 100644 index 0000000000..abf7ef299d --- /dev/null +++ b/packages/component-library/src/DateRangePicker/index.stories.tsx @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { DateRangePicker as DateRangePickerComponent } from "./index.jsx"; + +const meta = { + component: DateRangePickerComponent, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + argTypes: { + label: { + control: "text", + table: { + category: "Label", + }, + }, + hideLabel: { + control: "boolean", + table: { + category: "Label", + }, + }, + buttonLabel: { + control: "text", + table: { + category: "Label", + }, + }, + variant: { + control: "select", + options: [ + "primary", + "secondary", + "solid", + "outline", + "ghost", + "success", + "danger", + ], + table: { + category: "Appearance", + }, + }, + size: { + control: "select", + options: ["xs", "sm", "md", "lg"], + table: { + category: "Appearance", + }, + }, + showPresets: { + control: "boolean", + table: { + category: "Behavior", + }, + }, + isDisabled: { + control: "boolean", + table: { + category: "Behavior", + }, + }, + onChange: { + table: { + category: "Behavior", + }, + }, + }, +} satisfies Meta; +export default meta; + +export const Default = { + args: { + label: "Select date range", + hideLabel: false, + variant: "outline", + size: "md", + showPresets: true, + isDisabled: false, + defaultValue: { + start: new Date(2025, 4, 13, 15, 28), + end: new Date(2025, 4, 13, 15, 28), + }, + }, +} satisfies StoryObj; + +export const NoPresets = { + args: { + label: "Select date range", + hideLabel: true, + variant: "outline", + size: "md", + showPresets: false, + isDisabled: false, + defaultValue: { + start: new Date(2025, 4, 1), + end: new Date(2025, 4, 13), + }, + }, +} satisfies StoryObj; \ No newline at end of file diff --git a/packages/component-library/src/DateRangePicker/index.tsx b/packages/component-library/src/DateRangePicker/index.tsx new file mode 100644 index 0000000000..bf026c377c --- /dev/null +++ b/packages/component-library/src/DateRangePicker/index.tsx @@ -0,0 +1,374 @@ +"use client"; + +import { + getLocalTimeZone, + parseAbsoluteToLocal, + toCalendarDateTime, +} from "@internationalized/date"; +import { CalendarBlank } from "@phosphor-icons/react/dist/ssr/CalendarBlank"; +import { Check } from "@phosphor-icons/react/dist/ssr/Check"; +import clsx from "clsx"; +import type { ComponentProps, ReactNode } from "react"; +import { useCallback, useMemo, useState } from "react"; +import type { + TimeValue, + DateRange as AriaDateRange +} from "react-aria-components"; +import { + Button as AriaButton, + Dialog, + DialogTrigger, + Group, + Label, + Popover, + Switch, +} from "react-aria-components"; + +import styles from "./index.module.scss"; +import { Button } from "../Button/index.jsx"; +import { DateRangeCalendar } from "../DateRangeCalendar/index.jsx"; +import { TimeField } from "../TimeField/index.jsx"; + +export type DateRange = { + start: Date; + end: Date; +}; + +type Preset = { + id: string; + label: string; + getValue: () => DateRange; +}; + +const DEFAULT_PRESETS: Preset[] = [ + { + id: "live", + label: "Live", + getValue: () => { + const end = new Date(); + return { start: end, end }; + }, + }, + { + id: "last-hour", + label: "Last hour", + getValue: () => { + const end = new Date(); + const start = new Date(end.getTime() - 60 * 60 * 1000); + return { start, end }; + }, + }, + { + id: "last-24-hours", + label: "Last 24 hours", + getValue: () => { + const end = new Date(); + const start = new Date(end.getTime() - 24 * 60 * 60 * 1000); + return { start, end }; + }, + }, + { + id: "last-7-days", + label: "Last 7 days", + getValue: () => { + const end = new Date(); + const start = new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000); + return { start, end }; + }, + }, + { + id: "last-month", + label: "Last month", + getValue: () => { + const end = new Date(); + const start = new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000); + return { start, end }; + }, + }, +]; + +type Props = { + label: string; + hideLabel?: boolean | undefined; + value?: DateRange | undefined; + defaultValue?: DateRange | undefined; + onChange?: ((value: DateRange) => void) | undefined; + presets?: Preset[] | undefined; + showPresets?: boolean | undefined; + variant?: ComponentProps["variant"]; + size?: ComponentProps["size"]; + className?: string | undefined; + buttonLabel?: ReactNode | undefined; + isDisabled?: boolean | undefined; +}; + +export const DateRangePicker = ({ + label, + hideLabel, + value: controlledValue, + defaultValue, + onChange, + presets = DEFAULT_PRESETS, + showPresets = true, + variant = "outline", + size = "md", + className, + buttonLabel, + isDisabled, +}: Props) => { + const [uncontrolledValue, setUncontrolledValue] = useState< + DateRange | undefined + >(defaultValue); + const [selectedPreset, setSelectedPreset] = useState(undefined); + const [isOpen, setIsOpen] = useState(false); + const [includeTime, setIncludeTime] = useState(true); + + const value = controlledValue ?? uncontrolledValue; + + const handleChange = useCallback( + (newValue: DateRange) => { + if (controlledValue === undefined) { + setUncontrolledValue(newValue); + } + onChange?.(newValue); + setSelectedPreset(undefined); + }, + [controlledValue, onChange], + ); + + const handlePresetSelect = useCallback( + (preset: Preset) => { + const newValue = preset.getValue(); + handleChange(newValue); + setSelectedPreset(preset.id); + setIsOpen(false); + }, + [handleChange], + ); + + const handleReset = useCallback(() => { + const resetValue = defaultValue ?? { + start: new Date(), + end: new Date(), + }; + handleChange(resetValue); + setSelectedPreset(undefined); + }, [defaultValue, handleChange]); + + const handleApply = useCallback(() => { + setIsOpen(false); + }, []); + + const handleSetEndToNow = useCallback(() => { + if (value) { + handleChange({ ...value, end: new Date() }); + } + }, [value, handleChange]); + + const ariaDateRange = useMemo(() => { + if (!value) return null; // eslint-disable-line unicorn/no-null + return { + start: toCalendarDateTime( + parseAbsoluteToLocal(value.start.toISOString()), + ), + end: toCalendarDateTime(parseAbsoluteToLocal(value.end.toISOString())), + } + }, [value]); + + const startTime = useMemo(() => { + if (!value) return null; // eslint-disable-line unicorn/no-null + const date = parseAbsoluteToLocal(value.start.toISOString()); + return toCalendarDateTime(date) + }, [value]); + + const endTime = useMemo(() => { + if (!value) return null; // eslint-disable-line unicorn/no-null + const date = parseAbsoluteToLocal(value.end.toISOString()); + return toCalendarDateTime(date) + }, [value]); + + const handleCalendarChange = useCallback( + (newRange: AriaDateRange | null) => { + if (!newRange || !value) return; + + const startDate = newRange.start.toDate(getLocalTimeZone()); + const endDate = newRange.end.toDate(getLocalTimeZone()); + + // Preserve the time from the current value + const start = new Date( + startDate.getFullYear(), + startDate.getMonth(), + startDate.getDate(), + value.start.getHours(), + value.start.getMinutes(), + value.start.getSeconds(), + ); + + const end = new Date( + endDate.getFullYear(), + endDate.getMonth(), + endDate.getDate(), + value.end.getHours(), + value.end.getMinutes(), + value.end.getSeconds(), + ); + + handleChange({ start, end }); + }, + [value, handleChange], + ); + + const handleStartTimeChange = useCallback( + (newTime: TimeValue | null) => { + if (!newTime || !value) return; + + const start = new Date(value.start); + start.setHours(newTime.hour); + start.setMinutes(newTime.minute); + start.setSeconds(0); + start.setMilliseconds(0); + + handleChange({ ...value, start }); + }, + [value, handleChange], + ); + + const handleEndTimeChange = useCallback( + (newTime: TimeValue | null) => { + if (!newTime || !value) return; + + const end = new Date(value.end); + end.setHours(newTime.hour); + end.setMinutes(newTime.minute); + end.setSeconds(0); + end.setMilliseconds(0); + + handleChange({ ...value, end }); + }, + [value, handleChange], + ); + + const displayValue = useMemo(() => { + if (!value) return "Select date range"; + if (buttonLabel) return buttonLabel; + + const formatDate = (date: Date) => { + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); + }; + + const formatTime = (date: Date) => { + return date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + }; + + return `${formatDate(value.start)} ${formatTime(value.start)} - ${formatDate(value.end)} ${formatTime(value.end)}`; + }, [value, buttonLabel]); + + return ( + + + + + + + {showPresets && ( +
+
DATE TIME
+ {presets.map((preset) => ( + { + handlePresetSelect(preset); + }} + data-selected={ + selectedPreset === preset.id ? "" : undefined + } + > + {preset.label} + {selectedPreset === preset.id && ( + + )} + + ))} +
+ )} +
+ +
+ +
+ Include time + + {includeTime && ( +
+ + + +
+ )} +
+
+ + +
+
+
+
+
+
+ ); +}; + +export { DEFAULT_PRESETS }; +export type { Preset }; + diff --git a/packages/component-library/src/TimeField/index.module.scss b/packages/component-library/src/TimeField/index.module.scss new file mode 100644 index 0000000000..5315d327de --- /dev/null +++ b/packages/component-library/src/TimeField/index.module.scss @@ -0,0 +1,100 @@ +@use "../theme"; + +.timeField { + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(1); + + &[data-label-hidden] .label { + @include theme.sr-only; + } +} + +.label { + font-size: theme.font-size("sm"); + font-weight: theme.font-weight("medium"); + color: theme.color("paragraph"); +} + +.inputWrapper { + position: relative; + display: flex; + align-items: center; + gap: theme.spacing(2); + padding: 0 theme.spacing(3); + height: theme.spacing(10); + background-color: theme.color("background", "secondary"); + border: 1px solid theme.color("border"); + border-radius: theme.border-radius("lg"); + transition-property: border-color, outline-color; + transition-duration: 100ms; + transition-timing-function: linear; + outline: theme.spacing(1) solid transparent; + outline-offset: 0; + + &:has(.input[data-focus-within]) { + border-color: theme.color("focus"); + outline-color: theme.color("focus-dim"); + } +} + +.icon { + width: theme.spacing(4); + height: theme.spacing(4); + color: theme.color("muted"); + flex-shrink: 0; +} + +.input { + display: flex; + flex: 1; + gap: theme.spacing(1); + outline: none; + font-size: theme.font-size("sm"); + font-variant-numeric: tabular-nums; +} + +.segment { + padding: theme.spacing(0.5) theme.spacing(1); + border-radius: theme.border-radius("md"); + color: theme.color("paragraph"); + outline: none; + + &[data-placeholder] { + color: theme.color("muted"); + } + + &[data-type="literal"] { + padding: 0; + color: theme.color("muted"); + } + + &[data-focused] { + background-color: theme.color("button", "primary", "background", "normal"); + color: theme.color("button", "primary", "foreground"); + } +} + +.clearButton { + background: transparent; + border: none; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: theme.color("muted"); + transition: color 100ms linear; + -webkit-tap-highlight-color: transparent; + + &[data-hovered] { + color: theme.color("paragraph"); + } +} + +.clearIcon { + width: theme.spacing(4); + height: theme.spacing(4); +} + + diff --git a/packages/component-library/src/TimeField/index.stories.tsx b/packages/component-library/src/TimeField/index.stories.tsx new file mode 100644 index 0000000000..3d15f752f5 --- /dev/null +++ b/packages/component-library/src/TimeField/index.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { TimeField as TimeFieldComponent } from "./index.jsx"; + +const meta = { + component: TimeFieldComponent, + argTypes: { + label: { + control: "text", + table: { + category: "Label", + }, + }, + hideLabel: { + control: "boolean", + table: { + category: "Label", + }, + }, + isDisabled: { + control: "boolean", + table: { + category: "Behavior", + }, + }, + isReadOnly: { + control: "boolean", + table: { + category: "Behavior", + }, + }, + isRequired: { + control: "boolean", + table: { + category: "Behavior", + }, + }, + }, +} satisfies Meta; +export default meta; + +export const Default = { + args: { + label: "Start time", + hideLabel: false, + isDisabled: false, + isReadOnly: false, + isRequired: false, + }, +} satisfies StoryObj; + +export const WithClearButton = { + args: { + label: "End time", + hideLabel: false, + isDisabled: false, + onClear: () => { + console.log("Clear clicked"); + }, + }, +} satisfies StoryObj; + +export const HiddenLabel = { + args: { + label: "Time", + hideLabel: true, + isDisabled: false, + }, +} satisfies StoryObj; + + diff --git a/packages/component-library/src/TimeField/index.tsx b/packages/component-library/src/TimeField/index.tsx new file mode 100644 index 0000000000..2cb3d82746 --- /dev/null +++ b/packages/component-library/src/TimeField/index.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { Clock } from "@phosphor-icons/react/dist/ssr/Clock"; +import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle"; +import clsx from "clsx"; +import type { ComponentProps } from "react"; +import { + TimeField as BaseTimeField, + DateInput, + Label, +} from "react-aria-components"; + +import styles from "./index.module.scss"; +import { Button } from "../unstyled/Button/index.jsx"; + +type Props = Omit, "children"> & { + label: string; + hideLabel?: boolean | undefined; + onClear?: (() => void) | undefined; +}; + +export const TimeField = ({ + className, + label, + hideLabel, + onClear, + ...props +}: Props) => ( + + +
+ + + {(segment) => ( +
+ {segment.text} +
+ )} +
+ {onClear && ( + + )} +
+
+); + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66e2d13862..538927b451 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ catalogs: '@heroicons/react': specifier: ^2.2.0 version: 2.2.0 + '@internationalized/date': + specifier: ^3.9.0 + version: 3.9.0 '@mysten/sui': specifier: ^1.3.0 version: 1.26.1 @@ -1431,7 +1434,7 @@ importers: dependencies: '@certusone/wormhole-sdk': specifier: ^0.9.8 - version: 0.9.24(bufferutil@4.0.9)(encoding@0.1.13)(google-protobuf@3.21.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.2)(utf-8-validate@5.0.10) + version: 0.9.24(@types/react@19.1.0)(bufferutil@4.0.9)(encoding@0.1.13)(google-protobuf@3.21.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.2)(utf-8-validate@5.0.10) '@coral-xyz/anchor': specifier: ^0.29.0 version: 0.29.0(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10) @@ -1781,7 +1784,7 @@ importers: dependencies: '@certusone/wormhole-sdk': specifier: ^0.10.15 - version: 0.10.18(bufferutil@4.0.9)(encoding@0.1.13)(google-protobuf@3.21.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@4.9.5)(utf-8-validate@5.0.10) + version: 0.10.18(@types/react@19.1.0)(bufferutil@4.0.9)(encoding@0.1.13)(google-protobuf@3.21.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@4.9.5)(utf-8-validate@5.0.10) '@coral-xyz/anchor': specifier: ^0.29.0 version: 0.29.0(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10) @@ -2166,6 +2169,9 @@ importers: '@axe-core/react': specifier: 'catalog:' version: 4.10.1 + '@internationalized/date': + specifier: 'catalog:' + version: 3.9.0 '@next/third-parties': specifier: 'catalog:' version: 15.3.2(next@15.5.0(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@19.1.0-rc.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react@19.1.0) @@ -2728,7 +2734,7 @@ importers: dependencies: '@certusone/wormhole-sdk': specifier: ^0.9.22 - version: 0.9.24(bufferutil@4.0.7)(encoding@0.1.13)(google-protobuf@3.21.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.2)(utf-8-validate@6.0.3) + version: 0.9.24(@types/react@19.1.0)(bufferutil@4.0.7)(encoding@0.1.13)(google-protobuf@3.21.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.2)(utf-8-validate@6.0.3) '@matterlabs/hardhat-zksync': specifier: ^1.1.0 version: 1.5.0(72d1c37dadfb409731b518bd145750dd) @@ -3110,7 +3116,7 @@ importers: dependencies: '@certusone/wormhole-sdk': specifier: ^0.9.12 - version: 0.9.24(bufferutil@4.0.9)(encoding@0.1.13)(google-protobuf@3.21.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.2)(utf-8-validate@5.0.10) + version: 0.9.24(@types/react@19.1.0)(bufferutil@4.0.9)(encoding@0.1.13)(google-protobuf@3.21.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.2)(utf-8-validate@5.0.10) '@mysten/sui': specifier: ^1.3.0 version: 1.26.1(typescript@5.8.2) @@ -3147,7 +3153,7 @@ importers: dependencies: '@certusone/wormhole-sdk': specifier: ^0.9.12 - version: 0.9.24(bufferutil@4.0.9)(encoding@0.1.13)(google-protobuf@3.21.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.2)(utf-8-validate@5.0.10) + version: 0.9.24(@types/react@19.1.0)(bufferutil@4.0.9)(encoding@0.1.13)(google-protobuf@3.21.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.2)(utf-8-validate@5.0.10) '@iota/iota-sdk': specifier: ^0.5.0 version: 0.5.0(typescript@5.8.2) @@ -6028,6 +6034,9 @@ packages: '@internationalized/date@3.8.2': resolution: {integrity: sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==} + '@internationalized/date@3.9.0': + resolution: {integrity: sha512-yaN3brAnHRD+4KyyOsJyk49XUvj2wtbNACSqg0bz3u8t2VuzhC8Q5dfRnrSxjnnbDb+ienBnkn1TzQfE154vyg==} + '@internationalized/message@3.1.8': resolution: {integrity: sha512-Rwk3j/TlYZhn3HQ6PyXUV0XP9Uv42jqZGNegt0BXlxjE6G3+LwHjbQZAGHhCnCPdaA6Tvd3ma/7QzLlLkJxAWA==} @@ -24799,14 +24808,14 @@ snapshots: '@types/long': 4.0.2 '@types/node': 18.19.86 - '@certusone/wormhole-sdk@0.10.18(@types/react@19.1.0)(bufferutil@4.0.9)(encoding@0.1.13)(google-protobuf@3.21.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.2)(utf-8-validate@5.0.10)': + '@certusone/wormhole-sdk@0.10.18(@types/react@19.1.0)(bufferutil@4.0.9)(encoding@0.1.13)(google-protobuf@3.21.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@4.9.5)(utf-8-validate@5.0.10)': dependencies: '@certusone/wormhole-sdk-proto-web': 0.0.7(google-protobuf@3.21.4) '@certusone/wormhole-sdk-wasm': 0.0.1 '@coral-xyz/borsh': 0.2.6(@solana/web3.js@1.98.0(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10)) '@mysten/sui.js': 0.32.2(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@project-serum/anchor': 0.25.0(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10) - '@solana/spl-token': 0.3.11(@solana/web3.js@1.98.0(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.2)(utf-8-validate@5.0.10) + '@solana/spl-token': 0.3.11(@solana/web3.js@1.98.0(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@4.9.5)(utf-8-validate@5.0.10) '@solana/web3.js': 1.98.0(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10) '@terra-money/terra.js': 3.1.9 '@xpla/xpla.js': 0.2.3 @@ -24837,14 +24846,14 @@ snapshots: - typescript - utf-8-validate - '@certusone/wormhole-sdk@0.10.18(bufferutil@4.0.9)(encoding@0.1.13)(google-protobuf@3.21.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@4.9.5)(utf-8-validate@5.0.10)': + '@certusone/wormhole-sdk@0.10.18(@types/react@19.1.0)(bufferutil@4.0.9)(encoding@0.1.13)(google-protobuf@3.21.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.2)(utf-8-validate@5.0.10)': dependencies: '@certusone/wormhole-sdk-proto-web': 0.0.7(google-protobuf@3.21.4) '@certusone/wormhole-sdk-wasm': 0.0.1 '@coral-xyz/borsh': 0.2.6(@solana/web3.js@1.98.0(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10)) '@mysten/sui.js': 0.32.2(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@project-serum/anchor': 0.25.0(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10) - '@solana/spl-token': 0.3.11(@solana/web3.js@1.98.0(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@4.9.5)(utf-8-validate@5.0.10) + '@solana/spl-token': 0.3.11(@solana/web3.js@1.98.0(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.2)(utf-8-validate@5.0.10) '@solana/web3.js': 1.98.0(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10) '@terra-money/terra.js': 3.1.9 '@xpla/xpla.js': 0.2.3 @@ -24875,7 +24884,7 @@ snapshots: - typescript - utf-8-validate - '@certusone/wormhole-sdk@0.9.24(bufferutil@4.0.7)(encoding@0.1.13)(google-protobuf@3.21.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.2)(utf-8-validate@6.0.3)': + '@certusone/wormhole-sdk@0.9.24(@types/react@19.1.0)(bufferutil@4.0.7)(encoding@0.1.13)(google-protobuf@3.21.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.2)(utf-8-validate@6.0.3)': dependencies: '@certusone/wormhole-sdk-proto-web': 0.0.6(google-protobuf@3.21.4) '@certusone/wormhole-sdk-wasm': 0.0.1 @@ -24897,7 +24906,7 @@ snapshots: near-api-js: 1.1.0(encoding@0.1.13) optionalDependencies: '@injectivelabs/networks': 1.10.12 - '@injectivelabs/sdk-ts': 1.10.72(bufferutil@4.0.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(utf-8-validate@6.0.3) + '@injectivelabs/sdk-ts': 1.10.72(@types/react@19.1.0)(bufferutil@4.0.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(utf-8-validate@6.0.3) '@injectivelabs/utils': 1.10.12 transitivePeerDependencies: - '@types/react' @@ -24913,7 +24922,7 @@ snapshots: - typescript - utf-8-validate - '@certusone/wormhole-sdk@0.9.24(bufferutil@4.0.9)(encoding@0.1.13)(google-protobuf@3.21.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.2)(utf-8-validate@5.0.10)': + '@certusone/wormhole-sdk@0.9.24(@types/react@19.1.0)(bufferutil@4.0.9)(encoding@0.1.13)(google-protobuf@3.21.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.2)(utf-8-validate@5.0.10)': dependencies: '@certusone/wormhole-sdk-proto-web': 0.0.6(google-protobuf@3.21.4) '@certusone/wormhole-sdk-wasm': 0.0.1 @@ -27533,12 +27542,12 @@ snapshots: protobufjs: 7.4.0 rxjs: 7.8.2 - '@injectivelabs/sdk-ts@1.10.72(@types/react@19.1.0)(bufferutil@4.0.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(utf-8-validate@5.0.10)': + '@injectivelabs/sdk-ts@1.10.72(@types/react@19.1.0)(bufferutil@4.0.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(utf-8-validate@6.0.3)': dependencies: '@apollo/client': 3.13.5(@types/react@19.1.0)(graphql@16.10.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@cosmjs/amino': 0.30.1 '@cosmjs/proto-signing': 0.30.1 - '@cosmjs/stargate': 0.30.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@cosmjs/stargate': 0.30.1(bufferutil@4.0.7)(utf-8-validate@6.0.3) '@ethersproject/bytes': 5.8.0 '@injectivelabs/core-proto-ts': 0.0.14 '@injectivelabs/exceptions': 1.14.47 @@ -27557,9 +27566,9 @@ snapshots: bech32: 2.0.0 bip39: 3.1.0 cosmjs-types: 0.7.2 - eth-crypto: 2.7.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + eth-crypto: 2.7.0(bufferutil@4.0.7)(utf-8-validate@6.0.3) ethereumjs-util: 7.1.5 - ethers: 5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + ethers: 5.8.0(bufferutil@4.0.7)(utf-8-validate@6.0.3) google-protobuf: 3.21.4 graphql: 16.10.0 http-status-codes: 2.3.0 @@ -27582,12 +27591,12 @@ snapshots: - utf-8-validate optional: true - '@injectivelabs/sdk-ts@1.10.72(bufferutil@4.0.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(utf-8-validate@6.0.3)': + '@injectivelabs/sdk-ts@1.10.72(@types/react@19.1.0)(bufferutil@4.0.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(utf-8-validate@5.0.10)': dependencies: '@apollo/client': 3.13.5(@types/react@19.1.0)(graphql@16.10.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@cosmjs/amino': 0.30.1 '@cosmjs/proto-signing': 0.30.1 - '@cosmjs/stargate': 0.30.1(bufferutil@4.0.7)(utf-8-validate@6.0.3) + '@cosmjs/stargate': 0.30.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@ethersproject/bytes': 5.8.0 '@injectivelabs/core-proto-ts': 0.0.14 '@injectivelabs/exceptions': 1.14.47 @@ -27606,9 +27615,9 @@ snapshots: bech32: 2.0.0 bip39: 3.1.0 cosmjs-types: 0.7.2 - eth-crypto: 2.7.0(bufferutil@4.0.7)(utf-8-validate@6.0.3) + eth-crypto: 2.7.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) ethereumjs-util: 7.1.5 - ethers: 5.8.0(bufferutil@4.0.7)(utf-8-validate@6.0.3) + ethers: 5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) google-protobuf: 3.21.4 graphql: 16.10.0 http-status-codes: 2.3.0 @@ -27851,6 +27860,10 @@ snapshots: dependencies: '@swc/helpers': 0.5.15 + '@internationalized/date@3.9.0': + dependencies: + '@swc/helpers': 0.5.15 + '@internationalized/message@3.1.8': dependencies: '@swc/helpers': 0.5.15 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 583fe85248..71274642c7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -66,6 +66,7 @@ catalog: "@floating-ui/react": ^0.27.6 "@headlessui/react": ^2.2.0 "@heroicons/react": ^2.2.0 + "@internationalized/date": ^3.9.0 "@katex/katex": ^0.16.9 "@next/third-parties": ^15.3.2 "@phosphor-icons/react": ^2.1.7