Refactor DateSeparator using MVVM and move to shared-components (#32482)
* Refactor DateSeparator using MVVM and move to shared-components
* Add a few more stories, tests and screenshots
* Use the shared component and viewmodel in element-web
* Renaming custom content property an updating snapshots
* Fix lint errors and update snapshot after merge
* Change lifecycle handling for DateSeparatoreViewModel in components where manual handling is preferrable over wrapper component.
* Move context menu from viewmodel to shared components - step 1
* Create a jump to date picker component in shared components
* Add tests for coverage and fix layout issues and roving indexes
* Make element-web use the new component
* Simplify context menu and adjusting tests
* The HTMLExport now render shared components and need a I18nContext.Provider
* Updating unit tests for context menu
* Changed to {translate: _t} to let scripts pick up translations
* Fix lint issue and updating screenshots after merge
* Update snaps for element web components
* Renaming MVVM view components with suffix View.
* Fixing problem with input date calendar icon and system dark theme
* Changed the rendering of the menu and added a separate button component
* Handle input control with useRef in onKeyDown
* Updating DateSeparator snapshots on unit tests
* Updating layout after compound Menu got a className property
* Move files to new subfolder after merge
* Updated snapshot after merge
* Updating lock file
* Updates to styling from PR review
* Updates to focus/blur functionality
* Fixed tabbing and export documentation to stories
* Updated snapshots
---------
Co-authored-by: Zack <zazi21@student.bth.se>
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
"dismiss": "Dismiss",
|
||||
"edit": "Edit",
|
||||
"explore_rooms": "Explore rooms",
|
||||
"go": "Go",
|
||||
"invite": "Invite",
|
||||
"new_conversation": "New conversation",
|
||||
"new_room": "New room",
|
||||
@@ -42,6 +43,9 @@
|
||||
"shared": "New members see history",
|
||||
"world_readable": "Anyone can see history"
|
||||
},
|
||||
"jump_to_date": "Jump to date",
|
||||
"jump_to_date_beginning": "The beginning of the room",
|
||||
"jump_to_date_prompt": "Pick a date to jump to",
|
||||
"status_bar": {
|
||||
"delete_all": "Delete all",
|
||||
"exceeded_resource_limit_description": "Please contact your service administrator to continue using the service.",
|
||||
|
||||
@@ -35,6 +35,7 @@ export * from "./room-list/RoomListView";
|
||||
export * from "./room-list/RoomListItemView";
|
||||
export * from "./room-list/RoomListPrimaryFilters";
|
||||
export * from "./room-list/VirtualizedRoomListView";
|
||||
export * from "./timeline/DateSeparatorView/";
|
||||
export * from "./utils/Box";
|
||||
export * from "./utils/Flex";
|
||||
export * from "./right-panel/WidgetContextMenu";
|
||||
|
||||
@@ -27,6 +27,12 @@ export interface TimelineSeparatorProps {
|
||||
* Optional children to render inside the timeline separator
|
||||
*/
|
||||
children?: PropsWithChildren["children"];
|
||||
/**
|
||||
* ARIA role for the separator container.
|
||||
* Use "none" when the separator contains interactive controls.
|
||||
* @default "separator"
|
||||
*/
|
||||
role?: "separator" | "none";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,13 +41,13 @@ export interface TimelineSeparatorProps {
|
||||
* @param label the accessible label string describing the separator
|
||||
* @param children the children to draw within the timeline separator
|
||||
*/
|
||||
const TimelineSeparator: React.FC<TimelineSeparatorProps> = ({ label, className, children }) => {
|
||||
const TimelineSeparator: React.FC<TimelineSeparatorProps> = ({ label, className, children, role = "separator" }) => {
|
||||
// ARIA treats <hr/>s as separators, here we abuse them slightly so manually treat this entire thing as one
|
||||
return (
|
||||
<Flex
|
||||
className={classNames(className, styles.timelineSeparator)}
|
||||
role="separator"
|
||||
aria-label={label}
|
||||
role={role}
|
||||
aria-label={role === "separator" ? label : undefined}
|
||||
align="center"
|
||||
>
|
||||
<hr role="none" />
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
|
||||
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import { useI18n } from "../../utils/i18nContext";
|
||||
|
||||
/** Props for DateSeparatorButton. */
|
||||
export interface DateSeparatorButtonProps {
|
||||
/** Visible date label shown in the separator button. */
|
||||
label: string;
|
||||
/** Controls tooltip visibility when parent manages open state. */
|
||||
tooltipOpen?: boolean;
|
||||
/** Extra CSS classes to apply to the component. */
|
||||
className?: string;
|
||||
/** Optional ref for the button container element. */
|
||||
buttonRef?: React.Ref<HTMLDivElement>;
|
||||
/** Called when the pointer enters the button trigger. */
|
||||
onMouseEnter?: React.MouseEventHandler<HTMLDivElement>;
|
||||
/** Called when the pointer leaves the button trigger. */
|
||||
onMouseLeave?: React.MouseEventHandler<HTMLDivElement>;
|
||||
/** Called when the button trigger receives focus. */
|
||||
onFocus?: React.FocusEventHandler<HTMLDivElement>;
|
||||
/** Called when the button trigger loses focus. */
|
||||
onBlur?: React.FocusEventHandler<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/** Interactive date separator button that opens the jump-to-date menu. */
|
||||
export function DateSeparatorButton({
|
||||
label,
|
||||
tooltipOpen,
|
||||
className,
|
||||
buttonRef,
|
||||
...props
|
||||
}: DateSeparatorButtonProps): React.ReactNode {
|
||||
const { translate: _t } = useI18n();
|
||||
return (
|
||||
<Tooltip description={_t("room|jump_to_date")} placement="right" open={tooltipOpen}>
|
||||
<Flex
|
||||
ref={buttonRef}
|
||||
data-testid="jump-to-date-separator-button"
|
||||
className={className}
|
||||
aria-live="off"
|
||||
aria-label={_t("room|jump_to_date")}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
>
|
||||
<h2 aria-hidden="true">{label}</h2>
|
||||
<ChevronDownIcon />
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.picker_menu {
|
||||
max-inline-size: none !important;
|
||||
padding: 0 !important;
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
.picker_menu_item {
|
||||
padding: var(--cpd-space-3x) var(--cpd-space-5x) !important;
|
||||
}
|
||||
|
||||
.picker_separator {
|
||||
margin-inline: 0 !important;
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { fireEvent, render, screen } from "@test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { type DateSeparatorViewModel, type DateSeparatorViewSnapshot } from "./DateSeparatorView";
|
||||
import { DateSeparatorContextMenuView } from "./DateSeparatorContextMenuView";
|
||||
|
||||
class TestDateSeparatorViewModel implements DateSeparatorViewModel {
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
public constructor(private snapshot: DateSeparatorViewSnapshot) {}
|
||||
|
||||
public getSnapshot = (): DateSeparatorViewSnapshot => this.snapshot;
|
||||
|
||||
public subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
public onLastWeekPicked = vi.fn<() => void>();
|
||||
public onLastMonthPicked = vi.fn<() => void>();
|
||||
public onBeginningPicked = vi.fn<() => void>();
|
||||
public onDatePicked = vi.fn<(dateString: string) => void>();
|
||||
}
|
||||
|
||||
function renderMenu({
|
||||
open = true,
|
||||
jumpToEnabled = true,
|
||||
onOpenChange = vi.fn<(open: boolean) => void>(),
|
||||
}: {
|
||||
open?: boolean;
|
||||
jumpToEnabled?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
} = {}): { vm: TestDateSeparatorViewModel; onOpenChange: (open: boolean) => void } {
|
||||
const vm = new TestDateSeparatorViewModel({
|
||||
label: "Today",
|
||||
jumpToEnabled,
|
||||
jumpFromDate: "2025-01-15",
|
||||
});
|
||||
|
||||
render(
|
||||
<DateSeparatorContextMenuView
|
||||
vm={vm}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
trigger={
|
||||
<button type="button" data-testid="jump-to-trigger">
|
||||
Trigger
|
||||
</button>
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
return { vm, onOpenChange };
|
||||
}
|
||||
|
||||
describe("DateSeparatorContextMenuView", () => {
|
||||
it("renders menu actions and date picker when open", () => {
|
||||
renderMenu({ open: true, jumpToEnabled: true });
|
||||
|
||||
expect(screen.getByTestId("jump-to-date-last-week")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("jump-to-date-last-month")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("jump-to-date-beginning")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("jump-to-date-picker")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onOpenChange for opening and closing transitions", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn<(open: boolean) => void>();
|
||||
|
||||
renderMenu({ open: false, jumpToEnabled: true, onOpenChange });
|
||||
await user.click(screen.getByTestId("jump-to-trigger"));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true);
|
||||
|
||||
renderMenu({ open: true, jumpToEnabled: true, onOpenChange });
|
||||
await user.click(screen.getByTestId("jump-to-date-last-week"));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("wires week/month/beginning menu actions to the correct callbacks", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { vm } = renderMenu({ open: true, jumpToEnabled: true });
|
||||
|
||||
await user.click(screen.getByTestId("jump-to-date-last-week"));
|
||||
await user.click(screen.getByTestId("jump-to-date-last-month"));
|
||||
await user.click(screen.getByTestId("jump-to-date-beginning"));
|
||||
|
||||
expect(vm.onLastWeekPicked).toHaveBeenCalledTimes(1);
|
||||
expect(vm.onLastMonthPicked).toHaveBeenCalledTimes(1);
|
||||
expect(vm.onBeginningPicked).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("submits date picker and closes the menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn<(open: boolean) => void>();
|
||||
const { vm } = renderMenu({ open: true, jumpToEnabled: true, onOpenChange });
|
||||
|
||||
const dateInput = screen.getByLabelText("Pick a date to jump to");
|
||||
fireEvent.input(dateInput, { target: { value: "2025-01-10" } });
|
||||
await user.click(screen.getByRole("button", { name: "Go" }));
|
||||
|
||||
expect(vm.onDatePicked).toHaveBeenCalledWith("2025-01-10");
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("moves focus from date input to submit button on Tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu({ open: true, jumpToEnabled: true });
|
||||
|
||||
const dateInput = screen.getByLabelText("Pick a date to jump to");
|
||||
const submitButton = screen.getByRole("button", { name: "Go" });
|
||||
dateInput.focus();
|
||||
await user.keyboard("{Tab}");
|
||||
|
||||
expect(submitButton).toHaveFocus();
|
||||
});
|
||||
|
||||
it("moves focus from submit button back to date input on Shift+Tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu({ open: true, jumpToEnabled: true });
|
||||
|
||||
const dateInput = screen.getByLabelText("Pick a date to jump to");
|
||||
const submitButton = screen.getByRole("button", { name: "Go" });
|
||||
submitButton.focus();
|
||||
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
||||
|
||||
expect(dateInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it("closes the menu on Tab from the submit button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn<(open: boolean) => void>();
|
||||
const { vm } = renderMenu({ open: true, jumpToEnabled: true, onOpenChange });
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Go" });
|
||||
submitButton.focus();
|
||||
await user.keyboard("{Tab}");
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(vm.onDatePicked).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits date picker with Enter on the submit button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn<(open: boolean) => void>();
|
||||
const { vm } = renderMenu({ open: true, jumpToEnabled: true, onOpenChange });
|
||||
|
||||
const dateInput = screen.getByLabelText("Pick a date to jump to");
|
||||
fireEvent.input(dateInput, { target: { value: "2025-01-11" } });
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Go" });
|
||||
submitButton.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(vm.onDatePicked).toHaveBeenCalledWith("2025-01-11");
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("submits date picker with Space on the submit button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn<(open: boolean) => void>();
|
||||
const { vm } = renderMenu({ open: true, jumpToEnabled: true, onOpenChange });
|
||||
|
||||
const dateInput = screen.getByLabelText("Pick a date to jump to");
|
||||
fireEvent.input(dateInput, { target: { value: "2025-01-12" } });
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Go" });
|
||||
submitButton.focus();
|
||||
await user.keyboard(" ");
|
||||
|
||||
expect(vm.onDatePicked).toHaveBeenCalledWith("2025-01-12");
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, type PropsWithChildren, useRef } from "react";
|
||||
import { Menu, MenuItem, Separator } from "@vector-im/compound-web";
|
||||
import { capitalize } from "lodash";
|
||||
|
||||
import { useI18n } from "../../utils/i18nContext";
|
||||
import { humanizeRelativeTime } from "../../utils/humanize";
|
||||
import { type DateSeparatorViewModel } from "./DateSeparatorView";
|
||||
import { DateSeparatorDatePickerView } from "./DateSeparatorDatePickerView";
|
||||
import styles from "./DateSeparatorContextMenuView.module.css";
|
||||
|
||||
/**
|
||||
* Props for DateSeparatorContextMenuView component.
|
||||
*/
|
||||
export interface DateSeparatorContextMenuViewProps {
|
||||
/** The date separator view model. */
|
||||
vm: DateSeparatorViewModel;
|
||||
/** Whether the menu is open (controlled by the parent). */
|
||||
open: boolean;
|
||||
/** The element used as the menu trigger. */
|
||||
trigger: React.ReactNode;
|
||||
/** Called when the menu requests an open state change. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date separator jump-to menu.
|
||||
* Uses the wrapped child as the menu trigger.
|
||||
*/
|
||||
export const DateSeparatorContextMenuView: React.FC<PropsWithChildren<DateSeparatorContextMenuViewProps>> = ({
|
||||
vm,
|
||||
open,
|
||||
trigger,
|
||||
onOpenChange,
|
||||
}): JSX.Element => {
|
||||
const i18n = useI18n();
|
||||
const { translate: _t } = useI18n();
|
||||
const dateInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLElement>): void => {
|
||||
if (event.key !== "ArrowDown") return;
|
||||
event.preventDefault();
|
||||
dateInputRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
onOpenChange?.(newOpen);
|
||||
}}
|
||||
title={_t("room|jump_to_date")}
|
||||
showTitle={false}
|
||||
trigger={trigger}
|
||||
align="start"
|
||||
className={styles.picker_menu}
|
||||
>
|
||||
<MenuItem
|
||||
label={capitalize(humanizeRelativeTime(i18n).format(-1, "week"))}
|
||||
onSelect={() => vm.onLastWeekPicked?.()}
|
||||
data-testid="jump-to-date-last-week"
|
||||
hideChevron={true}
|
||||
className={styles.picker_menu_item}
|
||||
/>
|
||||
<MenuItem
|
||||
label={capitalize(humanizeRelativeTime(i18n).format(-1, "month"))}
|
||||
onSelect={() => vm.onLastMonthPicked?.()}
|
||||
data-testid="jump-to-date-last-month"
|
||||
hideChevron={true}
|
||||
className={styles.picker_menu_item}
|
||||
/>
|
||||
<MenuItem
|
||||
label={_t("room|jump_to_date_beginning")}
|
||||
onSelect={() => vm.onBeginningPicked?.()}
|
||||
data-testid="jump-to-date-beginning"
|
||||
hideChevron={true}
|
||||
className={styles.picker_menu_item}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Separator decorative className={styles.picker_separator} />
|
||||
<DateSeparatorDatePickerView
|
||||
vm={vm}
|
||||
inputRef={dateInputRef}
|
||||
onSubmitted={() => onOpenChange?.(false)}
|
||||
onDismissed={() => onOpenChange?.(false)}
|
||||
/>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.picker_menu_item {
|
||||
padding: var(--cpd-space-3x) var(--cpd-space-5x) !important;
|
||||
}
|
||||
|
||||
.picker_form {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: wrap !important;
|
||||
padding: 0 !important;
|
||||
gap: var(--cpd-space-2x) !important;
|
||||
color: var(--cpd-color-text-primary);
|
||||
font: var(--cpd-font-body-md-medium);
|
||||
}
|
||||
|
||||
.picker_input {
|
||||
&:focus-within {
|
||||
border-color: var(--cpd-color-border-focused);
|
||||
}
|
||||
}
|
||||
|
||||
.picker_input_date {
|
||||
font: inherit;
|
||||
color-scheme: light;
|
||||
padding: var(--cpd-space-2x) !important;
|
||||
|
||||
:global(.cpd-theme-dark) &,
|
||||
:global(.cpd-theme-dark-hc) & {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.picker_input_date {
|
||||
color-scheme: dark;
|
||||
:global(.cpd-theme-light) &,
|
||||
:global(.cpd-theme-light-hc) & {
|
||||
color-scheme: light;
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, useId, useRef, useState } from "react";
|
||||
import { Root, Submit, Field, TextControl, MenuItem } from "@vector-im/compound-web";
|
||||
|
||||
import { formatDateForInput } from "../../utils/DateUtils";
|
||||
import { useI18n } from "../../utils/i18nContext";
|
||||
import { useViewModel } from "../../viewmodel";
|
||||
import { type DateSeparatorViewModel } from "./DateSeparatorView";
|
||||
import styles from "./DateSeparatorDatePickerView.module.css";
|
||||
|
||||
/**
|
||||
* Props for DateSeparatorDatePickerView component.
|
||||
*/
|
||||
export interface DateSeparatorDatePickerViewProps {
|
||||
/** The date separator view model. */
|
||||
vm: DateSeparatorViewModel;
|
||||
/** Optional input ref shared with parent for focus management. */
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>;
|
||||
/** Called after a date has been submitted. */
|
||||
onSubmitted?: () => void;
|
||||
/** Called when the picker is dismissed without submitting. */
|
||||
onDismissed?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date picker menu item.
|
||||
*/
|
||||
export const DateSeparatorDatePickerView: React.FC<DateSeparatorDatePickerViewProps> = ({
|
||||
vm,
|
||||
inputRef,
|
||||
onSubmitted,
|
||||
onDismissed,
|
||||
}): JSX.Element => {
|
||||
const snapshot = useViewModel(vm);
|
||||
const date = snapshot.jumpFromDate ? new Date(snapshot.jumpFromDate) : new Date();
|
||||
const dateInputDefaultValue = formatDateForInput(date);
|
||||
|
||||
const { translate: _t } = useI18n();
|
||||
const dateInputId = useId();
|
||||
const [dateValue, setDateValue] = useState(dateInputDefaultValue);
|
||||
const localDateInputRef = useRef<HTMLInputElement>(null);
|
||||
const dateInputRef = inputRef ?? localDateInputRef;
|
||||
const submitButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const onDateInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === "Tab") {
|
||||
if (event.shiftKey) {
|
||||
onDismissed?.();
|
||||
} else {
|
||||
event.preventDefault();
|
||||
submitButtonRef.current?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onDateValueInput = (event: React.InputEvent<HTMLInputElement>): void => {
|
||||
setDateValue(event.currentTarget.value);
|
||||
};
|
||||
|
||||
const submitDate = (): void => {
|
||||
vm.onDatePicked?.(dateValue);
|
||||
onSubmitted?.();
|
||||
};
|
||||
|
||||
const onJumpToDateSubmit = (event: React.SubmitEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
submitDate();
|
||||
};
|
||||
|
||||
const onSubmitButtonKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>): void => {
|
||||
if (event.key === "Tab") {
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault();
|
||||
dateInputRef.current?.focus();
|
||||
} else {
|
||||
onDismissed?.();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key == "Enter" || event.key == " " || event.key == "Spacebar") {
|
||||
event.preventDefault();
|
||||
submitDate();
|
||||
}
|
||||
};
|
||||
|
||||
const keepMenuOpenOnSelect = (event: Event): void => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
as="div"
|
||||
data-testid="jump-to-date-picker"
|
||||
label={_t("room|jump_to_date")}
|
||||
onSelect={keepMenuOpenOnSelect}
|
||||
hideChevron={true}
|
||||
className={styles.picker_menu_item}
|
||||
>
|
||||
<Root className={styles.picker_form} onSubmit={onJumpToDateSubmit}>
|
||||
<Field name="jump-to-date-field" className={styles.picker_input}>
|
||||
<TextControl
|
||||
ref={dateInputRef}
|
||||
id={dateInputId}
|
||||
type="date"
|
||||
aria-label={_t("room|jump_to_date_prompt")}
|
||||
onInput={onDateValueInput}
|
||||
onKeyDown={onDateInputKeyDown}
|
||||
value={dateValue}
|
||||
max={formatDateForInput(new Date())}
|
||||
className={styles.picker_input_date}
|
||||
/>
|
||||
</Field>
|
||||
<Submit
|
||||
ref={submitButtonRef}
|
||||
className={styles.picker_button}
|
||||
type="submit"
|
||||
kind="primary"
|
||||
size="sm"
|
||||
onKeyDown={onSubmitButtonKeyDown}
|
||||
>
|
||||
{_t("action|go")}
|
||||
</Submit>
|
||||
</Root>
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.content {
|
||||
padding: 0 25px;
|
||||
cursor: pointer;
|
||||
|
||||
h2 {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
svg {
|
||||
align-self: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DateSeparatorView, type DateSeparatorViewSnapshot, type DateSeparatorViewActions } from "./DateSeparatorView";
|
||||
import { useMockedViewModel } from "../../viewmodel/useMockedViewModel";
|
||||
import { withViewDocs } from "../../../.storybook/withViewDocs";
|
||||
|
||||
type DateSeparatorProps = DateSeparatorViewSnapshot & DateSeparatorViewActions;
|
||||
|
||||
const DateSeparatorViewWrapperImpl = ({
|
||||
onLastWeekPicked,
|
||||
onLastMonthPicked,
|
||||
onBeginningPicked,
|
||||
onDatePicked,
|
||||
...rest
|
||||
}: DateSeparatorProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, { onLastWeekPicked, onLastMonthPicked, onBeginningPicked, onDatePicked });
|
||||
return <DateSeparatorView vm={vm} />;
|
||||
};
|
||||
const DateSeparatorViewWrapper = withViewDocs(DateSeparatorViewWrapperImpl, DateSeparatorView);
|
||||
|
||||
const meta = {
|
||||
title: "Timeline/DateSeparatorView",
|
||||
component: DateSeparatorViewWrapper,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
jumpToEnabled: { control: "boolean" },
|
||||
jumpFromDate: { control: "text" },
|
||||
className: { control: "text" },
|
||||
},
|
||||
args: {
|
||||
label: "Today",
|
||||
},
|
||||
} satisfies Meta<typeof DateSeparatorViewWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const HasExtraClassNames: Story = {
|
||||
args: {
|
||||
className: "extra_class_1 extra_class_2",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithJumpToTooltip: Story = {
|
||||
args: {
|
||||
jumpToEnabled: true,
|
||||
jumpFromDate: "2025-01-15",
|
||||
onLastWeekPicked: () => console.log("onLastWeekPicked"),
|
||||
onLastMonthPicked: () => console.log("onLastMonthPicked"),
|
||||
onBeginningPicked: () => console.log("onBeginningPicked"),
|
||||
onDatePicked: () => console.log("onDatePicked"),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.hover(canvas.getByText("Today"));
|
||||
await expect(within(canvasElement.ownerDocument.body).findByRole("tooltip")).resolves.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithJumpToDatePicker: Story = {
|
||||
args: {
|
||||
jumpToEnabled: true,
|
||||
jumpFromDate: "2025-01-15",
|
||||
onLastWeekPicked: () => console.log("onLastWeekPicked"),
|
||||
onLastMonthPicked: () => console.log("onLastMonthPicked"),
|
||||
onBeginningPicked: () => console.log("onBeginningPicked"),
|
||||
onDatePicked: () => console.log("onDatePicked"),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByText("Today"));
|
||||
await expect(within(canvasElement.ownerDocument.body).findByText("Jump to date")).resolves.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const LongLocalizedLabel: Story = {
|
||||
args: {
|
||||
label: "Wednesday, December 17, 2025 at 11:59 PM Coordinated Universal Time",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render, screen } from "@test-utils";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { BaseViewModel } from "../../viewmodel/BaseViewModel";
|
||||
import { DateSeparatorView, type DateSeparatorViewModel, type DateSeparatorViewSnapshot } from "./DateSeparatorView";
|
||||
import * as stories from "./DateSeparatorView.stories";
|
||||
|
||||
const { Default, HasExtraClassNames, WithJumpToDatePicker, LongLocalizedLabel } = composeStories(stories);
|
||||
|
||||
class MutableDateSeparatorViewModel
|
||||
extends BaseViewModel<DateSeparatorViewSnapshot, undefined>
|
||||
implements DateSeparatorViewModel
|
||||
{
|
||||
public constructor(snapshot: DateSeparatorViewSnapshot) {
|
||||
super(undefined, snapshot);
|
||||
}
|
||||
|
||||
public setSnapshot(snapshot: DateSeparatorViewSnapshot): void {
|
||||
this.snapshot.set(snapshot);
|
||||
}
|
||||
|
||||
public onLastWeekPicked = (): void => undefined;
|
||||
public onLastMonthPicked = (): void => undefined;
|
||||
public onBeginningPicked = (): void => undefined;
|
||||
public onDatePicked = (_dateString: string): void => undefined;
|
||||
}
|
||||
|
||||
describe("DateSeparatorView", () => {
|
||||
it("renders default story", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(screen.getByText("Today")).toBeInTheDocument();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders with extra class names", () => {
|
||||
const { container } = render(<HasExtraClassNames />);
|
||||
expect(container.firstElementChild).toHaveClass("extra_class_1");
|
||||
expect(container.firstElementChild).toHaveClass("extra_class_2");
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders with jump to date picker story", async () => {
|
||||
const { container } = render(<WithJumpToDatePicker />);
|
||||
await userEvent.click(screen.getByTestId("jump-to-date-separator-button"));
|
||||
await expect(screen.findByTestId("jump-to-date-last-week")).resolves.toBeInTheDocument();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders long localized label story", () => {
|
||||
const { container } = render(<LongLocalizedLabel />);
|
||||
expect(
|
||||
screen.getByText("Wednesday, December 17, 2025 at 11:59 PM Coordinated Universal Time"),
|
||||
).toBeInTheDocument();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("updates when view model snapshot changes", async () => {
|
||||
const vm = new MutableDateSeparatorViewModel({ label: "Today" });
|
||||
render(<DateSeparatorView vm={vm} />);
|
||||
|
||||
expect(screen.getByText("Today", { selector: "h2" })).toBeInTheDocument();
|
||||
vm.setSnapshot({ label: "Yesterday" });
|
||||
await waitFor(() => expect(screen.getByText("Yesterday", { selector: "h2" })).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import React, { type JSX, useState } from "react";
|
||||
|
||||
import { type ViewModel } from "../../viewmodel/ViewModel";
|
||||
import { useViewModel } from "../../viewmodel/useViewModel";
|
||||
import styles from "./DateSeparatorView.module.css";
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import { TimelineSeparator } from "../../message-body/TimelineSeparator";
|
||||
import { DateSeparatorContextMenuView } from "./DateSeparatorContextMenuView";
|
||||
import { DateSeparatorButton } from "./DateSeparatorButton";
|
||||
|
||||
export interface DateSeparatorViewSnapshot {
|
||||
/**
|
||||
* Visible date label and the separator's accessible label.
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* Controls whether the jump-to menu is rendered.
|
||||
*/
|
||||
jumpToEnabled?: boolean;
|
||||
/**
|
||||
* Reference date as input format used to prefill the jump-to-date picker value.
|
||||
*/
|
||||
jumpFromDate?: string;
|
||||
/**
|
||||
* Extra CSS classes to apply to the component.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface DateSeparatorViewActions {
|
||||
/** Optional: Jump to messages from the last week. */
|
||||
onLastWeekPicked?: () => void;
|
||||
/** Optional: Jump to messages from the last month. */
|
||||
onLastMonthPicked?: () => void;
|
||||
/** Optional: Jump to the beginning of the room history. */
|
||||
onBeginningPicked?: () => void;
|
||||
/** Optional: Jump to the picked date of the room history. */
|
||||
onDatePicked?: (date: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The view model for the component.
|
||||
*/
|
||||
export type DateSeparatorViewModel = ViewModel<DateSeparatorViewSnapshot> & DateSeparatorViewActions;
|
||||
|
||||
interface DateSeparatorViewProps {
|
||||
/**
|
||||
* The view model for the component.
|
||||
*/
|
||||
vm: DateSeparatorViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a timeline date separator.
|
||||
* When `jumpToEnabled` is true, wraps the separator label with a jump-to menu trigger.
|
||||
* The tooltip is disabled while the menu is open to avoid overlap.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <DateSeparatorView vm={vm} />
|
||||
* ```
|
||||
*/
|
||||
export function DateSeparatorView({ vm }: Readonly<DateSeparatorViewProps>): JSX.Element {
|
||||
const { label, className, jumpToEnabled } = useViewModel(vm);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isTriggerHovered, setIsTriggerHovered] = useState(false);
|
||||
const [isTriggerFocused, setIsTriggerFocused] = useState(false);
|
||||
const onMenuOpenChange = (newOpen: boolean): void => {
|
||||
setIsMenuOpen(newOpen);
|
||||
if (newOpen) {
|
||||
setIsTriggerHovered(false);
|
||||
setIsTriggerFocused(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (jumpToEnabled) {
|
||||
return (
|
||||
<TimelineSeparator label={label} className={classNames(className)} role="none">
|
||||
<DateSeparatorContextMenuView
|
||||
vm={vm}
|
||||
open={isMenuOpen}
|
||||
onOpenChange={onMenuOpenChange}
|
||||
trigger={
|
||||
<DateSeparatorButton
|
||||
label={label}
|
||||
tooltipOpen={!isMenuOpen && (isTriggerHovered || isTriggerFocused)}
|
||||
className={styles.content}
|
||||
onMouseEnter={() => setIsTriggerHovered(true)}
|
||||
onMouseLeave={() => setIsTriggerHovered(false)}
|
||||
onFocus={(event) => setIsTriggerFocused(event.currentTarget.matches(":focus-visible"))}
|
||||
onBlur={() => setIsTriggerFocused(false)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</TimelineSeparator>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineSeparator label={label} className={classNames(className)}>
|
||||
<Flex className={styles.content}>
|
||||
<h2 aria-hidden="true">{label}</h2>
|
||||
</Flex>
|
||||
</TimelineSeparator>
|
||||
);
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`DateSeparatorView > renders default story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
aria-label="Today"
|
||||
class="flex timelineSeparator"
|
||||
role="separator"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<hr
|
||||
role="none"
|
||||
/>
|
||||
<div
|
||||
class="flex content"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<h2
|
||||
aria-hidden="true"
|
||||
>
|
||||
Today
|
||||
</h2>
|
||||
</div>
|
||||
<hr
|
||||
role="none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`DateSeparatorView > renders long localized label story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
aria-label="Wednesday, December 17, 2025 at 11:59 PM Coordinated Universal Time"
|
||||
class="flex timelineSeparator"
|
||||
role="separator"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<hr
|
||||
role="none"
|
||||
/>
|
||||
<div
|
||||
class="flex content"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<h2
|
||||
aria-hidden="true"
|
||||
>
|
||||
Wednesday, December 17, 2025 at 11:59 PM Coordinated Universal Time
|
||||
</h2>
|
||||
</div>
|
||||
<hr
|
||||
role="none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`DateSeparatorView > renders with extra class names 1`] = `
|
||||
<div>
|
||||
<div
|
||||
aria-label="Today"
|
||||
class="flex extra_class_1 extra_class_2 timelineSeparator"
|
||||
role="separator"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<hr
|
||||
role="none"
|
||||
/>
|
||||
<div
|
||||
class="flex content"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<h2
|
||||
aria-hidden="true"
|
||||
>
|
||||
Today
|
||||
</h2>
|
||||
</div>
|
||||
<hr
|
||||
role="none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`DateSeparatorView > renders with jump to date picker story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="flex timelineSeparator"
|
||||
role="none"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<hr
|
||||
aria-hidden="true"
|
||||
data-aria-hidden="true"
|
||||
role="none"
|
||||
/>
|
||||
<div
|
||||
aria-controls="radix-_r_1_"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Jump to date"
|
||||
aria-live="off"
|
||||
class="flex content"
|
||||
data-state="open"
|
||||
data-testid="jump-to-date-separator-button"
|
||||
id="radix-_r_0_"
|
||||
role="button"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<h2
|
||||
aria-hidden="true"
|
||||
>
|
||||
Today
|
||||
</h2>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<hr
|
||||
aria-hidden="true"
|
||||
data-aria-hidden="true"
|
||||
role="none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { DateSeparatorView, type DateSeparatorViewModel, type DateSeparatorViewSnapshot } from "./DateSeparatorView";
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { formatSeconds, formatDateForInput } from "./DateUtils";
|
||||
|
||||
describe("formatSeconds", () => {
|
||||
it("correctly formats time with hours", () => {
|
||||
expect(formatSeconds(60 * 60 * 3 + 60 * 31 + 55)).toBe("03:31:55");
|
||||
expect(formatSeconds(60 * 60 * 3 + 60 * 0 + 55)).toBe("03:00:55");
|
||||
expect(formatSeconds(60 * 60 * 3 + 60 * 31 + 0)).toBe("03:31:00");
|
||||
expect(formatSeconds(-(60 * 60 * 3 + 60 * 31 + 0))).toBe("-03:31:00");
|
||||
});
|
||||
|
||||
it("correctly formats time without hours", () => {
|
||||
expect(formatSeconds(60 * 60 * 0 + 60 * 31 + 55)).toBe("31:55");
|
||||
expect(formatSeconds(60 * 60 * 0 + 60 * 0 + 55)).toBe("00:55");
|
||||
expect(formatSeconds(60 * 60 * 0 + 60 * 31 + 0)).toBe("31:00");
|
||||
expect(formatSeconds(-(60 * 60 * 0 + 60 * 31 + 0))).toBe("-31:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDateForInput", () => {
|
||||
it.each([["1993-11-01"], ["1066-10-14"], ["0571-04-22"], ["0062-02-05"]])(
|
||||
"should format %s",
|
||||
(dateString: string) => {
|
||||
expect(formatDateForInput(new Date(dateString))).toBe(dateString);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -33,3 +33,17 @@ export function formatSeconds(inSeconds: number): string {
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats dates to be compatible with attributes of a `<input type="date">`. Dates
|
||||
* should be formatted like "2020-06-23" (formatted according to ISO8601).
|
||||
*
|
||||
* @param date The date to format.
|
||||
* @returns The date string in ISO8601 format ready to be used with an `<input>`
|
||||
*/
|
||||
export function formatDateForInput(date: Date): string {
|
||||
const year = `${date.getFullYear()}`.padStart(4, "0");
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, "0");
|
||||
const day = `${date.getDate()}`.padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
@@ -53,3 +53,7 @@ export function humanizeTime(timeMillis: number, i18nApi?: I18nApi): string {
|
||||
return _t("time|in_n_days", { num: days });
|
||||
}
|
||||
}
|
||||
|
||||
export function humanizeRelativeTime(i18nApi?: I18nApi): Intl.RelativeTimeFormat {
|
||||
return new Intl.RelativeTimeFormat(i18nApi?.language, { style: "long", numeric: "auto" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user