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:
rbondesson
2026-03-02 13:18:51 +01:00
committed by GitHub
parent 76cbfb1ae4
commit 11030ae68d
48 changed files with 1819 additions and 1048 deletions
@@ -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.",
+1
View File
@@ -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>
);
}
@@ -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;
}
@@ -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);
});
});
@@ -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>
);
};
@@ -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;
}
}
}
@@ -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>
);
};
@@ -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);
}
}
@@ -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>
);
}
@@ -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" });
}