Shared Components Restructure, Cherry Picked | Core (#32914)

* refactor(shared-components): move core primitives under core

* refactor(shared-components): restore i18n strings path

* fix(shared-components): repair typedoc story imports

* fix(shared-components): align newer imports with core paths

* test(shared-components): add core visual baselines

* refactor(shared-components): move virtualized list to core root
This commit is contained in:
Zack
2026-03-27 19:58:56 +00:00
committed by GitHub
parent 738a4a16c5
commit e8701f5a06
202 changed files with 185 additions and 185 deletions
@@ -0,0 +1,113 @@
/*
Copyright 2026 Element Creations.
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 type { Meta, StoryObj } from "@storybook/react-vite";
import { FlatVirtualizedList, type FlatVirtualizedListProps } from "./FlatVirtualizedList";
import { type VirtualizedListContext } from "../virtualized-list";
import { items, SimpleItemComponent } from "../story-mock";
import { getContainerAccessibleProps, getItemAccessibleProps } from "../accessbility";
const meta = {
title: "Utils/VirtualizedList/FlatVirtualizedList",
component: FlatVirtualizedList<SimpleItemComponent, undefined>,
parameters: {
docs: {
description: {
component: `
A flat virtualized list that renders large datasets efficiently using
[react-virtuoso](https://virtuoso.dev/), while exposing full keyboard navigation.
## Accessibility with **\`listbox\`** ARIA pattern
This example uses the **\`listbox\`** ARIA pattern, which maps naturally to a
flat list of selectable options.
### Container props — \`getContainerAccessibleProps("listbox")\`
Spread the result of \`getContainerAccessibleProps("listbox")\` directly onto the
\`FlatVirtualizedList\` component to mark the scrollable container as a \`listbox\`:
| Prop | Value | Purpose |
|------|-------|---------|
| \`role\` | \`"listbox"\` | Identifies the container as a listbox widget to assistive technologies. |
\`\`\`tsx
<FlatVirtualizedList
{...getContainerAccessibleProps("listbox")}
aria-label="My list"
{/* other props */}
/>
\`\`\`
### Item props — \`getItemAccessibleProps("listbox", index, listSize)\`
Spread the result of \`getItemAccessibleProps("listbox", index, listSize)\` onto each rendered
item element so that screen readers can announce position and total count even when most DOM
nodes are not mounted (virtualized):
| Prop | Value | Purpose |
|------|-------|---------|
| \`role\` | \`"option"\` | Identifies the element as a selectable option within the listbox. |
| \`aria-posinset\` | \`index + 1\` | 1-based position of this option within the full set. |
| \`aria-setsize\` | \`listSize\` | Total number of options in the list. |
The list uses a [roving tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex)
pattern: \`context.tabIndexKey\` holds the key of the item that currently owns focus. Set
\`tabIndex={0}\` on the matching item and \`tabIndex={-1}\` on every other to keep the list
to a single tab stop while arrow-key navigation moves focus between items.
\`\`\`tsx
getItemComponent={(index, item, context, onFocus) => {
const selected = context.tabIndexKey === item.id;
return (
<button
type="button"
tabIndex={selected ? 0 : -1}
{...getItemAccessibleProps("listbox", index, items.length)}
onFocus={(e) => onFocus(item, e)}
onClick={() => console.log("Clicked item")}}
>
{item.label}
</button>
);
}}
\`\`\`
`,
},
},
},
args: {
items,
"getItemComponent": (
index: number,
item: SimpleItemComponent,
context: VirtualizedListContext<undefined>,
onFocus: (item: SimpleItemComponent, e: React.FocusEvent) => void,
) => (
<SimpleItemComponent
key={item.id}
item={item}
context={context}
onFocus={onFocus}
{...getItemAccessibleProps("listbox", index, items.length)}
/>
),
"isItemFocusable": () => true,
"getItemKey": (item) => item.id,
"style": { height: "400px" },
"aria-label": "Flat virtualized list",
...getContainerAccessibleProps("listbox"),
},
} satisfies Meta<FlatVirtualizedListProps<SimpleItemComponent, undefined>>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
@@ -0,0 +1,58 @@
/*
* 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, useCallback } from "react";
import { Virtuoso } from "react-virtuoso";
import { useVirtualizedList, type VirtualizedListContext, type VirtualizedListProps } from "../virtualized-list";
export interface FlatVirtualizedListProps<Item, Context> extends VirtualizedListProps<Item, Context> {
/**
* Function that renders each list item as a JSX element.
* @param index - The index of the item in the list
* @param item - The data item to render
* @param context - The context object containing the focused key and any additional data
* @param onFocus - A callback that is required to be called when the item component receives focus
* @returns JSX element representing the rendered item
*/
getItemComponent: (
index: number,
item: Item,
context: VirtualizedListContext<Context>,
onFocus: (item: Item, e: React.FocusEvent) => void,
) => JSX.Element;
}
/**
* A generic virtualized list component built on top of react-virtuoso.
* Provides keyboard navigation and virtualized rendering for performance with large lists.
*
* @template Item - The type of data items in the list
* @template Context - The type of additional context data passed to items
*/
export function FlatVirtualizedList<Item, Context>(props: FlatVirtualizedListProps<Item, Context>): React.ReactElement {
const { getItemComponent, ...restProps } = props;
const { onFocusForGetItemComponent, ...virtuosoProps } = useVirtualizedList<Item, Context>(restProps);
const getItemComponentInternal = useCallback(
(index: number, item: Item, context: VirtualizedListContext<Context>): JSX.Element =>
getItemComponent(index, item, context, onFocusForGetItemComponent),
[getItemComponent, onFocusForGetItemComponent],
);
return (
<Virtuoso
// note that either the container of direct children must be focusable to be axe
// compliant, so we leave tabIndex as the default so the container can be focused
// (virtuoso wraps the children inside another couple of elements so setting it
// on those doesn't seem to work, unfortunately)
itemContent={getItemComponentInternal}
data={props.items}
{...virtuosoProps}
/>
);
}
@@ -0,0 +1,9 @@
/*
* 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 { FlatVirtualizedList } from "./FlatVirtualizedList";
export type { FlatVirtualizedListProps } from "./FlatVirtualizedList";
@@ -0,0 +1,218 @@
/*
* 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 { type Meta, type StoryObj } from "@storybook/react-vite";
import React from "react";
import { GroupedVirtualizedList, type GroupedVirtualizedListProps } from "./GroupedVirtualizedList";
import { type VirtualizedListContext } from "../virtualized-list";
import { GroupHeaderComponent, groups, SimpleItemComponent, type SimpleGroupHeader } from "../story-mock";
import { getContainerAccessibleProps, getGroupHeaderAccessibleProps, getItemAccessibleProps } from "../accessbility";
// Calculate total rows for ARIA props (group headers + items)
const totalRows = groups.reduce((total, group) => total + 1 + group.items.length, 0);
const meta = {
title: "Utils/VirtualizedList/GroupedVirtualizedList",
component: GroupedVirtualizedList<SimpleGroupHeader, SimpleItemComponent, undefined>,
parameters: {
docs: {
description: {
component: `
A grouped virtualized list that renders large datasets organised into labelled sections
efficiently using [react-virtuoso](https://virtuoso.dev/), while exposing full keyboard
navigation for both group headers and child items.
## Accessibility with **\`treegrid\`** ARIA pattern
This example uses the **\`treegrid\`** ARIA pattern. A treegrid models a
two-level hierarchy: group headers sit at **level 1** and their child items sit at
**level 2**. This lets assistive technologies announce both the group structure and the
position of each item within its group.
### Container props — \`getContainerAccessibleProps("treegrid", totalRows)\`
Spread the result of \`getContainerAccessibleProps("treegrid", totalRows)\` directly onto the
\`GroupedVirtualizedList\` component to mark the scrollable container as a \`treegrid\`:
| Prop | Value | Purpose |
|------|-------|---------|
| \`role\` | \`"treegrid"\` | Identifies the container as a treegrid widget to assistive technologies. |
| \`aria-rowcount\` | \`totalRows\` | Total number of rows in the treegrid (group headers + items). Because virtualization only mounts a subset of rows, browsers cannot count them from the DOM — this attribute supplies the true count so screen readers can announce e.g. *"row 12 of 53"*. |
\`totalRows\` must include **every** row that will ever appear: one per group header plus one
per item across all groups.
\`\`\`tsx
const totalRows = groups.reduce((total, group) => total + 1 + group.items.length, 0);
<GroupedVirtualizedList
{...getContainerAccessibleProps("treegrid", totalRows)}
aria-label="My grouped list"
{/* other props */}
/>
\`\`\`
---
### Group header props — \`getGroupHeaderAccessibleProps(index, groupIndex, groupSize)\`
Spread the result of \`getGroupHeaderAccessibleProps\` onto each rendered group header element
to place it at level 1 in the tree hierarchy:
| Prop | Value | Purpose |
|------|-------|---------|
| \`role\` | \`"row"\` | Identifies the element as a row within the treegrid. |
| \`aria-level\` | \`1\` | Places the header at the root level of the tree hierarchy. |
| \`aria-posinset\` | \`groupIndex + 1\` | 1-based position of this group among all groups. |
| \`aria-rowindex\` | \`index + 1\` | 1-based position of this row in the full flat row sequence (headers + items). |
| \`aria-setsize\` | \`groupSize\` | Total number of items inside this group. |
The list also uses a [roving tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex)
pattern: \`context.tabIndexKey\` holds the key of the element that currently owns focus. Set
\`tabIndex={0}\` on the matching gridcell and \`tabIndex={-1}\` on every other to keep the
list to a single tab stop while arrow-key navigation moves focus between rows.
\`\`\`tsx
getGroupHeaderComponent={(groupIndex, header, context, onFocus) => {
// Flat row index: sum of (1 header + N items) for every preceding group
const index = groups
.slice(0, groupIndex)
.reduce((sum, g) => sum + 1 + g.items.length, 0);
const groupSize = groups[groupIndex].items.length;
const selected = context.tabIndexKey === header.id;
return (
<div
{...getGroupHeaderAccessibleProps(index, groupIndex, groupSize)}
>
{/* Direct child must be a gridcell */}
<button
role="gridcell"
type="button"
tabIndex={selected ? 0 : -1}
onFocus={(e) => onFocus(header, e)}
onClick={() => console.log("Clicked group header")}}
>
{header.label}
</button>
</div>
);
}}
\`\`\`
---
### Item props — \`getItemAccessibleProps("treegrid", index, indexInGroup)\`
Spread the result of \`getItemAccessibleProps("treegrid", index, indexInGroup)\` onto each
rendered item element to place it at level 2 in the tree hierarchy:
| Prop | Value | Purpose |
|------|-------|---------|
| \`role\` | \`"row"\` | Identifies the element as a row within the treegrid. |
| \`aria-level\` | \`2\` | Places the item as a child of its group header at level 1. |
| \`aria-rowindex\` | \`index + 1\` | 1-based position of this row in the full flat row sequence (headers + items). |
| \`aria-posinset\` | \`indexInGroup + 1\` | 1-based position of this item within its own group. |
Both \`index\` (flat row index across the whole treegrid) and \`indexInGroup\` (position
within the item's group) must be computed before passing to the function.
As with group headers, apply the [roving tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex)
pattern using \`context.tabIndexKey\` to keep the list to a single tab stop.
\`\`\`tsx
getItemComponent={(_, item, context, onFocus, groupIndex) => {
const group = groups[groupIndex];
const indexInGroup = group.items.findIndex((i) => i.id === item.id);
// Flat row index: skip (1 header + N items) per preceding group, then add
// 1 for the current group's header, then the item's position within the group.
const index = groups
.slice(0, groupIndex)
.reduce((sum, g) => sum + 1 + g.items.length, indexInGroup + 1);
const selected = context.tabIndexKey === item.id;
return (
<div
{...getItemAccessibleProps("treegrid", index, indexInGroup)}
>
{/* Direct child must be a gridcell */}
<button
role="gridcell"
type="button"
tabIndex={selected ? 0 : -1}
onFocus={(e) => onFocus(item, e)}
onClick={() => console.log("Clicked item")}}
>
{item.label}
</button>
</div>
);
}}
\`\`\`
`,
},
},
},
args: {
groups,
"getItemComponent": (
_index: number,
item: SimpleItemComponent,
context: VirtualizedListContext<undefined>,
onFocus: (item: SimpleItemComponent, e: React.FocusEvent) => void,
groupIndex: number,
) => {
const group = groups[groupIndex];
const indexInGroup = group.items.findIndex((i) => i.id === item.id);
const index = groups.slice(0, groupIndex).reduce((sum, g) => sum + 1 + g.items.length, indexInGroup + 1);
return (
<SimpleItemComponent
key={item.id}
item={item}
context={context}
onFocus={onFocus}
{...getItemAccessibleProps("treegrid", index, indexInGroup)}
/>
);
},
"getGroupHeaderComponent": (
groupIndex: number,
header: SimpleGroupHeader,
context: VirtualizedListContext<undefined>,
onFocus: (header: SimpleGroupHeader, e: React.FocusEvent) => void,
) => {
const index = groups.slice(0, groupIndex).reduce((sum, g) => sum + 1 + g.items.length, 0);
const groupSize = groups[groupIndex].items.length;
return (
<GroupHeaderComponent
key={header.id}
header={header}
context={context}
onFocus={onFocus}
{...getGroupHeaderAccessibleProps(index, groupIndex, groupSize)}
/>
);
},
"isItemFocusable": () => true,
"isGroupHeaderFocusable": () => true,
"getItemKey": (item) => item.id,
"getHeaderKey": (header) => header.id,
"style": { height: "400px" },
"aria-label": "Grouped virtualized list",
...getContainerAccessibleProps("treegrid", totalRows),
},
} satisfies Meta<GroupedVirtualizedListProps<SimpleGroupHeader, SimpleItemComponent, undefined>>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
@@ -0,0 +1,233 @@
/*
* 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, useCallback, useMemo } from "react";
import { Virtuoso } from "react-virtuoso";
import { useVirtualizedList, type VirtualizedListContext, type VirtualizedListProps } from "../virtualized-list";
/**
* A group of items for the grouped virtualized list.
* The `header` uses a dedicated `Header` type, separate from the `Item` type
* used for the group's child items.
*/
export interface Group<Header, Item> {
/** The data representing this group's header. */
header: Header;
/** The items belonging to this group. */
items: Item[];
}
/**
* Internal discriminated union used to bridge the separate `Item` / `Header`
* types into a single array that the keyboard-navigation hook can operate on.
* Discriminated by property name: `"header" in entry` vs `"item" in entry`.
*/
type NavigationEntry<Header, Item> = { header: Header } | { item: Item };
export interface GroupedVirtualizedListProps<Header, Item, Context> extends Omit<
VirtualizedListProps<Item, Context>,
"items" | "isItemFocusable" | "getItemKey"
> {
/**
* The groups to display in the virtualized list.
* Each group has a header and an array of child items.
*/
groups: Group<Header, Item>[];
/**
* Function to get a unique key for an item.
* @param item - The item to get the key for
* @returns A unique key string
*/
getItemKey: (item: Item) => string;
/**
* Function to get a unique key for a group header.
* @param header - The header to get the key for
* @returns A unique key string
*/
getHeaderKey: (header: Header) => string;
/**
* Function to determine if an item can receive focus during keyboard navigation.
* @param item - The item to check
* @returns true if the item can be focused
*/
isItemFocusable: (item: Item) => boolean;
/**
* Function to determine if a group header can receive focus during keyboard navigation.
* @param header - The header to check
* @returns true if the header can be focused
*/
isGroupHeaderFocusable: (header: Header) => boolean;
/**
* Function that renders the group header as a JSX element.
* @param groupIndex - The index of the group in the list
* @param header - The header data for this group
* @param context - The context object containing the focused key and any additional data
* @param onFocus - A callback that must be called when the group header component receives
* focus. Should be invoked as `onFocus(header, e)`.
* @returns JSX element representing the rendered group header
*/
getGroupHeaderComponent: (
groupIndex: number,
header: Header,
context: VirtualizedListContext<Context>,
onFocus: (header: Header, e: React.FocusEvent) => void,
) => JSX.Element;
/**
* Function that renders each list item as a JSX element.
* @param index - The index of the item in the list (relative to the entire list, not the group)
* @param item - The data item to render
* @param context - The context object containing the focused key and any additional data
* @param onFocus - A callback that is required to be called when the item component receives focus
* @param groupIndex - The index of the group this item belongs to
* @returns JSX element representing the rendered item
*/
getItemComponent: (
index: number,
item: Item,
context: VirtualizedListContext<Context>,
onFocus: (item: Item, e: React.FocusEvent) => void,
groupIndex: number,
) => JSX.Element;
}
/**
* A generic grouped virtualized list component built on top of react-virtuoso's Virtuoso.
* Provides keyboard navigation (including group headers) and virtualized rendering for
* performance with large lists.
*
* Group headers use a dedicated `Header` type, while child items use `Item`.
* Internally, a unified flat array interleaving headers and items is built using
* `flatMap` so that the keyboard-navigation hook can treat every focusable element
* uniformly.
*
* @template Header - The type of group header data
* @template Item - The type of data items in the list
* @template Context - The type of additional context data passed to items
*/
export function GroupedVirtualizedList<Header, Item, Context>(
props: GroupedVirtualizedListProps<Header, Item, Context>,
): React.ReactElement {
const {
getItemComponent,
groups,
getGroupHeaderComponent,
isItemFocusable,
isGroupHeaderFocusable,
getItemKey,
getHeaderKey,
...restProps
} = props;
// Build a flat array interleaving group headers with items.
// Each entry is either { header } or { item }.
const flatEntries = useMemo(
() =>
groups.flatMap<NavigationEntry<Header, Item>>((group) => [
{ header: group.header },
...group.items.map<NavigationEntry<Header, Item>>((item) => ({ item })),
]),
[groups],
);
// Pre-compute a lookup from flat index to group index.
// Each group contributes 1 header + N items, all mapped to the same group index.
const flatIndexToGroupIndex = useMemo(
() => groups.flatMap((group, groupIdx) => new Array(1 + group.items.length).fill(groupIdx)),
[groups],
);
// Wrap getItemKey: dispatch to getHeaderKey or getItemKey based on entry type
const wrappedGetEntryKey = useCallback(
(entry: NavigationEntry<Header, Item>): string =>
"header" in entry ? getHeaderKey(entry.header) : getItemKey(entry.item),
[getHeaderKey, getItemKey],
);
// Wrap isItemFocusable: headers use isGroupHeaderFocusable, items use isItemFocusable
const wrappedIsEntryFocusable = useCallback(
(entry: NavigationEntry<Header, Item>): boolean =>
"header" in entry ? isGroupHeaderFocusable(entry.header) : isItemFocusable(entry.item),
[isGroupHeaderFocusable, isItemFocusable],
);
const { onFocusForGetItemComponent, ...virtuosoProps } = useVirtualizedList<NavigationEntry<Header, Item>, Context>(
{
...(restProps as Omit<
VirtualizedListProps<NavigationEntry<Header, Item>, Context>,
"items" | "isItemFocusable" | "getItemKey"
>),
items: flatEntries,
isItemFocusable: wrappedIsEntryFocusable,
getItemKey: wrappedGetEntryKey,
},
);
// Convert (Item, e) → (NavigationEntry, e) for regular items
const onFocusForItem = useCallback(
(item: Item, e: React.FocusEvent): void => {
onFocusForGetItemComponent({ item }, e);
},
[onFocusForGetItemComponent],
);
// Convert (Header, e) → (NavigationEntry, e) for group headers
const onFocusForHeader = useCallback(
(header: Header, e: React.FocusEvent): void => {
onFocusForGetItemComponent({ header }, e);
},
[onFocusForGetItemComponent],
);
// Unified item renderer that dispatches to group header or item component
// based on the entry type at the given flat index.
const itemContent = useCallback(
(
flatIndex: number,
_entry: NavigationEntry<Header, Item>,
context: VirtualizedListContext<Context>,
): JSX.Element => {
const entry = flatEntries[flatIndex];
const groupIndex = flatIndexToGroupIndex[flatIndex];
if ("header" in entry) {
return getGroupHeaderComponent(groupIndex, entry.header, context, onFocusForHeader);
}
// Item index in the flattened (non-header) items array:
// flatIndex minus the number of headers before it (groupIndex + 1).
const itemIndex = flatIndex - (groupIndex + 1);
return getItemComponent(itemIndex, entry.item, context, onFocusForItem, groupIndex);
},
[
flatEntries,
flatIndexToGroupIndex,
getGroupHeaderComponent,
getItemComponent,
onFocusForItem,
onFocusForHeader,
],
);
return (
<Virtuoso
// note that either the container of direct children must be focusable to be axe
// compliant, so we leave tabIndex as the default so the container can be focused
// (virtuoso wraps the children inside another couple of elements so setting it
// on those doesn't seem to work, unfortunately)
itemContent={itemContent}
data={flatEntries}
{...virtuosoProps}
/>
);
}
@@ -0,0 +1,9 @@
/*
* 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 { GroupedVirtualizedList } from "./GroupedVirtualizedList";
export type { GroupedVirtualizedListProps, Group } from "./GroupedVirtualizedList";
@@ -0,0 +1,158 @@
/*
* 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.
*/
/** The ARIA pattern used to make the virtualized list accessible. */
export type Pattern = "listbox" | "treegrid";
/** ARIA props for a `listbox` container element. */
export type ListboxContainerProps = {
role: "listbox";
};
/** ARIA props for a `treegrid` container element, including the total row count. */
export type TreegridContainerProps = {
/** The ARIA role identifying this element as a treegrid. */
"role": "treegrid";
/** The total number of rows in the treegrid, used by assistive technologies to announce list size. */
"aria-rowcount": number;
};
/**
* Returns the ARIA props to spread onto the virtualized list container element.
*
* @param pattern - `"listbox"` — returns {@link ListboxContainerProps}.
* @returns ARIA props for a `listbox` container.
*/
export function getContainerAccessibleProps(pattern: "listbox"): ListboxContainerProps;
/**
* Returns the ARIA props to spread onto the virtualized list container element.
*
* @param pattern - `"treegrid"` — returns {@link TreegridContainerProps}.
* @param size - Total number of rows in the treegrid, set as `aria-rowcount`.
* @returns ARIA props for a `treegrid` container.
*/
export function getContainerAccessibleProps(pattern: "treegrid", size: number): TreegridContainerProps;
export function getContainerAccessibleProps(
pattern: Pattern,
size?: number,
): ListboxContainerProps | TreegridContainerProps {
switch (pattern) {
case "listbox":
return {
role: "listbox",
};
case "treegrid":
return {
"role": "treegrid",
"aria-rowcount": size!,
};
}
}
/** ARIA props for an item rendered inside a `listbox`. */
export type ListboxItemProps = {
/** Identifies the element as a selectable option within the listbox. */
"role": "option";
/** The 1-based position of this option within the full set, used for virtual lists where not all DOM nodes are mounted. */
"aria-posinset": number;
/** The total number of options in the set. */
"aria-setsize": number;
};
/** ARIA props for an item rendered inside a `treegrid` at depth level 2 (i.e. a child row within a group). */
export type TreegridItemProps = {
/** Identifies the element as a row within the treegrid. */
"role": "row";
/** The depth of this row in the tree hierarchy. Items are always at level 2 (inside a group). */
"aria-level": 2;
/** The 1-based index of this row within the full treegrid row sequence (headers + items). */
"aria-rowindex": number;
/** The 1-based position of this item within its group, used by assistive technologies to announce position. */
"aria-posinset": number;
};
/** ARIA props for a virtualized list item, either in a `listbox` or `treegrid`. */
export type ItemAccessibleProps = ListboxItemProps | TreegridItemProps;
/**
* Returns the ARIA props to spread onto a virtualized list item element.
*
* @param pattern - `"listbox"` — returns {@link ListboxItemProps}.
* @param index - The 0-based index of the item in the full flat list.
* @param listSize - The total number of items across the entire list.
* @returns ARIA props for a `listbox` option.
*/
export function getItemAccessibleProps(pattern: "listbox", index: number, listSize: number): ListboxItemProps;
/**
* Returns the ARIA props to spread onto a virtualized list item element.
*
* @param pattern - `"treegrid"` — returns {@link TreegridItemProps}.
* @param index - The 0-based index of this row in the full flat treegrid row sequence (headers + items).
* @param indexInGroup - The 0-based index of this item within its group, used to compute `aria-posinset`.
* @returns ARIA props for a `treegrid` row at level 2.
*/
export function getItemAccessibleProps(pattern: "treegrid", index: number, indexInGroup: number): TreegridItemProps;
export function getItemAccessibleProps(
pattern: Pattern,
index: number,
listSizeOrIndexInGroup: number,
): ListboxItemProps | TreegridItemProps {
switch (pattern) {
case "listbox":
return {
"role": "option",
"aria-posinset": index + 1,
"aria-setsize": listSizeOrIndexInGroup,
};
case "treegrid":
return {
"role": "row",
"aria-level": 2,
"aria-rowindex": index + 1,
"aria-posinset": listSizeOrIndexInGroup + 1,
};
}
}
/** ARIA props for a group header row rendered inside a `treegrid` at depth level 1. */
export type TreegridGroupHeaderProps = {
/** Identifies the element as a row within the treegrid. */
"role": "row";
/** The depth of this row in the tree hierarchy. Group headers are always at the root level (1). */
"aria-level": 1;
/** The 1-based position of this group among all groups. */
"aria-posinset": number;
/** The 1-based index of this row within the full treegrid row sequence (headers + items). */
"aria-rowindex": number;
/** The total number of groups in the treegrid. */
"aria-setsize": number;
};
/**
* Returns the ARIA props to spread onto a group header row element inside a `treegrid`.
*
* Group headers are rendered at `aria-level="1"` and act as the parent nodes for their
* child item rows (`aria-level="2"`).
*
* @param index - The 0-based index of this row in the full flat treegrid row sequence (headers + items), used to compute `aria-rowindex`.
* @param groupIndex - The 0-based index of this group among all groups, used to compute `aria-posinset`.
* @param groupSize - The total number of items in the group, set as `aria-setsize`.
* @returns ARIA props for a group header `row` at level 1.
*/
export function getGroupHeaderAccessibleProps(
index: number,
groupIndex: number,
groupSize: number,
): TreegridGroupHeaderProps {
return {
"role": "row",
"aria-level": 1,
"aria-posinset": groupIndex + 1,
"aria-rowindex": index + 1,
"aria-setsize": groupSize,
};
}
@@ -0,0 +1,17 @@
/*
* 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 { FlatVirtualizedList } from "./FlatVirtualizedList";
export type { FlatVirtualizedListProps } from "./FlatVirtualizedList";
export { GroupedVirtualizedList } from "./GroupedVirtualizedList";
export type { GroupedVirtualizedListProps, Group } from "./GroupedVirtualizedList";
export type { VirtualizedListContext, ScrollIntoViewOnChange } from "./virtualized-list";
export * from "./accessbility";
// Re-export VirtuosoMockContext for testing purposes
// Tests should import this from shared-components to ensure context compatibility
export { VirtuosoMockContext } from "react-virtuoso";
@@ -0,0 +1,37 @@
/*
* Copyright 2025 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.
*/
.item {
all: unset;
width: 100%;
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
button {
all: unset;
}
}
.itemSelected {
background-color: #559f24;
}
.group {
width: 100%;
padding: 8px;
background-color: #00adad;
border: 1px solid lightgrey;
font-weight: "bold";
button {
all: unset;
}
}
.groupSelected {
background-color: #559f24;
}
@@ -0,0 +1,100 @@
/*
* 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, { memo } from "react";
import { type JSX } from "react";
import classNames from "classnames";
import { type VirtualizedListContext } from "./virtualized-list";
import type { Group } from "./GroupedVirtualizedList";
import styles from "./story-mock.module.css";
import type { ItemAccessibleProps, TreegridGroupHeaderProps } from "./accessbility";
export interface SimpleItemComponent {
id: string;
label: string;
}
export interface SimpleGroupHeader {
id: string;
label: string;
}
export const items: SimpleItemComponent[] = Array.from({ length: 50 }, (_, i) => ({
id: `item-${i}`,
label: `Item ${i + 1}`,
}));
export const groups: Group<SimpleGroupHeader, SimpleItemComponent>[] = [
{ header: { id: "group-1", label: "Group 1" }, items: items.slice(0, 10) },
{ header: { id: "group-2", label: "Group 2" }, items: items.slice(10, 30) },
{ header: { id: "group-3", label: "Group 3" }, items: items.slice(30, 50) },
];
type SimpleItemComponentProps<Context> = ItemAccessibleProps & {
item: SimpleItemComponent;
context: Context;
onFocus: (item: SimpleItemComponent, e: React.FocusEvent) => void;
};
export const SimpleItemComponent = memo(function SimpleItemComponent({
item,
context,
onFocus,
...rest
}: SimpleItemComponentProps<VirtualizedListContext<undefined>>): JSX.Element {
const selected = context.tabIndexKey === item.id;
const { role } = rest;
const buttonProps = role === "row" ? { role: "gridcell" } : rest;
const button = (
<button
className={classNames(styles.item, { [styles.itemSelected]: selected })}
tabIndex={selected ? 0 : -1}
type="button"
{...buttonProps}
onFocus={(e) => onFocus(item, e)}
>
{item.label}
</button>
);
if (role === "option") return button;
return (
<div {...rest} {...{ "aria-selected": selected }}>
{button}
</div>
);
});
interface GroupHeaderComponentProps extends TreegridGroupHeaderProps {
header: SimpleGroupHeader;
context: VirtualizedListContext<undefined>;
onFocus: (header: SimpleGroupHeader, e: React.FocusEvent) => void;
}
export const GroupHeaderComponent = memo(function GroupHeaderComponent({
header,
context,
onFocus,
...rest
}: GroupHeaderComponentProps): JSX.Element {
const selected = context.tabIndexKey === header.id;
return (
<div
{...rest}
{...{ "aria-selected": selected }}
className={classNames(styles.group, { [styles.groupSelected]: selected })}
>
<button tabIndex={selected ? 0 : -1} type="button" role="gridcell" onFocus={(e) => onFocus(header, e)}>
{header.label}
</button>
</div>
);
});
@@ -0,0 +1,841 @@
/*
* 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 PropsWithChildren } from "react";
import { render, screen, fireEvent, waitFor, act } from "@test-utils";
import { VirtuosoMockContext } from "react-virtuoso";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { FlatVirtualizedList, type FlatVirtualizedListProps } from "./FlatVirtualizedList";
import { GroupedVirtualizedList, type GroupedVirtualizedListProps } from "./GroupedVirtualizedList";
import type { VirtualizedListContext } from "./virtualized-list";
// ─── Test types ──────────────────────────────────────────────────────────────
interface TestItem {
id: string;
name: string;
isFocusable?: boolean;
}
const SEPARATOR_ITEM = "SEPARATOR" as const;
type TestItemWithSeparator = TestItem | typeof SEPARATOR_ITEM;
interface TestGroupHeader {
id: string;
name: string;
}
// ─── Shared helpers ──────────────────────────────────────────────────────────
const expectTabIndex = (element: Element, expected: string): void => {
expect(element.getAttribute("tabindex")).toBe(expected);
};
const expectAttribute = (element: Element, attr: string, expected: string): void => {
expect(element.getAttribute(attr)).toBe(expected);
};
const getItemKey = (item: TestItemWithSeparator): string => (typeof item === "string" ? item : item.id);
/** Renders an item element used by the default mock. */
function renderItemElement(
index: number,
item: TestItemWithSeparator,
context: VirtualizedListContext<any>,
): React.JSX.Element {
const itemKey = typeof item === "string" ? item : item.id;
const isFocused = context.tabIndexKey === itemKey;
return (
<div className="mx_item" data-testid={`row-${index}`} tabIndex={isFocused ? 0 : -1} role="gridcell">
{item === SEPARATOR_ITEM ? "---" : (item as TestItem).name}
</div>
);
}
/** Renders a clickable item element used by the scroll-click test mock. */
function renderClickableItemElement(
index: number,
item: TestItemWithSeparator,
context: VirtualizedListContext<any>,
onFocus: (item: TestItemWithSeparator, e: React.FocusEvent) => void,
onClick: () => void,
): React.JSX.Element {
const itemKey = typeof item === "string" ? item : item.id;
const isFocused = context.tabIndexKey === itemKey;
return (
<div
className="mx_item"
data-testid={`row-${index}`}
tabIndex={isFocused ? 0 : -1}
role="button"
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onClick();
}
}}
onFocus={(e) => onFocus(item, e)}
>
{item === SEPARATOR_ITEM ? "---" : (item as TestItem).name}
</div>
);
}
// ─── Variant definitions ─────────────────────────────────────────────────────
interface ListTestVariant {
name: string;
/** Build the JSX element for the given items and props. */
createComponent: (
items: TestItemWithSeparator[],
mockGetItemComponent: any,
mockIsItemFocusable: any,
extraProps?: Record<string, unknown>,
) => React.JSX.Element;
/** Wire up the default `getItemComponent` mock (simple items, no onFocus). */
setupDefaultMock: (mockGetItemComponent: any, getItems: () => TestItemWithSeparator[]) => void;
/** Wire up the `getItemComponent` mock for the click-after-scroll test. */
setupClickTestMock: (mockGetItemComponent: any, mockOnClick: any, getItems: () => TestItemWithSeparator[]) => void;
/** Number of ArrowDown key presses after initial focus to reach the first regular item.
* 0 for flat lists, 1 for grouped lists (to skip past the group header). */
stepsToFirstItem: number;
/** CSS selector matching all elements that participate in keyboard navigation. */
navigableSelector: string;
}
const flatVariant: ListTestVariant = {
name: "FlatVirtualizedList",
stepsToFirstItem: 0,
navigableSelector: ".mx_item",
createComponent(items, mockGetItemComponent, mockIsItemFocusable, extraProps = {}) {
const props: FlatVirtualizedListProps<TestItemWithSeparator, any> = {
items,
"getItemComponent": mockGetItemComponent,
"isItemFocusable": mockIsItemFocusable,
getItemKey,
"role": "grid",
"aria-rowcount": items.length,
"aria-colcount": 1,
...extraProps,
};
return <FlatVirtualizedList {...props} />;
},
setupDefaultMock(mockGetItemComponent, _getItems) {
mockGetItemComponent.mockImplementation(
(index: number, item: TestItemWithSeparator, context: VirtualizedListContext<any>) =>
renderItemElement(index, item, context),
);
},
setupClickTestMock(mockGetItemComponent, mockOnClick, _getItems) {
mockGetItemComponent.mockImplementation(
(
index: number,
item: TestItemWithSeparator,
context: VirtualizedListContext<any>,
onFocus: (item: TestItemWithSeparator, e: React.FocusEvent) => void,
) => renderClickableItemElement(index, item, context, onFocus, () => mockOnClick(item)),
);
},
};
const groupedVariant: ListTestVariant = {
name: "GroupedVirtualizedList",
stepsToFirstItem: 1,
navigableSelector: ".mx_group_header, .mx_item",
createComponent(items, mockGetItemComponent, mockIsItemFocusable, extraProps = {}) {
const header: TestGroupHeader = { id: "test-group-header", name: "Group 0" };
const props: GroupedVirtualizedListProps<TestGroupHeader, TestItemWithSeparator, any> = {
"groups": [{ header, items }],
"getItemComponent": mockGetItemComponent,
"getGroupHeaderComponent": (
_groupIndex: number,
header: TestGroupHeader,
context: VirtualizedListContext<any>,
onFocus: (header: TestGroupHeader, e: React.FocusEvent) => void,
) => (
<div
className="mx_group_header"
data-testid={`group-header-${header.id}`}
tabIndex={context.tabIndexKey === header.id ? 0 : -1}
onFocus={(e) => onFocus(header, e)}
>
{header.name}
</div>
),
"isGroupHeaderFocusable": () => true,
"isItemFocusable": mockIsItemFocusable,
getItemKey,
"getHeaderKey": (header) => header.id,
"role": "grid",
"aria-rowcount": items.length,
"aria-colcount": 1,
...extraProps,
};
return <GroupedVirtualizedList {...props} />;
},
setupDefaultMock(mockGetItemComponent, _getItems) {
mockGetItemComponent.mockImplementation(
(index: number, item: TestItemWithSeparator, context: VirtualizedListContext<any>) =>
renderItemElement(index, item, context),
);
},
setupClickTestMock(mockGetItemComponent, mockOnClick, _getItems) {
mockGetItemComponent.mockImplementation(
(
index: number,
item: TestItemWithSeparator,
context: VirtualizedListContext<any>,
onFocus: (item: TestItemWithSeparator, e: React.FocusEvent) => void,
) => renderClickableItemElement(index, item, context, onFocus, () => mockOnClick(item)),
);
},
};
// ─── Shared test suite ───────────────────────────────────────────────────────
const virtuosoWrapper = ({ children }: PropsWithChildren): React.JSX.Element => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 400, itemHeight: 56 }}>
{children}
</VirtuosoMockContext.Provider>
);
describe.each<ListTestVariant>([flatVariant, groupedVariant])("$name", (variant) => {
const mockGetItemComponent = vi.fn();
const mockIsItemFocusable = vi.fn();
const defaultItems: TestItemWithSeparator[] = [
{ id: "1", name: "Item 1" },
SEPARATOR_ITEM,
{ id: "2", name: "Item 2" },
{ id: "3", name: "Item 3" },
];
/** Tracks whichever items were most recently passed to render / rerender,
* so the grouped variant's mock can look them up by index. */
let currentItems: TestItemWithSeparator[] = defaultItems;
const getListComponent = (
items: TestItemWithSeparator[],
extraProps: Record<string, unknown> = {},
): React.JSX.Element => {
currentItems = items;
return variant.createComponent(items, mockGetItemComponent, mockIsItemFocusable, extraProps);
};
const renderListWithHeight = (
overrides: { items?: TestItemWithSeparator[] } & Record<string, unknown> = {},
): ReturnType<typeof render> => {
const { items: overrideItems, ...extraProps } = overrides;
const items = overrideItems ?? defaultItems;
return render(getListComponent(items, extraProps), { wrapper: virtuosoWrapper });
};
beforeEach(() => {
vi.clearAllMocks();
currentItems = defaultItems;
variant.setupDefaultMock(mockGetItemComponent, () => currentItems);
mockIsItemFocusable.mockImplementation((item: TestItemWithSeparator) => item !== SEPARATOR_ITEM);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Rendering", () => {
it("should render the VirtualizedList component", () => {
renderListWithHeight();
expect(screen.getByRole("grid")).toBeDefined();
});
it("should render with empty items array", () => {
renderListWithHeight({ items: [] });
expect(screen.getByRole("grid")).toBeDefined();
});
});
/** Press ArrowDown the required number of times to move from the initial
* focus target (e.g. a group header) to the first regular item. */
const navigateToFirstItem = (container: Element): void => {
for (let i = 0; i < variant.stepsToFirstItem; i++) {
fireEvent.keyDown(container, { code: "ArrowDown" });
}
};
describe("Keyboard Navigation", () => {
it("should handle ArrowDown key navigation", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
fireEvent.focus(container);
navigateToFirstItem(container);
fireEvent.keyDown(container, { code: "ArrowDown" });
// ArrowDown should skip the non-focusable item at index 1 and go to index 2
const items = container.querySelectorAll(".mx_item");
expectTabIndex(items[2], "0");
expectTabIndex(items[0], "-1");
expectTabIndex(items[1], "-1");
});
it("should handle ArrowUp key navigation", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// First focus and navigate down past separator
fireEvent.focus(container);
navigateToFirstItem(container);
fireEvent.keyDown(container, { code: "ArrowDown" });
// Then navigate back up
fireEvent.keyDown(container, { code: "ArrowUp" });
// Verify focus moved back to first item
const items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0");
expectTabIndex(items[1], "-1");
});
it("should handle Home key navigation", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// First focus and navigate to a later item
fireEvent.focus(container);
navigateToFirstItem(container);
fireEvent.keyDown(container, { code: "ArrowDown" });
fireEvent.keyDown(container, { code: "ArrowDown" });
// Then press Home to go to first navigable element
fireEvent.keyDown(container, { code: "Home" });
// Verify focus moved to the very first navigable element
const allNav = container.querySelectorAll(variant.navigableSelector);
expectTabIndex(allNav[0], "0");
// Check that other navigable elements are not focused
for (let i = 1; i < allNav.length; i++) {
expectTabIndex(allNav[i], "-1");
}
});
it("should handle End key navigation", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// First focus on the list
fireEvent.focus(container);
// Then press End to go to last item
fireEvent.keyDown(container, { code: "End" });
// Verify focus moved to last visible navigable element
const allNav = container.querySelectorAll(variant.navigableSelector);
const lastIndex = allNav.length - 1;
expectTabIndex(allNav[lastIndex], "0");
// Check that other navigable elements are not focused
for (let i = 0; i < lastIndex; i++) {
expectTabIndex(allNav[i], "-1");
}
});
it("should handle PageDown key navigation", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// First focus on the list and navigate to first item
fireEvent.focus(container);
navigateToFirstItem(container);
// Then press PageDown to jump down by viewport size
fireEvent.keyDown(container, { code: "PageDown" });
// Verify focus moved down
const items = container.querySelectorAll(".mx_item");
// PageDown should move to the last visible item since we only have 4 items
const lastIndex = items.length - 1;
expectTabIndex(items[lastIndex], "0");
expectTabIndex(items[0], "-1");
});
it("should handle PageUp key navigation", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// First focus, navigate to first item, then End
fireEvent.focus(container);
navigateToFirstItem(container);
fireEvent.keyDown(container, { code: "End" });
// Then press PageUp to jump up by viewport size
fireEvent.keyDown(container, { code: "PageUp" });
// Verify focus moved up use the variant's navigable selector so
// group headers (which are also navigable) are included.
const allNav = container.querySelectorAll(variant.navigableSelector);
// PageUp should move back to the first navigable element since we only have a few items
expectTabIndex(allNav[0], "0");
const lastIndex = allNav.length - 1;
expectTabIndex(allNav[lastIndex], "-1");
});
it("should not handle keyboard navigation when modifier keys are pressed", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
fireEvent.focus(container);
navigateToFirstItem(container);
// Store initial state - first item should be focused
const initialItems = container.querySelectorAll(".mx_item");
expectTabIndex(initialItems[0], "0");
expectTabIndex(initialItems[2], "-1");
// Test ArrowDown with Ctrl modifier - should NOT navigate
fireEvent.keyDown(container, { code: "ArrowDown", ctrlKey: true });
let items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0"); // Should still be on first item
expectTabIndex(items[2], "-1"); // Should not have moved to third item
// Test ArrowDown with Alt modifier - should NOT navigate
fireEvent.keyDown(container, { code: "ArrowDown", altKey: true });
items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0"); // Should still be on first item
expectTabIndex(items[2], "-1"); // Should not have moved to third item
// Test ArrowDown with Shift modifier - should NOT navigate
fireEvent.keyDown(container, { code: "ArrowDown", shiftKey: true });
items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0"); // Should still be on first item
expectTabIndex(items[2], "-1"); // Should not have moved to third item
// Test ArrowDown with Meta/Cmd modifier - should NOT navigate
fireEvent.keyDown(container, { code: "ArrowDown", metaKey: true });
items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0"); // Should still be on first item
expectTabIndex(items[2], "-1"); // Should not have moved to third item
// Test normal ArrowDown without modifiers - SHOULD navigate
fireEvent.keyDown(container, { code: "ArrowDown" });
items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "-1"); // Should have moved from first item
expectTabIndex(items[2], "0"); // Should have moved to third item (skipping separator)
});
it("should skip non-focusable items when navigating down", () => {
// Create items where every other item is not focusable
const mixedItems: TestItemWithSeparator[] = [
{ id: "1", name: "Item 1", isFocusable: true },
{ id: "2", name: "Item 2", isFocusable: false },
{ id: "3", name: "Item 3", isFocusable: true },
SEPARATOR_ITEM,
{ id: "4", name: "Item 4", isFocusable: true },
];
mockIsItemFocusable.mockImplementation((item: TestItemWithSeparator) => {
if (item === SEPARATOR_ITEM) return false;
return (item as TestItem).isFocusable !== false;
});
renderListWithHeight({ items: mixedItems });
const container = screen.getByRole("grid");
fireEvent.focus(container);
navigateToFirstItem(container);
fireEvent.keyDown(container, { code: "ArrowDown" });
// Verify it skipped the non-focusable item at index 1
// and went directly to the focusable item at index 2
const items = container.querySelectorAll(".mx_item");
expectTabIndex(items[2], "0"); // Item 3 is focused
expectTabIndex(items[0], "-1"); // Item 1 is not focused
expectTabIndex(items[1], "-1"); // Item 2 (non-focusable) is not focused
});
it("should skip non-focusable items when navigating up", () => {
const mixedItems: TestItemWithSeparator[] = [
{ id: "1", name: "Item 1", isFocusable: true },
SEPARATOR_ITEM,
{ id: "2", name: "Item 2", isFocusable: false },
{ id: "3", name: "Item 3", isFocusable: true },
];
mockIsItemFocusable.mockImplementation((item: TestItemWithSeparator) => {
if (item === SEPARATOR_ITEM) return false;
return (item as TestItem).isFocusable !== false;
});
renderListWithHeight({ items: mixedItems });
const container = screen.getByRole("grid");
// Focus and go to last item first, then navigate up
fireEvent.focus(container);
fireEvent.keyDown(container, { code: "End" });
fireEvent.keyDown(container, { code: "ArrowUp" });
// Verify it skipped non-focusable items and went to the first focusable item.
// For grouped lists the header sits above the first item, so ArrowUp from
// Item 2 (skipping the non-focusable entries) lands on Item 1.
const items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0"); // Item 1 is focused
expectTabIndex(items[3], "-1"); // Item 3 is not focused anymore
});
});
describe("Focus Management", () => {
it("should focus first navigable element when list gains focus for the first time", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// Initial focus should go to first navigable element
fireEvent.focus(container);
// Verify first navigable element gets focus
const allNav = container.querySelectorAll(variant.navigableSelector);
expectTabIndex(allNav[0], "0");
// Other navigable elements should not be focused
for (let i = 1; i < allNav.length; i++) {
expectTabIndex(allNav[i], "-1");
}
});
it("should restore last focused item when regaining focus", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// Focus and navigate to simulate previous usage
fireEvent.focus(container);
navigateToFirstItem(container);
fireEvent.keyDown(container, { code: "ArrowDown" });
// Verify item 2 is focused (ArrowDown skips separator)
let items = container.querySelectorAll(".mx_item");
expectTabIndex(items[2], "0");
// Simulate blur by focusing elsewhere
fireEvent.blur(container);
// Regain focus should restore last position
fireEvent.focus(container);
// Verify focus is restored to the previously focused item
items = container.querySelectorAll(".mx_item");
expectTabIndex(items[2], "0"); // Should still be item 2
});
it("should not interfere with focus if item is already focused", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// Focus once
fireEvent.focus(container);
// Focus again when already focused
fireEvent.focus(container);
expect(container).toBeDefined();
});
it("should not scroll to top when clicking an item after manual scroll", () => {
// Create a larger list to enable meaningful scrolling
const largerItems: TestItemWithSeparator[] = Array.from({ length: 50 }, (_, i) => ({
id: `item-${i}`,
name: `Item ${i}`,
}));
const mockOnClick = vi.fn();
variant.setupClickTestMock(mockGetItemComponent, mockOnClick, () => currentItems);
const { container } = renderListWithHeight({ items: largerItems });
const listContainer = screen.getByRole("grid");
// Step 1: Focus the list initially and navigate to the first regular item
fireEvent.focus(listContainer);
navigateToFirstItem(listContainer);
// Verify first item is focused and tabIndexKey is set to first item
let items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0");
expectAttribute(items[0], "data-testid", "row-0");
// Step 2: Simulate manual scrolling (mouse wheel, scroll bar drag, etc.)
// This changes which items are visible but DOES NOT change tabIndexKey
// tabIndexKey should still point to "item-0" but "item-0" is no longer visible
fireEvent.scroll(listContainer, { target: { scrollTop: 300 } });
// Step 3: After scrolling, different items should now be visible
// but tabIndexKey should still point to "item-0" (which is no longer visible)
items = container.querySelectorAll(".mx_item");
// Verify that item-0 is no longer in the DOM (because it's scrolled out of view)
const item0 = container.querySelector("[data-testid='row-0']");
expect(item0).toBeNull();
// Find a visible item to click on (should be items from further down the list)
const visibleItems = container.querySelectorAll(".mx_item");
expect(visibleItems.length).toBeGreaterThan(0);
const clickTargetItem = visibleItems[0];
// Click on the visible item
fireEvent.click(clickTargetItem);
// The click should trigger the onFocus callback, which updates the tabIndexKey
// This simulates the real user interaction where clicking an item focuses it
fireEvent.focus(clickTargetItem);
// Verify the click was handled
expect(mockOnClick).toHaveBeenCalled();
// With the fix applied: the clicked item should become focused (tabindex="0")
// This validates that the fix prevents unwanted scrolling back to the top
expectTabIndex(clickTargetItem, "0");
// The key validation: ensure we haven't scrolled back to the top
// item-0 should still not be visible (if the fix is working)
const item0AfterClick = container.querySelector("[data-testid='row-0']");
expect(item0AfterClick).toBeNull();
});
});
describe("Group header keyboard navigation", () => {
// These tests only exercise meaningful behaviour for the grouped variant;
// for the flat variant they degenerate to basic navigation assertions.
it("should navigate from first navigable element to the first item with ArrowDown", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
fireEvent.focus(container);
navigateToFirstItem(container);
const items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0");
});
it("should navigate back to the first navigable element with ArrowUp from the first item", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
fireEvent.focus(container);
navigateToFirstItem(container);
// Now press ArrowUp to go back before the first item
fireEvent.keyDown(container, { code: "ArrowUp" });
const allNav = container.querySelectorAll(variant.navigableSelector);
expectTabIndex(allNav[0], "0");
});
});
describe("Accessibility", () => {
it("should set correct ARIA attributes", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
expectAttribute(container, "role", "grid");
expectAttribute(container, "aria-rowcount", "4");
expectAttribute(container, "aria-colcount", "1");
});
it("should update aria-rowcount when items change", () => {
const { rerender } = renderListWithHeight();
let container = screen.getByRole("grid");
expectAttribute(container, "aria-rowcount", "4");
const fewerItems: TestItemWithSeparator[] = [
{ id: "1", name: "Item 1" },
{ id: "2", name: "Item 2" },
];
rerender(getListComponent(fewerItems));
container = screen.getByRole("grid");
expectAttribute(container, "aria-rowcount", "2");
});
it("should handle custom ARIA label", () => {
renderListWithHeight({ "aria-label": "Custom list label" });
const container = screen.getByRole("grid");
expectAttribute(container, "aria-label", "Custom list label");
});
});
describe("Focus preservation during keyboard navigation", () => {
/**
* Renders a 50-item list using real Virtuoso (no mock context) inside a
* fixed-height container. Because the tests run in real Chromium,
* Virtuoso will measure the viewport, virtualise items, and honour
* scrollIntoView calls exactly as it does in production.
*/
const ITEM_HEIGHT = 52;
const VIEWPORT_HEIGHT = 400;
const renderRealVirtualizedList = (): ReturnType<typeof render> => {
const largeItems: TestItemWithSeparator[] = Array.from({ length: 50 }, (_, i) => ({
id: `item-${i}`,
name: `Item ${i}`,
}));
mockIsItemFocusable.mockReturnValue(true);
mockGetItemComponent.mockImplementation(
(
index: number,
item: TestItemWithSeparator,
context: any,
onFocus: (item: TestItemWithSeparator, e: React.FocusEvent) => void,
) => {
const itemKey = typeof item === "string" ? item : item.id;
const isFocused = context.tabIndexKey === itemKey;
return (
<button
type="button"
className="mx_item"
data-testid={`row-${index}`}
tabIndex={isFocused ? 0 : -1}
role="gridcell"
style={{ height: `${ITEM_HEIGHT}px`, display: "block", width: "100%" }}
onFocus={(e) => onFocus(item, e)}
>
{item === SEPARATOR_ITEM ? "---" : (item as TestItem).name}
</button>
);
},
);
return render(
<FlatVirtualizedList
items={largeItems}
getItemComponent={mockGetItemComponent}
isItemFocusable={mockIsItemFocusable}
getItemKey={(item) => (typeof item === "string" ? item : item.id)}
role="grid"
style={{ height: `${VIEWPORT_HEIGHT}px` }}
fixedItemHeight={ITEM_HEIGHT}
/>,
);
};
it("should scroll down through many items with ArrowDown and virtualise earlier items out of the DOM", async () => {
const { container } = renderRealVirtualizedList();
const listContainer = screen.getByRole("grid");
// Wait for Virtuoso to finish its initial render.
await waitFor(() => {
expect(screen.getByTestId("row-0")).toBeDefined();
});
fireEvent.focus(listContainer);
const TARGET_INDEX = 20;
// Press ArrowDown many times — each press calls scrollIntoView which
// makes Virtuoso scroll and re-virtualise automatically.
for (let i = 0; i < TARGET_INDEX; i++) {
await act(async () => {
fireEvent.keyDown(listContainer, { code: "ArrowDown" });
});
}
// The focused item should be item-20.
await waitFor(() => {
const focused = Array.from(container.querySelectorAll(".mx_item")).find(
(el) => el.getAttribute("tabindex") === "0",
);
expect(focused).toBeDefined();
expect(focused!.textContent).toBe(`Item ${TARGET_INDEX}`);
});
// The first item should have been virtualised out of the DOM.
expect(container.querySelector("[data-testid='row-0']")).toBeNull();
});
it("should move focus from a focused child element to the scroller on keyboard navigation", async () => {
renderRealVirtualizedList();
const listContainer = screen.getByRole("grid");
// Wait for Virtuoso to finish its initial render.
await waitFor(() => {
expect(screen.getByTestId("row-0")).toBeDefined();
});
// Directly focus a child button (not the scroller itself).
// This simulates a user clicking/tabbing into a button inside the list.
const firstButton = screen.getByTestId("row-0");
await act(async () => {
firstButton.focus();
});
// Verify the child button has DOM focus, not the scroller.
expect(document.activeElement).toBe(firstButton);
expect(document.activeElement).not.toBe(listContainer);
// Press ArrowDown — the handler should detect that a child element
// has focus and move it to the scroller before scrolling, so that
// Virtuoso unmounting the child doesn't send focus to <body>.
await act(async () => {
fireEvent.keyDown(listContainer, { code: "ArrowDown" });
});
// After the keyDown, focus should have moved to the scroller element
// (not remain on the child button, and not escape to <body>).
expect(document.activeElement).toBe(listContainer);
});
it("should scroll up through many items with ArrowUp and virtualise later items out of the DOM", async () => {
const { container } = renderRealVirtualizedList();
const listContainer = screen.getByRole("grid");
await waitFor(() => {
expect(screen.getByTestId("row-0")).toBeDefined();
});
fireEvent.focus(listContainer);
// First navigate down to item-30.
for (let i = 0; i < 30; i++) {
await act(async () => {
fireEvent.keyDown(listContainer, { code: "ArrowDown" });
});
}
await waitFor(() => {
const focused = Array.from(container.querySelectorAll(".mx_item")).find(
(el) => el.getAttribute("tabindex") === "0",
);
expect(focused!.textContent).toBe("Item 30");
});
// Now navigate back up 20 times to item-10.
for (let i = 0; i < 20; i++) {
await act(async () => {
fireEvent.keyDown(listContainer, { code: "ArrowUp" });
});
}
// The focused item should be item-10.
await waitFor(() => {
const focused = Array.from(container.querySelectorAll(".mx_item")).find(
(el) => el.getAttribute("tabindex") === "0",
);
expect(focused).toBeDefined();
expect(focused!.textContent).toBe("Item 10");
});
// Items near the bottom (e.g. item-30) should have been virtualised out.
expect(container.querySelector("[data-testid='row-30']")).toBeNull();
});
});
});
@@ -0,0 +1,394 @@
/*
* 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type ListRange, type VirtuosoHandle, type VirtuosoProps } from "react-virtuoso";
/**
* Keyboard key codes
*/
export const Key = {
ARROW_UP: "ArrowUp",
ARROW_DOWN: "ArrowDown",
HOME: "Home",
END: "End",
PAGE_UP: "PageUp",
PAGE_DOWN: "PageDown",
ENTER: "Enter",
SPACE: "Space",
} as const;
/**
* Check if a keyboard event includes modifier keys
*/
export function isModifiedKeyEvent(event: React.KeyboardEvent): boolean {
return event.ctrlKey || event.metaKey || event.shiftKey || event.altKey;
}
/**
* Context object passed to each list item containing the currently focused key
* and any additional context data from the parent component.
*/
export type VirtualizedListContext<Context> = {
/** The key of item that should have tabIndex == 0 */
tabIndexKey?: string;
/** Whether an item in the list is currently focused */
focused: boolean;
/** Additional context data passed from the parent component */
context: Context;
};
export interface VirtualizedListProps<Item, Context> extends Omit<
VirtuosoProps<Item, VirtualizedListContext<Context>>,
"data" | "itemContent" | "context"
> {
/**
* The array of items to display in the virtualized list.
* Each item will be passed to getItemComponent for rendering.
*/
items: Item[];
/**
* Optional additional context data to pass to each rendered item.
* This will be available in the VirtualizedListContext passed to getItemComponent.
*/
context?: Context;
/**
* Function to determine if an item can receive focus during keyboard navigation.
* @param item - The item to check for focusability
* @returns true if the item can be focused, false otherwise
*/
isItemFocusable: (item: Item) => boolean;
/**
* Function to get the key to use for focusing an item.
* @param item - The item to get the key for
* @return The key to use for focusing the item
*/
getItemKey: (item: Item) => string;
/**
* Callback function to handle key down events on the list container.
* List handles keyboard navigation for focus(up, down, home, end, pageUp, pageDown)
* and stops propagation otherwise the event bubbles and this callback is called for the use of the parent.
* @param e - The keyboard event
* @returns
*/
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
/**
* Optional total count of items (for virtualization with partial data loading).
* If provided, this will be used instead of items.length for the total count.
*/
totalCount?: number;
/**
* Optional callback when the visible range of items changes.
* Useful for loading data on-demand as the user scrolls.
* @param range - The new visible range with startIndex and endIndex
*/
rangeChanged?: (range: ListRange) => void;
/**
* Optional function to map from the items array index to the scroll index
* used by virtuoso's scrollIntoView. This is needed when the items array
* contains entries (such as group headers) that don't have a direct 1:1
* mapping with virtuoso's own item indices.
*
* @param itemsIndex - The index in the items array
* @returns The index to pass to virtuoso's scrollIntoView
*/
mapScrollIndex?: (itemsIndex: number) => number;
/**
* Optional function to map from virtuoso's reported visible-range indices
* back to the items array indices. This is needed when virtuoso reports
* ranges in a different index space than the items array (e.g., in
* GroupedVirtuoso where group headers are not counted in the range).
*
* @param virtuosoIndex - The index reported by virtuoso's rangeChanged
* @returns The corresponding index in the items array
*/
mapRangeIndex?: (virtuosoIndex: number) => number;
}
/**
* Utility type for the prop scrollIntoViewOnChange allowing it to be memoised by a caller without repeating types
*/
export type ScrollIntoViewOnChange<Item, Context> = NonNullable<
VirtuosoProps<Item, VirtualizedListContext<Context>>["scrollIntoViewOnChange"]
>;
export interface UseVirtualizedListResult<Item, Context> extends Omit<
VirtuosoProps<Item, VirtualizedListContext<Context>>,
"data" | "itemContent" | "context" | "onKeyDown" | "onFocus" | "onBlur" | "rangeChanged" | "scrollerRef" | "ref"
> {
ref: React.RefObject<VirtuosoHandle | null>;
scrollerRef: (element: HTMLElement | Window | null) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void;
onFocus: (e: React.FocusEvent) => void;
onBlur: (event: React.FocusEvent<HTMLDivElement>) => void;
rangeChanged: (range: ListRange) => void;
onFocusForGetItemComponent: (item: Item, e: React.FocusEvent) => void;
context: VirtualizedListContext<Context>;
}
/**
* A hook that provides keyboard navigation and focus management for a virtualized list
* built on top of react-virtuoso.
*
* Handles Arrow Up/Down, Home, End, Page Up/Down key navigation, focus tracking via
* a roving `tabIndex`, and automatic scrolling to keep the focused item visible.
*
* Returns props to spread onto a Virtuoso component along with an `onFocusForGetItemComponent`
* callback that each item must call on focus to keep the focus state in sync.
*
* @param props - The virtualized list configuration including items, focusability checks,
* key extraction, and any pass-through Virtuoso props.
* @returns An object of props to wire up to a Virtuoso component, plus `onFocusForGetItemComponent`
* for individual item focus handling.
*/
export function useVirtualizedList<Item, Context>(
props: VirtualizedListProps<Item, Context>,
): UseVirtualizedListResult<Item, Context> {
// Extract our custom props to avoid conflicts with Virtuoso props
const {
items,
isItemFocusable,
getItemKey,
context,
onKeyDown,
totalCount,
rangeChanged,
mapScrollIndex,
mapRangeIndex,
...virtuosoProps
} = props;
/** Reference to the Virtuoso component for programmatic scrolling */
const virtuosoHandleRef = useRef<VirtuosoHandle>(null);
/** Reference to the DOM element containing the virtualized list */
const virtuosoDomRef = useRef<HTMLElement | Window>(null);
/** Key of the item that should have tabIndex == 0 */
const [tabIndexKey, setTabIndexKey] = useState<string | undefined>(
props.items[0] ? getItemKey(props.items[0]) : undefined,
);
/** Range of currently visible items in the viewport */
const [visibleRange, setVisibleRange] = useState<ListRange | undefined>(undefined);
/** Map from item keys to their indices in the items array */
const keyToIndexMap = useMemo(() => {
const map = new Map<string, number>();
items.forEach((item, index) => map.set(getItemKey(item), index));
return map;
}, [items, getItemKey]);
const [isFocused, setIsFocused] = useState<boolean>(false);
// Ensure the tabIndexKey is set if there is none already or if the existing key is no longer displayed
useEffect(() => {
if (items.length && (!tabIndexKey || keyToIndexMap.get(tabIndexKey) === undefined)) {
setTabIndexKey(getItemKey(items[0]));
}
}, [items, getItemKey, tabIndexKey, keyToIndexMap]);
/**
* Scrolls to a specific item index and sets it as focused.
* Updates tabIndexKey immediately so the UI reflects the new focus
* synchronously, then asks Virtuoso to scroll the item into view.
*/
const scrollToIndex = useCallback(
(index: number, align?: "center" | "end" | "start"): void => {
// Ensure index is within bounds
const clampedIndex = Math.max(0, Math.min(index, items.length - 1));
if (items[clampedIndex]) {
const key = getItemKey(items[clampedIndex]);
setTabIndexKey(key);
const scrollIndex = mapScrollIndex ? mapScrollIndex(clampedIndex) : clampedIndex;
virtuosoHandleRef.current?.scrollIntoView({
index: scrollIndex,
align: align,
behavior: "auto",
});
}
},
[items, getItemKey, mapScrollIndex],
);
/**
* Scrolls to an item, skipping over non-focusable items if necessary.
* This is used for keyboard navigation to ensure focus lands on valid items.
*/
const scrollToItem = useCallback(
(index: number, isDirectionDown: boolean, align?: "center" | "end" | "start"): void => {
const totalRows = items.length;
let nextIndex: number | undefined;
for (let i = index; isDirectionDown ? i < totalRows : i >= 0; i = i + (isDirectionDown ? 1 : -1)) {
if (isItemFocusable(items[i])) {
nextIndex = i;
break;
}
}
if (nextIndex === undefined) {
return;
}
scrollToIndex(nextIndex, align);
},
[scrollToIndex, items, isItemFocusable],
);
/**
* Handles keyboard navigation for the list.
* Supports Arrow keys, Home, End, Page Up/Down, Enter, and Space.
*/
const keyDownCallback = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const currentIndex = tabIndexKey ? keyToIndexMap.get(tabIndexKey) : undefined;
let handled = false;
// Guard against null/undefined events and modified keys which we don't want to handle here but do
// at the settings level shortcuts(E.g. Select next room, etc )
// Guard against null/undefined events and modified keys
if (!e || isModifiedKeyEvent(e)) {
onKeyDown?.(e);
return;
}
if (e.code === Key.ARROW_UP && currentIndex !== undefined) {
scrollToItem(currentIndex - 1, false);
handled = true;
} else if (e.code === Key.ARROW_DOWN && currentIndex !== undefined) {
scrollToItem(currentIndex + 1, true);
handled = true;
} else if (e.code === Key.HOME) {
scrollToIndex(0);
handled = true;
} else if (e.code === Key.END) {
scrollToIndex(items.length - 1);
handled = true;
} else if (e.code === Key.PAGE_DOWN && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.min(currentIndex + numberDisplayed, items.length - 1), true, "start");
handled = true;
} else if (e.code === Key.PAGE_UP && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.max(currentIndex - numberDisplayed, 0), false, "start");
handled = true;
}
if (handled) {
// If a child element (e.g. a button) currently has DOM focus rather than the
// scroller itself, move focus to the scroller before the scroll takes effect.
// Without this, when Virtuoso unmounts the focused child because it has been
// scrolled out of the visible range, the browser moves focus to <body> and
// subsequent keyboard events no longer reach this handler.
if (virtuosoDomRef.current instanceof HTMLElement) {
const activeEl = document.activeElement;
if (activeEl && activeEl !== virtuosoDomRef.current && virtuosoDomRef.current.contains(activeEl)) {
virtuosoDomRef.current.focus({ preventScroll: true });
}
}
e.stopPropagation();
e.preventDefault();
} else {
onKeyDown?.(e);
}
},
[scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items, onKeyDown],
);
/**
* Callback ref for the Virtuoso scroller element.
* Stores the reference for use in focus management.
*/
const scrollerRef = useCallback((element: HTMLElement | Window | null) => {
virtuosoDomRef.current = element;
}, []);
/**
* Focus handler passed to each item component.
* Don't declare inside getItemComponent to avoid re-creating on each render.
*/
const onFocusForGetItemComponent = useCallback(
(item: Item, e: React.FocusEvent) => {
// If one of the item components has been focused directly, set the focused and tabIndex state
// and stop propagation so the List's onFocus doesn't also handle it.
const key = getItemKey(item);
setIsFocused(true);
setTabIndexKey(key);
e.stopPropagation();
},
[getItemKey],
);
/**
* Handles focus events on the list.
* Sets the focused state and scrolls to the focused item if it is not currently visible.
*/
const onFocus = useCallback(
(e: React.FocusEvent): void => {
if (e?.currentTarget !== virtuosoDomRef.current || typeof tabIndexKey !== "string") {
return;
}
setIsFocused(true);
const index = keyToIndexMap.get(tabIndexKey);
if (
index !== undefined &&
visibleRange &&
(index < visibleRange.startIndex || index > visibleRange.endIndex)
) {
scrollToIndex(index);
}
e.stopPropagation();
e.preventDefault();
},
[keyToIndexMap, visibleRange, scrollToIndex, tabIndexKey],
);
const onBlur = useCallback((event: React.FocusEvent<HTMLDivElement>): void => {
// Only set isFocused to false if the focus is moving outside the list
// This prevents the list from losing focus when interacting with menus inside it
if (!event.currentTarget.contains(event.relatedTarget)) {
setIsFocused(false);
}
}, []);
const listContext: VirtualizedListContext<Context> = useMemo(
() => ({
tabIndexKey: tabIndexKey,
focused: isFocused,
context: props.context || ({} as Context),
}),
[tabIndexKey, isFocused, props.context],
);
// Combine internal range tracking with optional external callback
const handleRangeChanged = useCallback(
(range: ListRange) => {
const internalRange = mapRangeIndex
? { startIndex: mapRangeIndex(range.startIndex), endIndex: mapRangeIndex(range.endIndex) }
: range;
setVisibleRange(internalRange);
rangeChanged?.(range);
},
[rangeChanged, mapRangeIndex],
);
return {
...virtuosoProps,
ref: virtuosoHandleRef,
scrollerRef,
onKeyDown: keyDownCallback,
onFocus,
onBlur,
rangeChanged: handleRangeChanged,
onFocusForGetItemComponent,
context: listContext,
};
}
@@ -0,0 +1,31 @@
/*
* Copyright 2025 New Vector 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.
*/
.avatarWithDetails {
display: flex;
align-items: center;
border-radius: 12px;
background-color: var(--cpd-color-gray-200);
padding: var(--cpd-space-2x);
gap: var(--cpd-space-2x);
.title {
display: inline-block;
font-weight: var(--cpd-font-weight-semibold);
font-size: var(--cpd-font-size-body-md);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.details {
font-size: var(--cpd-font-size-body-sm);
}
}
@@ -0,0 +1,26 @@
/*
* Copyright 2025 New Vector 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 { type Meta, type StoryObj } from "@storybook/react-vite";
import { AvatarWithDetails } from "./AvatarWithDetails";
const meta = {
title: "Avatar/AvatarWithDetails",
component: AvatarWithDetails,
tags: ["autodocs"],
args: {
avatar: <div style={{ width: 40, height: 40, backgroundColor: "#888", borderRadius: "50%" }} />,
details: "Details about the avatar go here",
title: "Room Name",
},
} satisfies Meta<typeof AvatarWithDetails>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
@@ -0,0 +1,22 @@
/*
Copyright 2025 New Vector 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 { composeStories } from "@storybook/react-vite";
import { render } from "@test-utils";
import React from "react";
import { describe, it, expect } from "vitest";
import * as stories from "./AvatarWithDetails.stories.tsx";
const { Default } = composeStories(stories);
describe("AvatarWithDetails", () => {
it("renders a textual event", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
});
@@ -0,0 +1,65 @@
/*
* Copyright 2025 New Vector 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 { type ComponentProps, type ElementType, type JSX, type PropsWithChildren } from "react";
import React from "react";
import classNames from "classnames";
import styles from "./AvatarWithDetails.module.css";
import { Flex } from "../../utils/Flex";
export type AvatarWithDetailsProps<C extends ElementType> = {
/**
* The HTML tag.
* @default "div"
*/
as?: C;
/**
* The CSS class name.
*/
className?: string;
/**
* The title/label next to the avatar. Usually the user or room name.
*/
title: string;
/**
* A label with details to display under the avatar title.
* Commonly used to display the number of participants in a room.
*/
details: React.ReactNode;
/** The avatar to display. */
avatar: React.ReactNode;
} & ComponentProps<C>;
/**
* A component to display an avatar with a title next to it in a grey box.
*
* @example
* ```tsx
* <AvatarWithDetails title="Room Name" details="10 participants" className="custom-class" />
* ```
*/
export function AvatarWithDetails<C extends React.ElementType = "div">({
as,
className,
details,
avatar,
title,
...props
}: PropsWithChildren<AvatarWithDetailsProps<C>>): JSX.Element {
const Component = as || "div";
return (
<Component className={classNames(styles.avatarWithDetails, className)} {...props}>
{avatar}
<Flex direction="column">
<span className={styles.title}>{title}</span>
<span className={styles.details}>{details}</span>
</Flex>
</Component>
);
}
@@ -0,0 +1,28 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AvatarWithDetails > renders a textual event 1`] = `
<div>
<div
class="avatarWithDetails"
>
<div
style="width: 40px; height: 40px; background-color: rgb(136, 136, 136); border-radius: 50%;"
/>
<div
class="flex"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="title"
>
Room Name
</span>
<span
class="details"
>
Details about the avatar go here
</span>
</div>
</div>
</div>
`;
@@ -0,0 +1,8 @@
/*
* Copyright 2025 New Vector 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 { AvatarWithDetails } from "./AvatarWithDetails";
@@ -0,0 +1,156 @@
/*
* Copyright 2025 New Vector 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 { type KeyboardEvent } from "react";
import { renderHook } from "@test-utils";
import { vi, describe, expect, it, beforeEach, afterEach } from "vitest";
import { useListKeyboardNavigation } from "./useListKeyboardNavigation";
describe("useListKeyDown", () => {
let mockList: HTMLUListElement;
let mockItems: HTMLElement[];
let mockEvent: Partial<KeyboardEvent<HTMLUListElement>>;
beforeEach(() => {
// Create mock DOM elements
mockList = document.createElement("ul");
mockItems = [document.createElement("li"), document.createElement("li"), document.createElement("li")];
// Set up the DOM structure
mockItems.forEach((item, index) => {
item.setAttribute("tabindex", "0");
item.setAttribute("data-testid", `item-${index}`);
mockList.appendChild(item);
});
document.body.appendChild(mockList);
// Mock event object
mockEvent = {
preventDefault: vi.fn(),
key: "",
};
// Mock focus methods
mockItems.forEach((item) => {
item.focus = vi.fn();
item.click = vi.fn();
});
});
afterEach(() => {
document.body.removeChild(mockList);
vi.clearAllMocks();
});
function render(): {
current: {
listRef: React.RefObject<HTMLUListElement | null>;
onKeyDown: React.KeyboardEventHandler<HTMLUListElement>;
onFocus: React.FocusEventHandler<HTMLUListElement>;
};
} {
const { result } = renderHook(() => useListKeyboardNavigation());
result.current.listRef.current = mockList;
return result;
}
it.each([
["Enter", "Enter"],
["Space", " "],
])("should handle %s key to click active element", (name, key) => {
const result = render();
// Mock document.activeElement
Object.defineProperty(document, "activeElement", {
value: mockItems[1],
configurable: true,
});
// Simulate key press
result.current.onKeyDown({
...mockEvent,
key,
} as KeyboardEvent<HTMLUListElement>);
expect(mockItems[1].click).toHaveBeenCalledTimes(1);
expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
});
it.each(
// key, finalPosition, startPosition
[
["ArrowDown", 1, 0],
["ArrowUp", 1, 2],
["Home", 0, 1],
["End", 2, 1],
],
)("should handle %s to focus the %inth element", (key, finalPosition, startPosition) => {
const result = render();
mockList.contains = vi.fn().mockReturnValue(true);
Object.defineProperty(document, "activeElement", {
value: mockItems[startPosition],
configurable: true,
});
result.current.onKeyDown({
...mockEvent,
key,
} as KeyboardEvent<HTMLUListElement>);
expect(mockItems[finalPosition].focus).toHaveBeenCalledTimes(1);
expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
});
it.each([["ArrowDown"], ["ArrowUp"]])("should not handle %s when active element is not in list", (key) => {
const result = render();
mockList.contains = vi.fn().mockReturnValue(false);
const outsideElement = document.createElement("button");
Object.defineProperty(document, "activeElement", {
value: outsideElement,
configurable: true,
});
result.current.onKeyDown({
...mockEvent,
key,
} as KeyboardEvent<HTMLUListElement>);
// No item should be focused
mockItems.forEach((item) => expect(item.focus).not.toHaveBeenCalled());
expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
});
it("should not prevent default for unhandled keys", () => {
const result = render();
result.current.onKeyDown({
...mockEvent,
key: "Tab",
} as KeyboardEvent<HTMLUListElement>);
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
});
it("should focus the first item if list itself is focused", () => {
const result = render();
result.current.onFocus({ target: mockList } as React.FocusEvent<HTMLUListElement>);
expect(mockItems[0].focus).toHaveBeenCalledTimes(1);
});
it("should focus the selected item if list itself is focused", () => {
mockItems[1].setAttribute("aria-selected", "true");
const result = render();
result.current.onFocus({ target: mockList } as React.FocusEvent<HTMLUListElement>);
expect(mockItems[1].focus).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,92 @@
/*
* Copyright 2025 New Vector 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 {
useCallback,
useRef,
type RefObject,
type KeyboardEvent,
type KeyboardEventHandler,
type FocusEventHandler,
type FocusEvent,
} from "react";
/**
* A hook that provides keyboard navigation for a list of options.
*/
export function useListKeyboardNavigation(): {
listRef: RefObject<HTMLUListElement | null>;
onKeyDown: KeyboardEventHandler<HTMLUListElement>;
onFocus: FocusEventHandler<HTMLUListElement>;
} {
const listRef = useRef<HTMLUListElement>(null);
const onFocus = useCallback((evt: FocusEvent<HTMLUListElement>) => {
if (!listRef.current) return;
if (evt.target === listRef.current) {
// By default, focus the selected item
let selectedChild = listRef.current?.firstElementChild;
// If there is a selected item, focus that instead
for (const child of listRef.current.children) {
if (child.getAttribute("aria-selected") === "true") {
selectedChild = child;
break;
}
}
(selectedChild as HTMLElement)?.focus();
}
}, []);
const onKeyDown = useCallback((evt: KeyboardEvent<HTMLUListElement>) => {
const { key } = evt;
let handled = false;
switch (key) {
case "Enter":
case " ": {
handled = true;
(document.activeElement as HTMLElement).click();
break;
}
case "ArrowDown": {
handled = true;
const currentFocus = document.activeElement;
if (listRef.current?.contains(currentFocus) && currentFocus) {
(currentFocus.nextElementSibling as HTMLElement)?.focus();
}
break;
}
case "ArrowUp": {
handled = true;
const currentFocus = document.activeElement;
if (listRef.current?.contains(currentFocus) && currentFocus) {
(currentFocus.previousElementSibling as HTMLElement)?.focus();
}
break;
}
case "Home": {
handled = true;
(listRef.current?.firstElementChild as HTMLElement)?.focus();
break;
}
case "End": {
handled = true;
(listRef.current?.lastElementChild as HTMLElement)?.focus();
break;
}
}
if (handled) {
evt.preventDefault();
}
}, []);
return { listRef, onKeyDown, onFocus };
}
@@ -0,0 +1,23 @@
/*
* Copyright 2025 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 { I18nApi } from "./I18nApi";
describe("I18nApi", () => {
it("can register a translation and use it", () => {
const i18n = new I18nApi();
i18n.register({
["hello.world" as TranslationKey]: {
en: "Hello, World!",
},
});
expect(i18n.translate("hello.world" as TranslationKey)).toBe("Hello, World!");
});
});
@@ -0,0 +1,50 @@
/*
Copyright 2025 New Vector 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 { type I18nApi as II18nApi, type Variables, type Translations } from "@element-hq/element-web-module-api";
import { humanizeTime } from "../utils/humanize";
import { _t, getLocale, registerTranslations } from "./i18n";
export class I18nApi implements II18nApi {
/**
* Read the current language of the user in IETF Language Tag format
*/
public get language(): string {
return getLocale();
}
/**
* Register translations for the module, may override app's existing translations
*/
public register(this: void, translations: Partial<Translations>): void {
const langs: Record<string, Record<string, string>> = {};
for (const key in translations) {
for (const lang in translations[key as keyof Translations]) {
langs[lang] = langs[lang] || {};
langs[lang][key] = translations[key as keyof Translations]![lang];
}
}
// Finally, tell counterpart about our translations
for (const lang in langs) {
registerTranslations(lang, langs[lang]);
}
}
/**
* Perform a translation, with optional variables
* @param key - The key to translate
* @param variables - Optional variables to interpolate into the translation
*/
public translate(this: void, key: TranslationKey, variables?: Variables): string {
return _t(key, variables);
}
public humanizeTime = (timeMillis: number): string => humanizeTime(timeMillis, this);
}
@@ -0,0 +1,47 @@
/*
* Copyright 2025 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 counterpart from "counterpart";
import { vi, describe, it, beforeEach, expect } from "vitest";
import { registerTranslations, setMissingEntryGenerator, getLocale, setLocale } from "./i18n";
describe("i18n utils", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should wrap registerTranslations", () => {
vi.spyOn(counterpart, "registerTranslations");
registerTranslations("en", { test: "This is a test" });
expect(counterpart.registerTranslations).toHaveBeenCalledWith("en", { test: "This is a test" });
});
it("should wrap setMissingEntryGenerator", () => {
vi.spyOn(counterpart, "setMissingEntryGenerator");
const dummyFn = vi.fn();
setMissingEntryGenerator(dummyFn);
expect(counterpart.setMissingEntryGenerator).toHaveBeenCalledWith(dummyFn);
});
it("should wrap getLocale", () => {
vi.spyOn(counterpart, "getLocale");
getLocale();
expect(counterpart.getLocale).toHaveBeenCalled();
});
it("should wrap setLocale", () => {
vi.spyOn(counterpart, "setLocale");
setLocale("en");
expect(counterpart.setLocale).toHaveBeenCalledWith("en");
});
});
@@ -0,0 +1,431 @@
/*
* Copyright 2025 New Vector 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.
*/
/*
* Translates text and optionally also replaces XML-ish elements in the text with e.g. React components
* @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s".
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
*
* In both variables and tags, the values to substitute with can be either simple strings, React components,
* or functions that return the value to use in the substitution (e.g. return a React component). In case of
* a tag replacement, the function receives as the argument the text inside the element corresponding to the tag.
*
* Use tag substitutions if you need to translate text between tags (e.g. "<a>Click here!</a>"), otherwise
* you will end up with literal "<a>" in your output, rather than HTML. Note that you can also use variable
* substitution to insert React components, but you can't use it to translate text between tags.
*
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/
import React from "react";
import { KEY_SEPARATOR } from "matrix-web-i18n";
import counterpart from "counterpart";
export { KEY_SEPARATOR, normalizeLanguageKey, getNormalizedLanguageKeys } from "matrix-web-i18n";
// Path where we load language files from (the index plus translations for each language)
// The filename is appended to this, so a relative path here will result in a fetch for
// a relative URL.
const i18nFolder = "i18n/";
// Control whether to also return original, untranslated strings
// Useful for debugging and testing
const ANNOTATE_STRINGS = false;
// We use english strings as keys, some of which contain full stops
counterpart.setSeparator(KEY_SEPARATOR);
// see `translateWithFallback` for an explanation of fallback handling
const FALLBACK_LOCALE = "en";
counterpart.setFallbackLocale(FALLBACK_LOCALE);
// export wrappers around these functions because if we used counterpart directly from
// element-web, it operates on a different instance of counterpart
export function registerTranslations(locale: string, data: object): void {
counterpart.registerTranslations(locale, data);
}
export function setMissingEntryGenerator(callback: (value: string) => void): void {
counterpart.setMissingEntryGenerator(callback);
}
export function getLocale(): string {
return counterpart.getLocale();
}
export function setLocale(value: string): string {
return counterpart.setLocale(value);
}
// Function which only purpose is to mark that a string is translatable
// Does not actually do anything. It's helpful for automatic extraction of translatable strings
export function _td(s: TranslationKey): TranslationKey {
return s;
}
function isValidTranslation(translated: string): boolean {
return typeof translated === "string" && !translated.startsWith("missing translation:");
}
/**
* to improve screen reader experience translations that are not in the main page language
* eg a translation that fell back to english from another language
* should be wrapped with an appropriate `lang='en'` attribute
* counterpart's `translate` doesn't expose a way to determine if the resulting translation
* is in the target locale or a fallback locale
* for this reason, force fallbackLocale === locale in the first call to translate
* and fallback 'manually' so we can mark fallback strings appropriately
* */
const translateWithFallback = (text: string, options?: IVariables): { translated: string; isFallback?: boolean } => {
const translated = counterpart.translate(text, { ...options, fallbackLocale: counterpart.getLocale() });
if (isValidTranslation(translated)) {
return { translated };
}
const fallbackTranslated = counterpart.translate(text, { ...options, locale: FALLBACK_LOCALE });
if (isValidTranslation(fallbackTranslated)) {
return { translated: fallbackTranslated, isFallback: true };
}
// Even the translation via FALLBACK_LOCALE failed; this can happen if
//
// 1. The string isn't in the translations dictionary, usually because you're in develop
// and haven't run pnpm i18n
// 2. Loading the translation resources over the network failed, which can happen due to
// to network or if the client tried to load a translation that's been removed from the
// server.
//
// At this point, its the lesser evil to show the i18n key which will be in English but not human-friendly,
// so the user can still make out *something*, rather than an opaque possibly-untranslated "missing translation" error.
return { translated: text, isFallback: true };
};
// Wrapper for counterpart's translation function so that it handles nulls and undefineds properly
// Takes the same arguments as counterpart.translate()
function safeCounterpartTranslate(text: string, variables?: IVariables): { translated: string; isFallback?: boolean } {
// Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components
// However, still pass the variables to counterpart so that it can choose the correct plural if count is given
// It is enough to pass the count variable, but in the future counterpart might make use of other information too
const options: IVariables & {
interpolate: boolean;
} = { ...variables, interpolate: false };
// Horrible hack to avoid https://github.com/vector-im/element-web/issues/4191
// The interpolation library that counterpart uses does not support undefined/null
// values and instead will throw an error. This is a problem since everywhere else
// in JS land passing undefined/null will simply stringify instead, and when converting
// valid ES6 template strings to i18n strings it's extremely easy to pass undefined/null
// if there are no existing null guards. To avoid this making the app completely inoperable,
// we'll check all the values for undefined/null and stringify them here.
if (options && typeof options === "object") {
Object.keys(options).forEach((k) => {
if (options[k] === undefined) {
console.warn("safeCounterpartTranslate called with undefined interpolation name: " + k);
options[k] = "undefined";
}
if (options[k] === null) {
console.warn("safeCounterpartTranslate called with null interpolation name: " + k);
options[k] = "null";
}
});
}
return translateWithFallback(text, options);
}
/**
* The value a variable or tag can take for a translation interpolation.
*/
type SubstitutionValue = number | string | React.ReactNode | ((sub: string) => React.ReactNode);
export interface IVariables {
count?: number;
[key: string]: SubstitutionValue;
}
export type Tags = Record<string, SubstitutionValue>;
export type TranslatedString = string | React.ReactNode;
// For development/testing purposes it is useful to also output the original string
// Don't do that for release versions
const annotateStrings = (result: TranslatedString, translationKey: TranslationKey): TranslatedString => {
if (!ANNOTATE_STRINGS) {
return result;
}
if (typeof result === "string") {
return `@@${translationKey}##${result}@@`;
} else {
return (
<span className="translated-string" data-orig-string={translationKey}>
{result}
</span>
);
}
};
export function _t(text: TranslationKey, variables?: IVariables): string;
export function _t(text: TranslationKey, variables: IVariables | undefined, tags: Tags): React.ReactNode;
export function _t(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString {
// The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
const { translated } = safeCounterpartTranslate(text, variables);
const substituted = substitute(translated, variables, tags);
return annotateStrings(substituted, text);
}
/**
* Utility function to look up a string by its translation key without resolving variables & tags
* @param key - the translation key to return the value for
*/
export function lookupString(key: TranslationKey): string {
return safeCounterpartTranslate(key, {}).translated;
}
/*
* Wraps normal _t function and adds atttribution for translations that used a fallback locale
* Wraps translations that fell back from active locale to fallback locale with a `<span lang=<fallback locale>>`
* @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s".
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
*
* @return a React <span> component if any non-strings were used in substitutions
* or translation used a fallback locale, otherwise a string
*/
// eslint-next-line @typescript-eslint/naming-convention
export function _tDom(text: TranslationKey, variables?: IVariables): TranslatedString;
export function _tDom(text: TranslationKey, variables: IVariables, tags: Tags): React.ReactNode;
export function _tDom(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString {
// The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
const { translated, isFallback } = safeCounterpartTranslate(text, variables);
const substituted = substitute(translated, variables, tags);
// wrap en fallback translation with lang attribute for screen readers
const result = isFallback ? <span lang="en">{substituted}</span> : substituted;
return annotateStrings(result, text);
}
/**
* Sanitizes unsafe text for the sanitizer, ensuring references to variables will not be considered
* replaceable by the translation functions.
* @param {string} text The text to sanitize.
* @returns {string} The sanitized text.
*/
export function sanitizeForTranslation(text: string): string {
// Add a non-breaking space so the regex doesn't trigger when translating.
return text.replace(/%\(([^)]*)\)/g, "%\xa0($1)");
}
/*
* Similar to _t(), except only does substitutions, and no translation
* @param {string} text The text, e.g "click <a>here</a> now to %(foo)s".
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
*
* The values to substitute with can be either simple strings, or functions that return the value to use in
* the substitution (e.g. return a React component). In case of a tag replacement, the function receives as
* the argument the text inside the element corresponding to the tag.
*
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/
export function substitute(text: string, variables?: IVariables): string;
export function substitute(text: string, variables: IVariables | undefined, tags: Tags | undefined): string;
export function substitute(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode {
let result: React.ReactNode | string = text;
if (variables !== undefined) {
const regexpMapping: IVariables = {};
for (const variable in variables) {
regexpMapping[`%\\(${variable}\\)s`] = variables[variable];
}
result = replaceByRegexes(result as string, regexpMapping);
}
if (tags !== undefined) {
const regexpMapping: Tags = {};
for (const tag in tags) {
regexpMapping[`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`] = tags[tag];
}
result = replaceByRegexes(result as string, regexpMapping);
}
return result;
}
/**
* Replace parts of a text using regular expressions
* @param text - The text on which to perform substitutions
* @param mapping - A mapping from regular expressions in string form to replacement string or a
* function which will receive as the argument the capture groups defined in the regexp. E.g.
* { 'Hello (.?) World': (sub) => sub.toUpperCase() }
*
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/
export function replaceByRegexes(text: string, mapping: IVariables): string;
export function replaceByRegexes(text: string, mapping: Tags): React.ReactNode;
export function replaceByRegexes(text: string, mapping: IVariables | Tags): string | React.ReactNode {
// We initially store our output as an array of strings and objects (e.g. React components).
// This will then be converted to a string or a <span> at the end
const output: SubstitutionValue[] = [text];
// If we insert any components we need to wrap the output in a span. React doesn't like just an array of components.
let shouldWrapInSpan = false;
for (const regexpString in mapping) {
// TODO: Cache regexps
const regexp = new RegExp(regexpString, "g");
// Loop over what output we have so far and perform replacements
// We look for matches: if we find one, we get three parts: everything before the match, the replaced part,
// and everything after the match. Insert all three into the output. We need to do this because we can insert objects.
// Otherwise there would be no need for the splitting and we could do simple replacement.
let matchFoundSomewhere = false; // If we don't find a match anywhere we want to log it
for (let outputIndex = 0; outputIndex < output.length; outputIndex++) {
const inputText = output[outputIndex];
if (typeof inputText !== "string") {
// We might have inserted objects earlier, don't try to replace them
continue;
}
// process every match in the string
// starting with the first
let match = regexp.exec(inputText);
if (!match) continue;
matchFoundSomewhere = true;
// The textual part before the first match
const head = inputText.slice(0, match.index);
const parts: SubstitutionValue[] = [];
// keep track of prevMatch
let prevMatch;
while (match) {
// store prevMatch
prevMatch = match;
const capturedGroups = match.slice(2);
let replaced: SubstitutionValue;
// If substitution is a function, call it
if (mapping[regexpString] instanceof Function) {
replaced = ((mapping as Tags)[regexpString] as (...subs: string[]) => string)(...capturedGroups);
} else {
replaced = mapping[regexpString];
}
if (typeof replaced === "object") {
shouldWrapInSpan = true;
}
// Here we also need to check that it actually is a string before comparing against one
// The head and tail are always strings
if (typeof replaced !== "string" || replaced !== "") {
parts.push(replaced);
}
// try the next match
match = regexp.exec(inputText);
// add the text between prevMatch and this one
// or the end of the string if prevMatch is the last match
let tail;
if (match) {
const startIndex = prevMatch.index + prevMatch[0].length;
tail = inputText.slice(startIndex, match.index);
} else {
tail = inputText.slice(prevMatch.index + prevMatch[0].length);
}
if (tail) {
parts.push(tail);
}
}
// Insert in reverse order as splice does insert-before and this way we get the final order correct
// remove the old element at the same time
output.splice(outputIndex, 1, ...parts);
if (head !== "") {
// Don't push empty nodes, they are of no use
output.splice(outputIndex, 0, head);
}
}
if (!matchFoundSomewhere) {
if (
// The current regexp did not match anything in the input. Missing
// matches is entirely possible because you might choose to show some
// variables only in the case of e.g. plurals. It's still a bit
// suspicious, and could be due to an error, so log it. However, not
// showing count is so common that it's not worth logging. And other
// commonly unused variables here, if there are any.
regexpString !== "%\\(count\\)s" &&
// Ignore the `locale` option which can be used to override the locale
// in counterpart
regexpString !== "%\\(locale\\)s"
) {
console.log(`Could not find ${regexp} in ${text}`);
}
}
}
if (shouldWrapInSpan) {
return React.createElement("span", null, ...(output as Array<number | string | React.ReactNode>));
} else {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
return output.join("");
}
}
type Languages = {
[lang: string]: string;
};
/**
* Sets the language for the application.
* In Element web,`languageHandler.setLanguage` should be used instead.
* @param language
*/
export async function setLanguage(language: string): Promise<void> {
const availableLanguages = await getLangsJson();
const chosenLanguage = language in availableLanguages ? language : "en";
const languageData = await getLanguage(i18nFolder + availableLanguages[chosenLanguage]);
counterpart.registerTranslations(chosenLanguage, languageData);
counterpart.setLocale(chosenLanguage);
}
interface ICounterpartTranslation {
[key: string]:
| string
| {
[pluralisation: string]: string;
};
}
async function getLanguage(langPath: string): Promise<ICounterpartTranslation> {
console.log("Loading language from", langPath);
const res = await fetch(langPath, { method: "GET" });
if (!res.ok) {
throw new Error(`Failed to load ${langPath}, got ${res.status}`);
}
return res.json();
}
export async function getLangsJson(): Promise<Languages> {
const url = i18nFolder + "languages.json";
const res = await fetch(url, { method: "GET" });
if (!res.ok) {
throw new Error(`Failed to load ${url}, got ${res.status}`);
}
return res.json();
}
@@ -0,0 +1,27 @@
/*
Copyright 2025 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 { createContext, useContext } from "react";
import { type I18nApi } from "@element-hq/element-web-module-api";
export const I18nContext = createContext<I18nApi | null>(null);
I18nContext.displayName = "I18nContext";
/**
* A hook to get the i18n API from the context. Will throw if no i18n context is found.
* @throws If no i18n context is found
* @returns The i18n API from the context
*/
export function useI18n(): I18nApi {
const i18n = useContext(I18nContext);
if (!i18n) {
throw new Error("useI18n must be used within an I18nContext.Provider");
}
return i18n;
}
@@ -0,0 +1,17 @@
/*
* Copyright 2025 New Vector 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.
*/
.pill {
background-color: var(--cpd-color-bg-action-primary-rest);
padding: var(--cpd-space-1x) var(--cpd-space-1-5x) var(--cpd-space-1x) var(--cpd-space-1x);
border-radius: 99px;
}
.label {
color: var(--cpd-color-text-on-solid-primary);
font: var(--cpd-font-body-sm-medium);
}
@@ -0,0 +1,33 @@
/*
* Copyright 2025 New Vector 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 { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Pill } from "./Pill";
const meta = {
title: "PillInput/Pill",
component: Pill,
tags: ["autodocs"],
args: {
label: "Pill",
children: <div style={{ width: 20, height: 20, borderRadius: "100%", backgroundColor: "#ccc" }} />,
onClick: fn(),
},
} satisfies Meta<typeof Pill>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithoutCloseButton: Story = {
args: {
onClick: undefined,
},
};
@@ -0,0 +1,27 @@
/*
* Copyright 2025 New Vector 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 { composeStories } from "@storybook/react-vite";
import { render } from "@test-utils";
import React from "react";
import { describe, it, expect } from "vitest";
import * as stories from "./Pill.stories";
const { Default, WithoutCloseButton } = composeStories(stories);
describe("Pill", () => {
it("renders the pill", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders the pill without close button", () => {
const { container } = render(<WithoutCloseButton />);
expect(container).toMatchSnapshot();
});
});
@@ -0,0 +1,69 @@
/*
* Copyright 2025 New Vector 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 MouseEventHandler, type JSX, type PropsWithChildren, type HTMLAttributes, useId } from "react";
import classNames from "classnames";
import { IconButton } from "@vector-im/compound-web";
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
import { Flex } from "../../utils/Flex";
import styles from "./Pill.module.css";
import { useI18n } from "../../i18n/i18nContext";
export interface PillProps extends Omit<HTMLAttributes<HTMLDivElement>, "onClick"> {
/**
* The text label to display inside the pill.
*/
label: string;
/**
* Optional click handler for a close button.
* If provided, a close button will be rendered.
*/
onClick?: MouseEventHandler<HTMLButtonElement>;
}
/**
* A pill component that can display a label and an optional close button.
* The badge can also contain child elements, such as icons or avatars.
*
* @example
* ```tsx
* <Pill label="New" onClick={() => console.log("Closed")}>
* <SomeIcon />
* </Pill>
* ```
*/
export function Pill({ className, children, label, onClick, ...props }: PropsWithChildren<PillProps>): JSX.Element {
const id = useId();
const { translate: _t } = useI18n();
return (
<Flex
display="inline-flex"
gap="var(--cpd-space-1-5x)"
align="center"
className={classNames(styles.pill, className)}
{...props}
>
{children}
<span id={id} className={styles.label}>
{label}
</span>
{onClick && (
<IconButton
aria-describedby={id}
size="16px"
onClick={onClick}
aria-label={_t("action|delete")}
className="mx_Dialog_nonDialogButton"
>
<CloseIcon />
</IconButton>
)}
</Flex>
);
}
@@ -0,0 +1,65 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Pill > renders the pill 1`] = `
<div>
<div
class="flex pill"
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
>
<div
style="width: 20px; height: 20px; border-radius: 100%; background-color: rgb(204, 204, 204);"
/>
<span
class="label"
id="_r_0_"
>
Pill
</span>
<button
aria-describedby="_r_0_"
aria-label="Delete"
class="_icon-button_1215g_8 mx_Dialog_nonDialogButton"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 16px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</button>
</div>
</div>
`;
exports[`Pill > renders the pill without close button 1`] = `
<div>
<div
class="flex pill"
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
>
<div
style="width: 20px; height: 20px; border-radius: 100%; background-color: rgb(204, 204, 204);"
/>
<span
class="label"
id="_r_1_"
>
Pill
</span>
</div>
</div>
`;
@@ -0,0 +1,8 @@
/*
* Copyright 2025 New Vector 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 { Pill } from "./Pill";
@@ -0,0 +1,34 @@
/*
* Copyright 2025 New Vector 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.
*/
.pillInput {
background-color: var(--cpd-color-bg-subtle-secondary);
border-radius: 20px;
padding: var(--cpd-space-2x) var(--cpd-space-3x) var(--cpd-space-2x) var(--cpd-space-3x);
/* To match pill height in order to avoid the PillInput to grow when a pill is inserted */
min-height: 28px;
}
.pillInput:has(.input:focus) {
outline: var(--cpd-border-width-1) solid var(--cpd-color-gray-1400);
}
.input {
all: unset;
width: 100%;
flex: 1;
color: var(--cpd-color-text-primary);
}
.input::placeholder {
color: var(--cpd-color-text-secondary);
font: var(--cpd-font-body-md-regular);
}
.largerInput {
padding: var(--cpd-space-2x) 0;
}
@@ -0,0 +1,38 @@
/*
* Copyright 2025 New Vector 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 { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PillInput } from "./PillInput";
const meta = {
title: "PillInput/PillInput",
component: PillInput,
tags: ["autodocs"],
args: {
children: (
<>
<div style={{ minWidth: 162, height: 28, backgroundColor: "#ccc", borderRadius: "99px" }} />
<div style={{ minWidth: 162, height: 28, backgroundColor: "#ccc", borderRadius: "99px" }} />
</>
),
onChange: fn(),
onRemoveChildren: fn(),
inputProps: {
"placeholder": "Type something...",
"aria-label": "pill input",
},
},
} satisfies Meta<typeof PillInput>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const NoChild: Story = { args: { children: undefined } };
@@ -0,0 +1,44 @@
/*
* Copyright 2025 New Vector 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 React from "react";
import { composeStories } from "@storybook/react-vite";
import userEvent from "@testing-library/user-event";
import { describe, it, vi, expect } from "vitest";
import * as stories from "./PillInput.stories";
import { PillInput } from "./PillInput";
const { Default, NoChild } = composeStories(stories);
describe("PillInput", () => {
it("renders the pill input", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders only the input without children", () => {
const { container } = render(<NoChild />);
expect(container).toMatchSnapshot();
});
it("calls onRemoveChildren when backspace is pressed and input is empty", async () => {
const user = userEvent.setup();
const mockOnRemoveChildren = vi.fn();
render(<PillInput onRemoveChildren={mockOnRemoveChildren} />);
const input = screen.getByRole("textbox");
// Focus the input and press backspace (input should be empty by default)
await user.click(input);
await user.keyboard("{Backspace}");
expect(mockOnRemoveChildren).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,96 @@
/*
* Copyright 2025 New Vector 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 PropsWithChildren,
type JSX,
useRef,
type KeyboardEventHandler,
type HTMLAttributes,
type HTMLProps,
Children,
} from "react";
import classNames from "classnames";
import { omit } from "lodash";
import { useMergeRefs } from "react-merge-refs";
import styles from "./PillInput.module.css";
import { Flex } from "../../utils/Flex";
export interface PillInputProps extends HTMLAttributes<HTMLDivElement> {
/**
* Callback for when the user presses backspace on an empty input.
*/
onRemoveChildren?: KeyboardEventHandler;
/**
* Props to pass to the input element.
*/
inputProps?: HTMLProps<HTMLInputElement> & { "data-testid"?: string };
}
/**
* An input component that can contain multiple child elements and an input field.
*
* @example
* ```tsx
* <PillInput>
* <div>Child 1</div>
* <div>Child 2</div>
* </PillInput>
* ```
*/
export function PillInput({
className,
children,
onRemoveChildren,
inputProps,
...props
}: PropsWithChildren<PillInputProps>): JSX.Element {
const inputRef = useRef<HTMLInputElement>(null);
const inputAttributes = omit(inputProps, ["onKeyDown", "ref"]);
const ref = useMergeRefs([inputRef, inputProps?.ref]);
const hasChildren = Children.toArray(children).length > 0;
return (
<Flex
{...props}
gap="var(--cpd-space-1x)"
direction="column"
className={classNames(styles.pillInput, className)}
onClick={(evt) => {
evt.preventDefault();
evt.stopPropagation();
inputRef.current?.focus();
}}
>
{hasChildren && (
<Flex gap="var(--cpd-space-1x)" wrap="wrap" align="center">
{children}
</Flex>
)}
<input
ref={ref}
autoComplete="off"
className={classNames(styles.input, { [styles.largerInput]: hasChildren })}
onKeyDown={(evt) => {
const value = evt.currentTarget.value.trim();
// If the input is empty and the user presses backspace, we call the onRemoveChildren handler
if (evt.key === "Backspace" && !value) {
evt.preventDefault();
onRemoveChildren?.(evt);
return;
}
inputProps?.onKeyDown?.(evt);
}}
{...inputAttributes}
/>
</Flex>
);
}
@@ -0,0 +1,44 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`PillInput > renders only the input without children 1`] = `
<div>
<div
class="flex pillInput"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<input
aria-label="pill input"
autocomplete="off"
class="input"
placeholder="Type something..."
/>
</div>
</div>
`;
exports[`PillInput > renders the pill input 1`] = `
<div>
<div
class="flex pillInput"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<div
class="flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: wrap;"
>
<div
style="min-width: 162px; height: 28px; background-color: rgb(204, 204, 204); border-radius: 99px;"
/>
<div
style="min-width: 162px; height: 28px; background-color: rgb(204, 204, 204); border-radius: 99px;"
/>
</div>
<input
aria-label="pill input"
autocomplete="off"
class="input largerInput"
placeholder="Type something..."
/>
</div>
</div>
`;
@@ -0,0 +1,8 @@
/*
* Copyright 2025 New Vector 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 { PillInput } from "./PillInput";
@@ -0,0 +1,76 @@
/*
* Copyright 2025 New Vector 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.
*/
.richItem {
/* Remove browser button style */
background: transparent;
border: none;
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-4x);
width: 100%;
box-sizing: border-box;
cursor: pointer;
text-align: start;
display: grid;
column-gap: var(--cpd-space-3x);
grid-template-columns: max-content 1fr max-content;
grid-template-areas:
"avatar title time"
"avatar description time";
}
.richItem:hover,
.richItem:focus {
background-color: var(--cpd-color-bg-subtle-secondary);
border-radius: 12px;
}
.richItem:not(:last-child) {
border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-gray-300);
}
.avatar {
grid-area: avatar;
align-self: center;
}
.title {
grid-area: title;
font: var(--cpd-font-body-sm-semibold);
color: var(--cpd-color-text-primary);
}
.description {
grid-area: description;
}
.timestamp {
grid-area: time;
align-self: center;
}
.title,
.description {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.description,
.timestamp {
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
}
.checkmark {
grid-area: avatar;
align-self: center;
background-color: var(--cpd-color-icon-accent-primary);
width: 32px;
height: 32px;
border-radius: 100%;
}
@@ -0,0 +1,70 @@
/*
* Copyright 2025 New Vector 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 { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RichItem } from "./RichItem";
const currentTimestamp = new Date("2025-03-09T12:00:00Z").getTime();
const meta = {
title: "RichList/RichItem",
component: RichItem,
tags: ["autodocs"],
args: {
avatar: <div style={{ width: 32, height: 32, backgroundColor: "#ccc", borderRadius: "50%" }} />,
title: "Rich Item Title",
description: "This is a description of the rich item.",
timestamp: currentTimestamp,
onClick: fn(),
},
beforeEach: () => {
Date.now = () => new Date("2025-08-01T12:00:00Z").getTime();
},
parameters: {
a11y: {
context: "button",
},
},
render: (args) => (
<ul role="listbox" style={{ all: "unset", listStyle: "none" }}>
<RichItem {...args} />
</ul>
),
} satisfies Meta<typeof RichItem>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Selected: Story = {
args: {
selected: true,
},
};
export const WithoutTimestamp: Story = {
args: {
timestamp: undefined,
},
};
export const Hover: Story = {
parameters: { pseudo: { hover: true } },
};
export const Separator: Story = {
render: (args) => (
<ul role="listbox" style={{ all: "unset", listStyle: "none" }}>
<RichItem {...args} />
<RichItem {...args} />
</ul>
),
};
@@ -0,0 +1,36 @@
/*
* Copyright 2025 New Vector 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 { composeStories } from "@storybook/react-vite";
import { render } from "@test-utils";
import React from "react";
import { describe, it, vi, beforeAll, expect } from "vitest";
import * as stories from "./RichItem.stories";
const { Default, Selected, WithoutTimestamp } = composeStories(stories);
describe("RichItem", () => {
beforeAll(() => {
vi.useFakeTimers().setSystemTime(new Date("2025-08-01T12:00:00Z"));
});
it("renders the item in default state", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders the item in selected state", () => {
const { container } = render(<Selected />);
expect(container).toMatchSnapshot();
});
it("renders the item without timestamp", () => {
const { container } = render(<WithoutTimestamp />);
expect(container).toMatchSnapshot();
});
});
@@ -0,0 +1,98 @@
/*
* Copyright 2025 New Vector 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 HTMLAttributes, type JSX, memo } from "react";
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
import styles from "./RichItem.module.css";
import { Flex } from "../../utils/Flex";
import { useI18n } from "../../i18n/i18nContext";
export interface RichItemProps extends HTMLAttributes<HTMLLIElement> {
/**
* Avatar to display at the start of the item
*/
avatar: React.ReactNode;
/**
* Title to display at the top of the item
*/
title: string;
/**
* Description to display below the title
*/
description: string;
/**
* Timestamp to display at the end of the item
* The value is humanized (e.g. "5 minutes ago")
*/
timestamp?: number;
/**
* Whether the item is selected
* This will replace the avatar with a checkmark
* @default false
*/
selected?: boolean;
}
/**
* A rich item to display in a list, with an avatar, title, description and optional timestamp.
* If selected, the avatar is replaced with a checkmark.
* A separator is added between items in a list.
*
* @example
* ```tsx
* <RichItem
* avatar={<AvatarComponent />}
* title="Rich Item Title"
* description="This is a description of the rich item."
* timestamp={Date.now() - 5 * 60 * 1000} // 5 minutes ago
* selected={true}
* onClick={() => console.log("Item clicked")}
* />
* ```
*/
export const RichItem = memo(function RichItem({
avatar,
title,
description,
timestamp,
selected,
...props
}: RichItemProps): JSX.Element {
const i18n = useI18n();
return (
<li
className={styles.richItem}
role="option"
tabIndex={-1}
aria-selected={selected}
aria-label={title}
{...props}
>
{selected ? <Checkmark /> : <Flex className={styles.avatar}>{avatar}</Flex>}
<span className={styles.title}>{title}</span>
<span className={styles.description}>{description}</span>
{timestamp && (
<span role="timer" className={styles.timestamp}>
{i18n.humanizeTime(timestamp)}
</span>
)}
</li>
);
});
/**
* A checkmark icon inside a circle, used to indicate selection.
*/
function Checkmark(): JSX.Element {
return (
<Flex align="center" justify="center" aria-hidden="true" className={styles.checkmark}>
<CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-on-solid-primary)" />
</Flex>
);
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
/*
* Copyright 2025 New Vector 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 { RichItem } from "./RichItem";
@@ -0,0 +1,30 @@
/*
* Copyright 2025 New Vector 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.
*/
.richList {
height: inherit;
}
.title {
font: var(--cpd-font-body-sm-semibold);
color: var(--cpd-color-text-secondary);
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-4x);
}
.content {
width: 100%;
overflow: auto;
/* remove browser default ul padding/margin */
padding: 0;
margin: 0;
}
.empty {
margin-left: var(--cpd-space-6x);
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
}
@@ -0,0 +1,50 @@
/*
* Copyright 2025 New Vector 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 { RichList } from "./RichList";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RichItem } from "../RichItem";
const avatar = <div style={{ width: 32, height: 32, backgroundColor: "#ccc", borderRadius: "50%" }} />;
const meta = {
title: "RichList/RichList",
component: RichList,
tags: ["autodocs"],
decorators: [
(Story) => (
<div style={{ height: "220px", overflow: "hidden" }}>
<Story />
</div>
),
],
args: {
title: "Rich List Title",
children: (
<>
<RichItem avatar={avatar} title="First Item" description="description" />
<RichItem selected={true} avatar={avatar} title="Second Item" description="description" />
<RichItem avatar={avatar} title="Third Item" description="description" />
<RichItem avatar={avatar} title="Fourth Item" description="description" />
<RichItem avatar={avatar} title="Fifth Item" description="description" />
</>
),
},
} satisfies Meta<typeof RichList>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Empty: Story = {
args: {
isEmpty: true,
children: "No items available",
},
};
@@ -0,0 +1,27 @@
/*
* Copyright 2025 New Vector 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 { composeStories } from "@storybook/react-vite";
import { render } from "@test-utils";
import React from "react";
import { describe, it, expect } from "vitest";
import * as stories from "./RichList.stories";
const { Default, Empty } = composeStories(stories);
describe("RichItem", () => {
it("renders the list", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders the list with isEmpty=true", () => {
const { container } = render(<Empty />);
expect(container).toMatchSnapshot();
});
});
@@ -0,0 +1,80 @@
/*
* Copyright 2025 New Vector 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 HTMLProps, type JSX, type PropsWithChildren, useId } from "react";
import classNames from "classnames";
import styles from "./RichList.module.css";
import { Flex } from "../../utils/Flex";
import { useListKeyboardNavigation } from "../../hooks/useListKeyboardNavigation";
export interface RichListProps extends HTMLProps<HTMLDivElement> {
/**
* Title to display at the top of the list
*/
title: string;
/**
* Attributes to pass to the title element
* This can be used to set accessibility attributes like `aria-level` or `role`
* @example
* ```tsx
* <RichList title="My List" titleAttributes={{ role: "heading", "aria-level": 2 }}>
* ```
*/
titleAttributes?: HTMLProps<HTMLSpanElement>;
/**
* Indicates if the list should show an empty state.
* The list renders its children in a span instead of an ul.
*/
isEmpty?: boolean;
}
/**
* A list component with a title and children.
*
* @example
* ```tsx
* <RichList title="My List">
* <RichItem ... />
* <RichItem ... />
* </RichList>
* ```
*/
export function RichList({
children,
title,
className,
titleAttributes,
isEmpty = false,
...props
}: PropsWithChildren<RichListProps>): JSX.Element {
const id = useId();
const { listRef, onKeyDown, onFocus } = useListKeyboardNavigation();
return (
<Flex className={classNames(styles.richList, className)} direction="column" {...props}>
<span id={id} className={styles.title} {...titleAttributes}>
{title}
</span>
{isEmpty ? (
<span className={styles.empty}>{children}</span>
) : (
<ul
ref={listRef}
role="listbox"
className={styles.content}
aria-labelledby={id}
tabIndex={0}
onKeyDown={onKeyDown}
onFocus={onFocus}
>
{children}
</ul>
)}
</Flex>
);
}
@@ -0,0 +1,189 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`RichItem > renders the list 1`] = `
<div>
<div
style="height: 220px; overflow: hidden;"
>
<div
class="flex richList"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="title"
id="_r_0_"
>
Rich List Title
</span>
<ul
aria-labelledby="_r_0_"
class="content"
role="listbox"
tabindex="0"
>
<li
aria-label="First Item"
class="richItem"
role="option"
tabindex="-1"
>
<div
class="flex avatar"
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;"
>
<div
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
/>
</div>
<span
class="title"
>
First Item
</span>
<span
class="description"
>
description
</span>
</li>
<li
aria-label="Second Item"
aria-selected="true"
class="richItem"
role="option"
tabindex="-1"
>
<div
aria-hidden="true"
class="flex checkmark"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<svg
color="var(--cpd-color-icon-on-solid-primary)"
fill="currentColor"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
<span
class="title"
>
Second Item
</span>
<span
class="description"
>
description
</span>
</li>
<li
aria-label="Third Item"
class="richItem"
role="option"
tabindex="-1"
>
<div
class="flex avatar"
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;"
>
<div
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
/>
</div>
<span
class="title"
>
Third Item
</span>
<span
class="description"
>
description
</span>
</li>
<li
aria-label="Fourth Item"
class="richItem"
role="option"
tabindex="-1"
>
<div
class="flex avatar"
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;"
>
<div
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
/>
</div>
<span
class="title"
>
Fourth Item
</span>
<span
class="description"
>
description
</span>
</li>
<li
aria-label="Fifth Item"
class="richItem"
role="option"
tabindex="-1"
>
<div
class="flex avatar"
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;"
>
<div
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
/>
</div>
<span
class="title"
>
Fifth Item
</span>
<span
class="description"
>
description
</span>
</li>
</ul>
</div>
</div>
</div>
`;
exports[`RichItem > renders the list with isEmpty=true 1`] = `
<div>
<div
style="height: 220px; overflow: hidden;"
>
<div
class="flex richList"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="title"
id="_r_1_"
>
Rich List Title
</span>
<span
class="empty"
>
No items available
</span>
</div>
</div>
</div>
`;
@@ -0,0 +1,8 @@
/*
* Copyright 2025 New Vector 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 { RichList } from "./RichList";
@@ -0,0 +1,19 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
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.
*/
.box-flex {
flex: var(--mx-box-flex, unset);
}
.box-shrink {
flex-shrink: var(--mx-box-shrink, unset);
}
.box-grow {
flex-grow: var(--mx-box-grow, unset);
}
@@ -0,0 +1,78 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
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, useMemo } from "react";
import styles from "./Box.module.css";
type BoxProps = {
/**
* The type of the HTML element
* @default div
*/
as?: string;
/**
* The CSS class name.
*/
className?: string;
/**
* the on click event callback
*/
onClick?: (e: React.MouseEvent) => void;
/**
* The flex space to use
* @default null
*/
flex?: string | null;
/**
* The flex shrink factor
* @default null
*/
shrink?: string | null;
/**
* The flex grow factor
* @default null
*/
grow?: string | null;
};
/**
* A flex child helper
*/
export function Box({
as = "div",
flex = null,
shrink = null,
grow = null,
className,
children,
...props
}: React.PropsWithChildren<BoxProps>): JSX.Element {
const style = useMemo(() => {
const style: Record<string, string> = {};
if (flex) style["--mx-box-flex"] = flex;
if (shrink) style["--mx-box-shrink"] = shrink;
if (grow) style["--mx-box-grow"] = grow;
return style;
}, [flex, grow, shrink]);
return React.createElement(
as,
{
...props,
className: classNames(className, {
[styles["box-flex"]]: !!flex,
[styles["box-shrink"]]: !!shrink,
[styles["box-grow"]]: !!grow,
}),
style,
},
children,
);
}
@@ -0,0 +1,8 @@
/*
* Copyright 2025 New Vector 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 { Box } from "./Box";
@@ -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);
},
);
});
@@ -0,0 +1,49 @@
/*
* Copyright 2025 New Vector 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.
*/
/**
* Formats a number of seconds into a human-readable string.
* @param inSeconds
*/
export function formatSeconds(inSeconds: number): string {
const isNegative = inSeconds < 0;
inSeconds = Math.abs(inSeconds);
const hours = Math.floor(inSeconds / (60 * 60))
.toFixed(0)
.padStart(2, "0");
const minutes = Math.floor((inSeconds % (60 * 60)) / 60)
.toFixed(0)
.padStart(2, "0");
const seconds = Math.floor((inSeconds % (60 * 60)) % 60)
.toFixed(0)
.padStart(2, "0");
let output = "";
if (hours !== "00") output += `${hours}:`;
output += `${minutes}:${seconds}`;
if (isNegative) {
output = "-" + output;
}
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}`;
}
@@ -0,0 +1,16 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
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.
*/
.flex {
display: var(--mx-flex-display, unset);
flex-direction: var(--mx-flex-direction, unset);
align-items: var(--mx-flex-align, unset);
justify-content: var(--mx-flex-justify, unset);
gap: var(--mx-flex-gap, unset);
flex-wrap: var(--mx-flex-wrap, unset);
}
@@ -0,0 +1,90 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
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, type ComponentProps, type JSXElementConstructor, useMemo } from "react";
import styles from "./Flex.module.css";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type FlexProps<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>> = {
/**
* The type of the HTML element
* @default div
*/
as?: T;
/**
* The CSS class name.
*/
className?: string;
/**
* The type of flex container
* @default flex
*/
display?: "flex" | "inline-flex";
/**
* The flow direction of the flex children
* @default row
*/
direction?: "row" | "column" | "row-reverse" | "column-reverse";
/**
* The alignment of the flex children
* @default start
*/
align?: "start" | "center" | "end" | "baseline" | "stretch" | "normal";
/**
* The justification of the flex children
* @default start
*/
justify?: "start" | "center" | "end" | "space-between";
/**
* The wrapping of the flex children
* @default nowrap
*/
wrap?: "wrap" | "nowrap" | "wrap-reverse";
/**
* The spacing between the flex children, expressed with the CSS unit
* @default 0
*/
gap?: string;
/**
* the on click event callback
*/
onClick?: (e: React.MouseEvent) => void;
} & ComponentProps<T>;
/**
* A flexbox container helper
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function Flex<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any> = "div">({
as = "div",
display = "flex",
direction = "row",
align = "start",
justify = "start",
gap = "0",
wrap = "nowrap",
className,
children,
...props
}: React.PropsWithChildren<FlexProps<T>>): JSX.Element {
const style = useMemo(
() => ({
"--mx-flex-display": display,
"--mx-flex-direction": direction,
"--mx-flex-align": align,
"--mx-flex-justify": justify,
"--mx-flex-gap": gap,
"--mx-flex-wrap": wrap,
}),
[align, direction, display, gap, justify, wrap],
);
return React.createElement(as, { ...props, className: classNames(styles.flex, className), style }, children);
}
@@ -0,0 +1,8 @@
/*
* Copyright 2025 New Vector 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 { Flex } from "./Flex";
@@ -0,0 +1,43 @@
/*
* 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 { Markdown } from "@storybook/addon-docs/blocks";
import type { Meta } from "@storybook/react-vite";
import formatBytesDoc from "../../../typedoc/functions/formatBytes.md?raw";
import formatSecondsDoc from "../../../typedoc/functions/formatSeconds.md?raw";
const meta = {
title: "utils/FormattingUtils",
parameters: {
docs: {
page: () => (
<>
<h1>Formatting Utilities</h1>
<p>A collection of utility functions for formatting data into human-readable strings.</p>
<hr />
<h2>formatBytes</h2>
<Markdown>{formatBytesDoc}</Markdown>
<hr />
<h2>formatSeconds</h2>
<Markdown>{formatSecondsDoc}</Markdown>
</>
),
},
},
tags: ["autodocs", "skip-test"],
} satisfies Meta;
export default meta;
// Docs-only story - renders nothing but triggers autodocs
export const Docs = {
render: () => null,
};
@@ -0,0 +1,22 @@
/*
* Copyright 2025 New Vector 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.
*/
/**
* format a size in bytes into a human readable form
* e.g: 1024 -> 1.00 KB
*/
export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
}
@@ -0,0 +1,13 @@
/*
* 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.
*/
.container {
a {
color: var(--cpd-color-text-link-external);
}
margin: 0;
}
@@ -0,0 +1,71 @@
/*
* 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 ComponentProps } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LinkedText } from "./LinkedText";
import { LinkedTextContext } from "./LinkedTextContext";
const meta = {
title: "Utils/LinkedText",
component: LinkedText,
decorators: [
(Story, { args }) => (
<LinkedTextContext.Provider
value={{
userIdListener: args.userIdListener,
roomAliasListener: args.roomAliasListener,
urlTargetTransformer: args.urlTargetTransformer,
hrefTransformer: args.hrefTransformer,
}}
>
<Story />
</LinkedTextContext.Provider>
),
],
args: {
children: "I love working on https://matrix.org.",
},
tags: ["autodocs"],
} satisfies Meta<ComponentProps<typeof LinkedText> & ComponentProps<typeof LinkedTextContext>["value"]>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithUserId: Story = {
args: {
children: "I love talking to @alice:example.org.",
userIdListener: fn(),
},
};
export const WithRoomAlias: Story = {
args: {
children: "I love talking in #general:example.org.",
roomAliasListener: fn(),
},
};
export const WithCustomUrlTarget: Story = {
args: {
urlTargetTransformer: () => "_fake_target",
},
tags: ["skip-test"],
};
export const WithCustomHref: Story = {
args: {
hrefTransformer: () => {
return "https://example.org";
},
},
tags: ["skip-test"],
};
@@ -0,0 +1,85 @@
/*
* 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 } from "@test-utils";
import { describe, it, expect, vitest } from "vitest";
import React from "react";
import { composeStories } from "@storybook/react-vite";
import userEvent from "@testing-library/user-event";
import * as stories from "./LinkedText.stories.tsx";
import { LinkedText } from "./LinkedText.tsx";
import { LinkifyOptionalSlashProtocols, PERMITTED_URL_SCHEMES } from "../linkify";
import { LinkedTextContext } from "./LinkedTextContext.tsx";
const { Default, WithUserId, WithRoomAlias, WithCustomHref, WithCustomUrlTarget } = composeStories(stories);
describe("LinkedText", () => {
it.each(
PERMITTED_URL_SCHEMES.filter((protocol) => !LinkifyOptionalSlashProtocols.includes(protocol)).map(
(protocol) => `${protocol}://abcdef/`,
),
)("renders protocol with no optional slash '%s'", (path) => {
const { getByRole } = render(
<LinkedTextContext value={{}}>
<LinkedText>Check out this link {path}</LinkedText>
</LinkedTextContext>,
);
expect(getByRole("link")).toBeInTheDocument();
});
it.each(LinkifyOptionalSlashProtocols.map((protocol) => `${protocol}://abcdef`))(
"renders protocol with optional slash '%s'",
(path) => {
const { getByRole } = render(
<LinkedTextContext value={{}}>
<LinkedText>Check out this link {path}</LinkedText>
</LinkedTextContext>,
);
expect(getByRole("link")).toBeInTheDocument();
},
);
it("renders a standard link", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders a user ID", () => {
const { container } = render(<WithUserId />);
expect(container).toMatchSnapshot();
});
it("renders a room alias", () => {
const { container } = render(<WithRoomAlias />);
expect(container).toMatchSnapshot();
});
it("renders a custom target", () => {
const { container } = render(<WithCustomUrlTarget />);
expect(container).toMatchSnapshot();
});
it("renders a custom href", () => {
const { container } = render(<WithCustomHref />);
expect(container).toMatchSnapshot();
});
it("supports setting an onLinkClicked handler", async () => {
const fn = vitest.fn();
const { getAllByRole } = render(
<LinkedTextContext value={{}}>
<LinkedText onLinkClick={fn}>Check out this link https://google.com and example.org</LinkedText>
</LinkedTextContext>,
);
const links = getAllByRole("link");
expect(links).toHaveLength(2);
await userEvent.click(links[0]);
await userEvent.click(links[1]);
expect(fn).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,52 @@
/*
* 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 { Link, Text } from "@vector-im/compound-web";
import React, { type ComponentProps } from "react";
import classNames from "classnames";
import Linkify from "linkify-react";
import styles from "./LinkedText.module.css";
import { generateLinkedTextOptions } from "../linkify";
import { useLinkedTextContext } from "./LinkedTextContext";
export type LinkedTextProps = ComponentProps<typeof Text> & {
/**
* Handler for when a link within the component is clicked. This will run
* *before* any LinkedTextContext handlers are run.
* @param ev The event raised by the click.
*/
onLinkClick?: (ev: MouseEvent) => void;
};
/**
* A component that renders URLs as clickable links inside some plain text.
*
* Requires a `<LinkedTextContext.Provider>`
*
* @example
* ```tsx
* <LinkedTextContext.Provider value={...}>
* <LinkedText>
* I love working on https://matrix.org
* </LinkedText>
* </LinkedTextContext.Provider>
* ```
*/
export function LinkedText({ children, className, onLinkClick, ...textProps }: LinkedTextProps): React.ReactNode {
const options = useLinkedTextContext();
const linkifyOptions = generateLinkedTextOptions({ ...options, onLinkClick });
return (
<Linkify
className={classNames(styles.container, className)}
as={Text}
options={{ ...linkifyOptions, render: Link }}
{...textProps}
>
{children}
</Linkify>
);
}
@@ -0,0 +1,50 @@
/*
* 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 { createContext, useContext } from "react";
import type { LinkEventListener, LinkifyMatrixOpaqueIdType } from "../linkify";
export interface LinkedTextConfiguration {
/**
* Event handlers for URL links.
*/
urlListener?: (href: string) => LinkEventListener;
/**
* Event handlers for room alias links.
*/
roomAliasListener?: (href: string) => LinkEventListener;
/**
* Event handlers for user ID links.
*/
userIdListener?: (href: string) => LinkEventListener;
/**
* Function that can be used to transform the `target` attribute on links, depending on the `href`.
*/
urlTargetTransformer?: (href: string) => string;
/**
* Function that can be used to transform the `href` attribute on links, depending on the current href and target type.
*/
hrefTransformer?: (href: string, target: LinkifyMatrixOpaqueIdType) => string;
}
export const LinkedTextContext = createContext<LinkedTextConfiguration | null>(null);
LinkedTextContext.displayName = "LinkedTextContext";
/**
* A hook to get the linked text configuration from the context. Will throw if no LinkedTextContext is found.
* @throws If no LinkedTextContext context is found
* @returns The linked text configuration from the context
*/
export function useLinkedTextContext(): LinkedTextConfiguration {
const config = useContext(LinkedTextContext);
if (!config) {
throw new Error("useLinkedTextContextOpts must be used within an LinkedTextContext.Provider");
}
return config;
}
@@ -0,0 +1,96 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LinkedText > renders a custom href 1`] = `
<div>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container"
>
I love working on
<a
data-linkified="true"
href="https://example.org"
rel="noreferrer noopener"
target="_blank"
>
https://matrix.org
</a>
.
</p>
</div>
`;
exports[`LinkedText > renders a custom target 1`] = `
<div>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container"
>
I love working on
<a
data-linkified="true"
href="https://matrix.org"
rel="noreferrer noopener"
target="_fake_target"
>
https://matrix.org
</a>
.
</p>
</div>
`;
exports[`LinkedText > renders a room alias 1`] = `
<div>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container"
>
I love talking in
<a
data-linkified="true"
href="#general:example.org"
rel="noreferrer noopener"
target="_blank"
>
#general:example.org
</a>
.
</p>
</div>
`;
exports[`LinkedText > renders a standard link 1`] = `
<div>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container"
>
I love working on
<a
data-linkified="true"
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
https://matrix.org
</a>
.
</p>
</div>
`;
exports[`LinkedText > renders a user ID 1`] = `
<div>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container"
>
I love talking to
<a
data-linkified="true"
href="@alice:example.org"
rel="noreferrer noopener"
target="_blank"
>
@alice:example.org
</a>
.
</p>
</div>
`;
@@ -0,0 +1,9 @@
/*
* 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 { LinkedText, type LinkedTextProps } from "./LinkedText";
export { LinkedTextContext, useLinkedTextContext } from "./LinkedTextContext";
@@ -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 React, { type JSX } from "react";
import { Text, type HTMLReactParserOptions } from "html-react-parser";
type Replacer = HTMLReactParserOptions["replace"];
/**
* Applies a parser replacer to string content while passing through JSX elements unchanged.
*
* @param input Plain-text body content or pre-rendered JSX elements (for example emoji bodies).
* Non-string items are returned verbatim.
* @param replacer Optional replace callback to run on string items.
* @returns The original `input` when no replacer is provided; otherwise an array where string
* items are replaced and JSX elements are passed through unchanged.
*/
export function applyReplacerOnString(
input: string | JSX.Element[],
replacer?: Replacer,
): JSX.Element | JSX.Element[] | string {
if (!replacer) return input;
const arr = Array.isArray(input) ? input : [input];
return arr.map((item, index): JSX.Element => {
if (typeof item === "string") {
return <React.Fragment key={index}>{(replacer(new Text(item), 0) as JSX.Element) || item}</React.Fragment>;
}
return item;
});
}
@@ -0,0 +1,34 @@
/*
* 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 { Markdown } from "@storybook/addon-docs/blocks";
import type { Meta } from "@storybook/react-vite";
import humanizeTimeDoc from "../../../typedoc/functions/humanizeTime.md?raw";
const meta = {
title: "utils/humanize",
parameters: {
docs: {
page: () => (
<>
<h1>humanize</h1>
<Markdown>{humanizeTimeDoc}</Markdown>
</>
),
},
},
tags: ["autodocs", "skip-test"],
} satisfies Meta;
export default meta;
// Docs-only story - renders nothing but triggers autodocs
export const Docs = {
render: () => null,
};
@@ -0,0 +1,39 @@
/*
* Copyright 2025 New Vector 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, beforeAll, vi, expect } from "vitest";
import { humanizeTime } from "./humanize";
describe("humanizeTime", () => {
const now = new Date("2025-08-01T12:00:00Z").getTime();
beforeAll(() => {
vi.useFakeTimers().setSystemTime(now);
});
it.each([
// Past
["returns 'a few seconds ago' for <15s ago", now - 5000, "a few seconds ago"],
["returns 'about a minute ago' for <75s ago", now - 60000, "about a minute ago"],
["returns '20 minutes ago' for <45min ago", now - 20 * 60000, "20 minutes ago"],
["returns 'about an hour ago' for <75min ago", now - 70 * 60000, "about an hour ago"],
["returns '5 hours ago' for <23h ago", now - 5 * 3600000, "5 hours ago"],
["returns 'about a day ago' for <26h ago", now - 25 * 3600000, "about a day ago"],
["returns '3 days ago' for >26h ago", now - 3 * 24 * 3600000, "3 days ago"],
// Future
["returns 'a few seconds from now' for <15s ahead", now + 5000, "a few seconds from now"],
["returns 'about a minute from now' for <75s ahead", now + 60000, "about a minute from now"],
["returns '20 minutes from now' for <45min ahead", now + 20 * 60000, "20 minutes from now"],
["returns 'about an hour from now' for <75min ahead", now + 70 * 60000, "about an hour from now"],
["returns '5 hours from now' for <23h ahead", now + 5 * 3600000, "5 hours from now"],
["returns 'about a day from now' for <26h ahead", now + 25 * 3600000, "about a day from now"],
["returns '3 days from now' for >26h ahead", now + 3 * 24 * 3600000, "3 days from now"],
])("%s", (_, date, expected) => {
expect(humanizeTime(date)).toBe(expected);
});
});
@@ -0,0 +1,59 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
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 { type I18nApi } from "@element-hq/element-web-module-api";
import { _t as _tFromModule } from "../i18n/i18n";
// These are the constants we use for when to break the text
const MILLISECONDS_RECENT = 15000;
const MILLISECONDS_1_MIN = 75000;
const MINUTES_UNDER_1_HOUR = 45;
const MINUTES_1_HOUR = 75;
const HOURS_UNDER_1_DAY = 23;
const HOURS_1_DAY = 26;
/**
* Converts a timestamp into human-readable, translated, text.
* @param {number} timeMillis The time in millis to compare against.
* @returns {string} The humanized time.
*/
export function humanizeTime(timeMillis: number, i18nApi?: I18nApi): string {
const now = Date.now();
let msAgo = now - timeMillis;
const minutes = Math.abs(Math.ceil(msAgo / 60000));
const hours = Math.ceil(minutes / 60);
const days = Math.ceil(hours / 24);
const _t = i18nApi?.translate ?? _tFromModule;
if (msAgo >= 0) {
// Past
if (msAgo <= MILLISECONDS_RECENT) return _t("time|few_seconds_ago");
if (msAgo <= MILLISECONDS_1_MIN) return _t("time|about_minute_ago");
if (minutes <= MINUTES_UNDER_1_HOUR) return _t("time|n_minutes_ago", { num: minutes });
if (minutes <= MINUTES_1_HOUR) return _t("time|about_hour_ago");
if (hours <= HOURS_UNDER_1_DAY) return _t("time|n_hours_ago", { num: hours });
if (hours <= HOURS_1_DAY) return _t("time|about_day_ago");
return _t("time|n_days_ago", { num: days });
} else {
// Future
msAgo = Math.abs(msAgo);
if (msAgo <= MILLISECONDS_RECENT) return _t("time|in_few_seconds");
if (msAgo <= MILLISECONDS_1_MIN) return _t("time|in_about_minute");
if (minutes <= MINUTES_UNDER_1_HOUR) return _t("time|in_n_minutes", { num: minutes });
if (minutes <= MINUTES_1_HOUR) return _t("time|in_about_hour");
if (hours <= HOURS_UNDER_1_DAY) return _t("time|in_n_hours", { num: hours });
if (hours <= HOURS_1_DAY) return _t("time|in_about_day");
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" });
}
@@ -0,0 +1,54 @@
/*
* 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 { Markdown } from "@storybook/addon-docs/blocks";
import type { Meta } from "@storybook/react-vite";
import LinkifyMatrixOpaqueIdType from "../../../typedoc/enumerations/LinkifyMatrixOpaqueIdType.md?raw";
import findLinksInString from "../../../typedoc/functions/findLinksInString.md?raw";
import isLinkable from "../../../typedoc/functions/isLinkable.md?raw";
import linkifyHtml from "../../../typedoc/functions/linkifyHtml.md?raw";
import linkifyString from "../../../typedoc/functions/linkifyString.md?raw";
import generateLinkedTextOptions from "../../../typedoc/functions/generateLinkedTextOptions.md?raw";
import LinkedTextOptions from "../../../typedoc/interfaces/LinkedTextOptions.md?raw";
const meta = {
title: "utils/linkify",
parameters: {
docs: {
page: () => (
<>
<h1>Linkify utilities</h1>
<p>Supporting functions and types for parsing links from HTML/strings.</p>
<h2>LinkifyMatrixOpaqueIdType</h2>
<Markdown>{LinkifyMatrixOpaqueIdType}</Markdown>
<h2>findLinksInString</h2>
<Markdown>{findLinksInString}</Markdown>
<h2>isLinkable</h2>
<Markdown>{isLinkable}</Markdown>
<h2>linkifyHtml</h2>
<Markdown>{linkifyHtml}</Markdown>
<h2>linkifyString</h2>
<Markdown>{linkifyString}</Markdown>
<h2>generateLinkedTextOptions</h2>
<Markdown>{generateLinkedTextOptions}</Markdown>
<h3>LinkedTextOptions</h3>
<Markdown>{LinkedTextOptions}</Markdown>
</>
),
},
},
tags: ["autodocs", "skip-test"],
} satisfies Meta;
export default meta;
// Docs-only story - renders nothing but triggers autodocs
export const Docs = {
render: () => null,
};
@@ -0,0 +1,411 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
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 { findLinksInString, isLinkable, linkifyHtml, LinkifyMatrixOpaqueIdType } from "./linkify";
describe("linkify-matrix", () => {
const linkTypesByInitialCharacter: Record<string, string> = {
"#": "roomalias",
"@": "userid",
};
describe.each(Object.entries(linkTypesByInitialCharacter))("handles '%s' (%s)", (char, type) => {
it("should not parse " + char + "foo without domain", () => {
const test = char + "foo";
const found = findLinksInString(test);
expect(isLinkable(test)).toEqual(false);
expect(found).toEqual([]);
});
describe("ip v4 tests", () => {
it("should properly parse IPs v4 as the domain name", () => {
const test = char + "potato:1.2.3.4";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "potato:1.2.3.4",
type,
isLink: true,
start: 0,
end: test.length,
value: char + "potato:1.2.3.4",
},
]);
});
it("should properly parse IPs v4 with port as the domain name with attached", () => {
const test = char + "potato:1.2.3.4:1337";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "potato:1.2.3.4:1337",
type,
isLink: true,
start: 0,
end: test.length,
value: char + "potato:1.2.3.4:1337",
},
]);
});
it("should properly parse IPs v4 as the domain name while ignoring missing port", () => {
const test = char + "potato:1.2.3.4:";
expect(isLinkable(test)).toEqual(false);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "potato:1.2.3.4",
type,
isLink: true,
start: 0,
end: test.length - 1,
value: char + "potato:1.2.3.4",
},
]);
});
});
// Currently those tests are failing, as there's missing implementation.
describe.skip("ip v6 tests", () => {
it("should properly parse IPs v6 as the domain name", () => {
const test = char + "username:[1234:5678::abcd]";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "username:[1234:5678::abcd]",
type,
isLink: true,
start: 0,
end: test.length,
value: char + "username:[1234:5678::abcd]",
},
]);
});
it("should properly parse IPs v6 with port as the domain name", () => {
const test = char + "username:[1234:5678::abcd]:1337";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "username:[1234:5678::abcd]:1337",
type,
isLink: true,
start: 0,
end: test.length,
value: char + "username:[1234:5678::abcd]:1337",
},
]);
});
// eslint-disable-next-line max-len
it("should properly parse IPs v6 while ignoring dangling comma when without port name as the domain name", () => {
const test = char + "username:[1234:5678::abcd]:";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "username:[1234:5678::abcd]:",
type,
isLink: true,
start: 0,
end: test.length - 1,
value: char + "username:[1234:5678::abcd]:",
},
]);
});
});
it("properly parses " + char + "_foonetic_xkcd:matrix.org", () => {
const test = "" + char + "_foonetic_xkcd:matrix.org";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "_foonetic_xkcd:matrix.org",
type,
value: char + "_foonetic_xkcd:matrix.org",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("properly parses " + char + "localhost:foo.com", () => {
const test = char + "localhost:foo.com";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "localhost:foo.com",
type,
value: char + "localhost:foo.com",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("properly parses " + char + "foo:localhost", () => {
const test = char + "foo:localhost";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "foo:localhost",
type,
value: char + "foo:localhost",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("accept " + char + "foo:bar.com", () => {
const test = "" + char + "foo:bar.com";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "foo:bar.com",
type,
value: char + "foo:bar.com",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("accept " + char + "foo:com (mostly for (TLD|DOMAIN)+ mixing)", () => {
const test = "" + char + "foo:com";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "foo:com",
type,
value: char + "foo:com",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("accept repeated TLDs (e.g .org.uk)", () => {
const test = "" + char + "foo:bar.org.uk";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "foo:bar.org.uk",
type,
value: char + "foo:bar.org.uk",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("accept hyphens in name " + char + "foo-bar:server.com", () => {
const test = "" + char + "foo-bar:server.com";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "foo-bar:server.com",
type,
value: char + "foo-bar:server.com",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("ignores trailing `:`", () => {
const test = "" + char + "foo:bar.com:";
expect(isLinkable(test)).toEqual(false);
const found = findLinksInString(test);
expect(found).toEqual([
{
type,
value: char + "foo:bar.com",
href: char + "foo:bar.com",
start: 0,
end: test.length - ":".length,
isLink: true,
},
]);
});
it("accept :NUM (port specifier)", () => {
const test = "" + char + "foo:bar.com:2225";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "foo:bar.com:2225",
type,
value: char + "foo:bar.com:2225",
start: 0,
end: test.length,
isLink: true,
},
]);
});
it("ignores duplicate :NUM (double port specifier)", () => {
const test = "" + char + "foo:bar.com:2225:1234";
expect(isLinkable(test)).toEqual(false);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "foo:bar.com:2225",
type,
value: char + "foo:bar.com:2225",
start: 0,
end: 17,
isLink: true,
},
]);
});
it("ignores all the trailing :", () => {
const test = "" + char + "foo:bar.com::::";
expect(isLinkable(test)).toEqual(false);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "foo:bar.com",
type,
value: char + "foo:bar.com",
end: test.length - 4,
start: 0,
isLink: true,
},
]);
});
it("properly parses room alias with dots in name", () => {
const test = "" + char + "foo.asdf:bar.com::::";
expect(isLinkable(test)).toEqual(false);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "foo.asdf:bar.com",
type,
value: char + "foo.asdf:bar.com",
start: 0,
end: test.length - ":".repeat(4).length,
isLink: true,
},
]);
});
it("does not parse room alias with too many separators", () => {
const test = "" + char + "foo:::bar.com";
expect(isLinkable(test)).toEqual(false);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: "http://bar.com",
type: "url",
value: "bar.com",
isLink: true,
start: 7,
end: test.length,
},
]);
});
it("properly parses room alias with hyphen in domain part", () => {
const test = "" + char + "foo:bar.com-baz.com";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: char + "foo:bar.com-baz.com",
type,
value: char + "foo:bar.com-baz.com",
end: 20,
start: 0,
isLink: true,
},
]);
});
});
describe("userid plugin", () => {
it("allows dots in localparts", () => {
const test = "@test.:matrix.org";
expect(isLinkable(test)).toEqual(true);
const found = findLinksInString(test);
expect(found).toEqual([
{
href: test,
type: "userid",
value: test,
start: 0,
end: test.length,
isLink: true,
},
]);
});
});
describe("matrix uri", () => {
const acceptedMatrixUris = [
"matrix:u/foo_bar:server.uk",
"matrix:r/foo-bar:server.uk",
"matrix:roomid/somewhere:example.org?via=elsewhere.ca",
"matrix:r/somewhere:example.org",
"matrix:r/somewhere:example.org/e/event",
"matrix:roomid/somewhere:example.org/e/event?via=elsewhere.ca",
"matrix:u/alice:example.org?action=chat",
];
for (const matrixUri of acceptedMatrixUris) {
it("accepts " + matrixUri, () => {
expect(isLinkable(matrixUri)).toEqual(true);
const found = findLinksInString(matrixUri);
expect(found).toEqual([
{
href: matrixUri,
type: LinkifyMatrixOpaqueIdType.URL,
value: matrixUri,
end: matrixUri.length,
start: 0,
isLink: true,
},
]);
});
}
});
describe("matrix-prefixed domains", () => {
const acceptedDomains = ["matrix.org", "matrix.to", "matrix-help.org", "matrix123.org"];
for (const domain of acceptedDomains) {
it("accepts " + domain, () => {
expect(isLinkable(domain)).toEqual(true);
const found = findLinksInString(domain);
expect(found).toEqual([
{
href: `http://${domain}`,
type: LinkifyMatrixOpaqueIdType.URL,
value: domain,
end: domain.length,
start: 0,
isLink: true,
},
]);
});
}
});
describe("linkifyHtml", () => {
it("removes any existing data-linkified", () => {
expect(
linkifyHtml("<span data-linkfied><a data-linkfied href='evil://com'>evil.com</a></span>"),
).toMatchInlineSnapshot(
`"<span data-linkfied=""><a data-linkfied="" href="evil://com">evil.com</a></span>"`,
);
});
});
});
@@ -0,0 +1,312 @@
/*
* 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 * as linkifyjs from "linkifyjs";
import { default as linkifyString } from "linkify-string"; // Only exported by this file, but imported for jsdoc.
import { default as linkifyHtml } from "linkify-html"; // Only exported by this file, but imported for jsdoc.
/**
* This file describes common linkify configuration settings such as supported protocols.
* The instance of "linkifyjs" is the canonical instance that all dependant apps should use.
*
* Plugins should be configured inside this file exclusively so as to avoid contamination of
* the global state.
*/
/**
* List of supported protocols natively by linkify. Kept in sync with upstreanm.
* @see https://github.com/nfrasser/linkifyjs/blob/main/packages/linkifyjs/src/scanner.mjs#L171-L177
*/
export const LinkifySupportedProtocols = ["file", "mailto", "http", "https", "ftp", "ftps"];
/**
* Protocols that do not require a slash in the URL.
*/
export const LinkifyOptionalSlashProtocols = [
"bitcoin",
"geo",
"im",
"magnet",
"mailto",
"matrix",
"news",
"openpgp4fpr",
"sip",
"sms",
"smsto",
"tel",
"urn",
"xmpp",
];
/**
* URL schemes that are safe to be resolved by the app consuming the library.
*/
export const PERMITTED_URL_SCHEMES = [...LinkifySupportedProtocols, ...LinkifyOptionalSlashProtocols];
export enum LinkifyMatrixOpaqueIdType {
URL = "url",
UserId = "userid",
RoomAlias = "roomalias",
}
/**
* Plugin function for linkifyjs to find Matrix Room or User IDs.
*
* Should be used exclusively by a `registerPlugin` function call.
*/
function parseOpaqueIdsToMatrixIds({
scanner,
parser,
token,
name,
}: {
scanner: linkifyjs.ScannerInit;
parser: linkifyjs.ParserInit;
token: "#" | "@";
name: LinkifyMatrixOpaqueIdType;
}): void {
const {
DOT,
// IPV4 necessity
NUM,
COLON,
SYM,
SLASH,
EQUALS,
HYPHEN,
UNDERSCORE,
} = scanner.tokens;
// Contains NUM, WORD, UWORD, EMOJI, TLD, UTLD, SCHEME, SLASH_SCHEME and LOCALHOST plus custom protocols (e.g. "matrix")
const { domain } = scanner.tokens.groups;
// Tokens we need that are not contained in the domain group
const additionalLocalpartTokens = [DOT, SYM, SLASH, EQUALS, UNDERSCORE, HYPHEN];
const additionalDomainpartTokens = [HYPHEN];
const matrixToken = linkifyjs.createTokenClass(name, { isLink: true });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const matrixTokenState = new linkifyjs.State(matrixToken) as any as linkifyjs.State<linkifyjs.MultiToken>; // linkify doesn't appear to type this correctly
const matrixTokenWithPort = linkifyjs.createTokenClass(name, { isLink: true });
const matrixTokenWithPortState = new linkifyjs.State(
matrixTokenWithPort,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any as linkifyjs.State<linkifyjs.MultiToken>; // linkify doesn't appear to type this correctly
const initialState = parser.start.tt(token);
// Localpart
const localpartState = new linkifyjs.State<linkifyjs.MultiToken>();
initialState.ta(domain, localpartState);
initialState.ta(additionalLocalpartTokens, localpartState);
localpartState.ta(domain, localpartState);
localpartState.ta(additionalLocalpartTokens, localpartState);
// Domainpart
const domainStateDot = localpartState.tt(COLON);
domainStateDot.ta(domain, matrixTokenState);
domainStateDot.ta(additionalDomainpartTokens, matrixTokenState);
matrixTokenState.ta(domain, matrixTokenState);
matrixTokenState.ta(additionalDomainpartTokens, matrixTokenState);
matrixTokenState.tt(DOT, domainStateDot);
// Port suffixes
matrixTokenState.tt(COLON).tt(NUM, matrixTokenWithPortState);
}
export type LinkEventListener = linkifyjs.EventListeners;
export interface LinkedTextOptions {
/**
* Event handlers for URL links.
*/
urlListener?: (href: string) => LinkEventListener;
/**
* Event handlers for room alias links.
*/
roomAliasListener?: (href: string) => LinkEventListener;
/**
* Event handlers for user ID links.
*/
userIdListener?: (href: string) => LinkEventListener;
/**
* Function that can be used to transform the `target` attribute on links, depending on the `href`.
*/
urlTargetTransformer?: (href: string) => string;
/**
* Function that can be used to transform the `href` attribute on links, depending on the current href and target type.
*/
hrefTransformer?: (href: string, target: LinkifyMatrixOpaqueIdType) => string;
/**
* Function called before all listeners when a link is clicked.
*/
onLinkClick?: (ev: MouseEvent) => void;
}
/**
* Generates a linkifyjs options object that is reasonably paired down
* to just the essentials required for an Element client.
*
* @return A `linkifyjs` `Opts` object. Used by `linkifyString` and `linkifyHtml
* @see {@link linkifyHtml}
* @see {@link linkifyString}
*/
export function generateLinkedTextOptions({
urlListener,
roomAliasListener,
userIdListener,
urlTargetTransformer,
hrefTransformer,
onLinkClick,
}: LinkedTextOptions): linkifyjs.Opts {
const events = (href: string, type: string): LinkEventListener => {
switch (type as LinkifyMatrixOpaqueIdType) {
case LinkifyMatrixOpaqueIdType.URL: {
if (urlListener) {
return urlListener(href);
}
break;
}
case LinkifyMatrixOpaqueIdType.UserId:
if (userIdListener) {
return userIdListener(href);
}
break;
case LinkifyMatrixOpaqueIdType.RoomAlias:
if (roomAliasListener) {
return roomAliasListener(href);
}
break;
}
return {};
};
const attributes = (href: string, type: string): Record<string, unknown> => {
const attrs: Record<string, unknown> = {
[`data-${LINKIFIED_DATA_ATTRIBUTE}`]: "true",
};
// linkify-react doesn't respect `events` and needs it mapping to React attributes
// so we need to manually add the click handler to the attributes
// https://linkify.js.org/docs/linkify-react.html#events
const options = events(href, type);
if (options?.click) {
attrs.onClick = options.click;
}
if (onLinkClick) {
attrs.onClick = (ev: MouseEvent) => {
onLinkClick(ev);
options?.click?.(ev);
};
}
return attrs;
};
return {
rel: "noreferrer noopener",
ignoreTags: ["a", "pre", "code"],
defaultProtocol: "https",
events,
attributes,
target(href, type) {
if (type === LinkifyMatrixOpaqueIdType.URL && urlTargetTransformer) {
return urlTargetTransformer(href);
}
return "_blank";
},
...(hrefTransformer
? {
formatHref: (href, type) => hrefTransformer(href, type as LinkifyMatrixOpaqueIdType),
}
: undefined),
// By default, ignore Matrix ID types.
// Other applications may implement their own version of LinkifyComponent.
validate: (_value, type: string) =>
!!(type === LinkifyMatrixOpaqueIdType.UserId && userIdListener) ||
!!(type === LinkifyMatrixOpaqueIdType.RoomAlias && roomAliasListener) ||
type === LinkifyMatrixOpaqueIdType.URL,
} satisfies linkifyjs.Opts;
}
/**
* Finds all links in a given string.
*
* @param str A string that may contain one or more strings.
* @returns A set of all links in the string.
*/
export function findLinksInString(str: string): ReturnType<typeof linkifyjs.find> {
return linkifyjs.find(str);
}
/**
* Is the provided value something that would be converted to a clickable
* link.
*
* E.g. 'https://matrix.org', `matrix.org` or 'example@matrix.org'
*
* @param str A string value to be tested if the entire value is linkable.
* @returns Whether or not the `str` value is a link.
* @see `PERMITTED_URL_SCHEMES` for permitted links.
* @see {@link linkifyjs.test}
*/
export function isLinkable(str: string): boolean {
return linkifyjs.test(str);
}
/**
* `data-linkified` is applied to all links generated by the linkifaction functions and `<LinkedText>`.
*/
export const LINKIFIED_DATA_ATTRIBUTE = "linkified";
export { linkifyString, linkifyHtml };
// Linkifyjs MUST be configured globally as it has no ability to be instanced seperately
// so we ensure it's always configured the same way.
let linkifyJSConfigured = false;
function configureLinkifyJS(): void {
if (linkifyJSConfigured) {
return;
}
// Register plugins
linkifyjs.registerPlugin(LinkifyMatrixOpaqueIdType.RoomAlias, ({ scanner, parser }) => {
const token = scanner.tokens.POUND as "#";
parseOpaqueIdsToMatrixIds({
scanner,
parser,
token,
name: LinkifyMatrixOpaqueIdType.RoomAlias,
});
});
linkifyjs.registerPlugin(LinkifyMatrixOpaqueIdType.UserId, ({ scanner, parser }) => {
const token = scanner.tokens.AT as "@";
parseOpaqueIdsToMatrixIds({
scanner,
parser,
token,
name: LinkifyMatrixOpaqueIdType.UserId,
});
});
// 'mxc' is specialcased. They can be linked to
linkifyjs.registerCustomProtocol("mxc", false);
// Linkify supports some common protocols but not others, register all permitted url schemes if unsupported
// https://github.com/nfrasser/linkifyjs/blob/main/packages/linkifyjs/src/scanner.mjs#L171-L177
// This also handles registering the `matrix:` protocol scheme
PERMITTED_URL_SCHEMES.forEach((scheme) => {
if (!LinkifySupportedProtocols.includes(scheme)) {
linkifyjs.registerCustomProtocol(scheme, LinkifyOptionalSlashProtocols.includes(scheme));
}
});
linkifyJSConfigured = true;
}
configureLinkifyJS();
@@ -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 { Markdown } from "@storybook/addon-docs/blocks";
import type { Meta } from "@storybook/react-vite";
import clampDoc from "../../../typedoc/functions/clamp.md?raw";
import defaultNumberDoc from "../../../typedoc/functions/defaultNumber.md?raw";
import percentageOfDoc from "../../../typedoc/functions/percentageOf.md?raw";
import percentageWithinDoc from "../../../typedoc/functions/percentageWithin.md?raw";
import sumDoc from "../../../typedoc/functions/sum.md?raw";
const meta = {
title: "utils/numbers",
parameters: {
docs: {
page: () => (
<>
<h1>Number Utilities</h1>
<p>
A collection of utility functions for working with numbers, including validation, clamping, and
percentage calculations.
</p>
<hr />
<h2>defaultNumber</h2>
<Markdown>{defaultNumberDoc}</Markdown>
<hr />
<h2>clamp</h2>
<Markdown>{clampDoc}</Markdown>
<hr />
<h2>sum</h2>
<Markdown>{sumDoc}</Markdown>
<hr />
<h2>percentageWithin</h2>
<Markdown>{percentageWithinDoc}</Markdown>
<hr />
<h2>percentageOf</h2>
<Markdown>{percentageOfDoc}</Markdown>
</>
),
},
},
tags: ["autodocs", "skip-test"],
} satisfies Meta;
export default meta;
// Docs-only story - renders nothing but triggers autodocs
export const Docs = {
render: () => null,
};
@@ -0,0 +1,162 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
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 { clamp, defaultNumber, percentageOf, percentageWithin, sum } from "./numbers";
describe("numbers", () => {
describe("defaultNumber", () => {
it("should use the default when the input is not a number", () => {
const def = 42;
let result = defaultNumber(null, def);
expect(result).toBe(def);
result = defaultNumber(undefined, def);
expect(result).toBe(def);
result = defaultNumber(Number.NaN, def);
expect(result).toBe(def);
});
it("should use the number when it is a number", () => {
const input = 24;
const def = 42;
const result = defaultNumber(input, def);
expect(result).toBe(input);
});
});
describe("clamp", () => {
it("should clamp high numbers", () => {
const input = 101;
const min = 0;
const max = 100;
const result = clamp(input, min, max);
expect(result).toBe(max);
});
it("should clamp low numbers", () => {
const input = -1;
const min = 0;
const max = 100;
const result = clamp(input, min, max);
expect(result).toBe(min);
});
it("should not clamp numbers in range", () => {
const input = 50;
const min = 0;
const max = 100;
const result = clamp(input, min, max);
expect(result).toBe(input);
});
it("should clamp floats", () => {
const min = -0.1;
const max = +0.1;
let result = clamp(-1.2, min, max);
expect(result).toBe(min);
result = clamp(1.2, min, max);
expect(result).toBe(max);
result = clamp(0.02, min, max);
expect(result).toBe(0.02);
});
});
describe("sum", () => {
it("should sum", () => {
// duh
const result = sum(1, 2, 1, 4);
expect(result).toBe(8);
});
});
describe("percentageWithin", () => {
it("should work within 0-100", () => {
const result = percentageWithin(0.4, 0, 100);
expect(result).toBe(40);
});
it("should work within 0-100 when pct > 1", () => {
const result = percentageWithin(1.4, 0, 100);
expect(result).toBe(140);
});
it("should work within 0-100 when pct < 0", () => {
const result = percentageWithin(-1.4, 0, 100);
expect(result).toBe(-140);
});
it("should work with ranges other than 0-100", () => {
const result = percentageWithin(0.4, 10, 20);
expect(result).toBe(14);
});
it("should work with ranges other than 0-100 when pct > 1", () => {
const result = percentageWithin(1.4, 10, 20);
expect(result).toBe(24);
});
it("should work with ranges other than 0-100 when pct < 0", () => {
const result = percentageWithin(-1.4, 10, 20);
expect(result).toBe(-4);
});
it("should work with floats", () => {
const result = percentageWithin(0.4, 10.2, 20.4);
expect(result).toBe(14.28);
});
});
// These are the inverse of percentageWithin
describe("percentageOf", () => {
it("should work within 0-100", () => {
const result = percentageOf(40, 0, 100);
expect(result).toBe(0.4);
});
it("should work within 0-100 when val > 100", () => {
const result = percentageOf(140, 0, 100);
expect(result).toBe(1.4);
});
it("should work within 0-100 when val < 0", () => {
const result = percentageOf(-140, 0, 100);
expect(result).toBe(-1.4);
});
it("should work with ranges other than 0-100", () => {
const result = percentageOf(14, 10, 20);
expect(result).toBe(0.4);
});
it("should work with ranges other than 0-100 when val > 100", () => {
const result = percentageOf(24, 10, 20);
expect(result).toBe(1.4);
});
it("should work with ranges other than 0-100 when val < 0", () => {
const result = percentageOf(-4, 10, 20);
expect(result).toBe(-1.4);
});
it("should work with floats", () => {
const result = percentageOf(14.28, 10.2, 20.4);
expect(result).toBe(0.4);
});
it("should return 0 for values that cause a division by zero", () => {
expect(percentageOf(0, 0, 0)).toBe(0);
});
});
});
@@ -0,0 +1,35 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
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.
*/
/**
* Returns the default number if the given value, i, is not a number. Otherwise
* returns the given value.
* @param {*} i The value to check.
* @param {number} def The default value.
* @returns {number} Either the value or the default value, whichever is a number.
*/
export function defaultNumber(i: unknown, def: number): number {
return Number.isFinite(i) ? Number(i) : def;
}
export function clamp(i: number, min: number, max: number): number {
return Math.min(Math.max(i, min), max);
}
export function sum(...i: number[]): number {
return [...i].reduce((p, c) => c + p, 0);
}
export function percentageWithin(pct: number, min: number, max: number): number {
return pct * (max - min) + min;
}
export function percentageOf(val: number, min: number, max: number): number {
const percentage = (val - min) / (max - min);
return Number.isNaN(percentage) ? 0 : percentage;
}
@@ -0,0 +1,51 @@
/*
Copyright 2025 New Vector 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 { type ViewModel } from "./ViewModel";
import { Disposables } from "./Disposables";
import { Snapshot } from "./Snapshot";
import { ViewModelSubscriptions } from "./ViewModelSubscriptions";
export abstract class BaseViewModel<T, P> implements ViewModel<T> {
protected subs: ViewModelSubscriptions;
protected snapshot: Snapshot<T>;
protected props: P;
protected disposables = new Disposables();
protected constructor(props: P, initialSnapshot: T) {
this.props = props;
this.subs = new ViewModelSubscriptions();
this.snapshot = new Snapshot(initialSnapshot, () => {
this.subs.emit();
});
}
public subscribe = (listener: () => void): (() => void) => {
return this.subs.add(listener);
};
/**
* Returns the current snapshot of the view model.
*/
public getSnapshot = (): T => {
return this.snapshot.current;
};
/**
* Relinquish any resources held by this view-model.
*/
public dispose(): void {
this.disposables.dispose();
}
/**
* Whether this view-model has been disposed.
*/
public get isDisposed(): boolean {
return this.disposables.isDisposed;
}
}
@@ -0,0 +1,70 @@
/*
Copyright 2025 New Vector 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 type { EventEmitter } from "events";
/**
* Something that needs to be eventually disposed. This can be:
* - A function that does the disposing
* - An object containing a dispose method which does the disposing
*/
export type DisposableItem = { dispose: () => void } | (() => void);
/**
* This class provides a way for the view-model to track any resource
* that it needs to eventually relinquish.
*/
export class Disposables {
private readonly disposables: DisposableItem[] = [];
private _isDisposed: boolean = false;
/**
* Relinquish all tracked disposable values
*/
public dispose(): void {
if (this.isDisposed) return;
this._isDisposed = true;
for (const disposable of this.disposables) {
if (typeof disposable === "function") {
disposable();
} else {
disposable.dispose();
}
}
}
/**
* Track a value that needs to be eventually relinquished
*/
public track<T extends DisposableItem>(disposable: T): T {
this.throwIfDisposed();
this.disposables.push(disposable);
return disposable;
}
/**
* Add an event listener that will be removed on dispose
*/
public trackListener(emitter: EventEmitter, event: string | symbol, callback: (...args: unknown[]) => void): void {
this.throwIfDisposed();
emitter.on(event, callback);
this.track(() => {
emitter.off(event, callback);
});
}
private throwIfDisposed(): void {
if (this.isDisposed) throw new Error("Disposable is already disposed");
}
/**
* Whether this disposable has been disposed
*/
public get isDisposed(): boolean {
return this._isDisposed;
}
}
@@ -0,0 +1,23 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type ViewModel } from "./ViewModel";
/**
* A mock view model that returns a static snapshot passed in the constructor, with no updates.
*/
export class MockViewModel<T> implements ViewModel<T> {
public constructor(private snapshot: T) {}
public getSnapshot = (): T => {
return this.snapshot;
};
public subscribe(listener: () => void): () => void {
return () => undefined;
}
}
@@ -0,0 +1,47 @@
/*
Copyright 2025 New Vector 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.
*/
/**
* This is the output of the viewmodel that the view consumes.
* Updating snapshot through this object will make react re-render
* components.
*/
export class Snapshot<T> {
public constructor(
private snapshot: T,
private emit: () => void,
) {}
/**
* Replace current snapshot with a new snapshot value.
* @param snapshot New snapshot value
*/
public set(snapshot: T): void {
this.snapshot = snapshot;
this.emit();
}
/**
* Update a part of the current snapshot by merging into the existing snapshot.
* Only emits if at least one of the merged fields has a different reference than the current value.
* @param snapshot A subset of the snapshot to merge into the current snapshot.
*/
public merge(snapshot: Partial<T>): void {
const keys = Object.keys(snapshot) as Array<keyof T>;
const hasChanged = keys.some((key) => !Object.is(snapshot[key], this.snapshot[key]));
if (!hasChanged) return;
this.snapshot = { ...this.snapshot, ...snapshot };
this.emit();
}
/**
* The current value of the snapshot.
*/
public get current(): T {
return this.snapshot;
}
}
@@ -0,0 +1,30 @@
/*
Copyright 2025 New Vector 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.
*/
/**
* The interface for a generic View Model passed to the shared components.
* The snapshot is of type T which is a type specifying a snapshot for the view in question.
*/
// Utility type to map all VM actions to unbound functions so that they do not have
// to be called with the correct 'this' context. This prevents "cannot read X of undefined" bugs.
type MapToVoidThis<T> = {
[K in keyof T]: T[K] extends (...args: infer A) => infer R ? (this: void, ...args: A) => R : T[K];
};
export type ViewModel<Snapshot, Actions = unknown> = {
/**
* The current snapshot of the view model.
*/
getSnapshot: () => Snapshot;
/**
* Subscribes to changes in the view model.
* The listener will be called whenever the snapshot changes.
*/
subscribe: (listener: () => void) => () => void;
} & MapToVoidThis<Actions>;
@@ -0,0 +1,34 @@
/*
Copyright 2025 New Vector 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.
*/
/**
* Utility class for view models to manage subscriptions to their updates
*/
export class ViewModelSubscriptions {
private listeners = new Set<() => void>();
/**
* Subscribe to changes in the view model.
* @param listener Will be called whenever the snapshot changes.
* @returns A function to unsubscribe from the view model updates.
*/
public add = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
/**
* Emit an update to all subscribed listeners.
*/
public emit = (): void => {
for (const listener of this.listeners) {
listener();
}
};
}
@@ -0,0 +1,16 @@
/*
* Copyright 2025 New Vector 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 * from "./BaseViewModel";
export * from "./Disposables";
export * from "./Snapshot";
export * from "./ViewModelSubscriptions";
export type * from "./ViewModel";
export * from "./MockViewModel";
export * from "./useCreateAutoDisposedViewModel";
export * from "./useMockedViewModel";
export * from "./useViewModel";
@@ -0,0 +1,58 @@
/*
Copyright 2025 New Vector 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 { EventEmitter } from "events";
import { describe, it, vi, expect } from "vitest";
import { Disposables } from "..";
describe("Disposable", () => {
it("isDisposed is true after dispose() is called", () => {
const disposables = new Disposables();
expect(disposables.isDisposed).toEqual(false);
disposables.dispose();
expect(disposables.isDisposed).toEqual(true);
});
it("dispose() calls the correct disposing function", () => {
const disposables = new Disposables();
const item1 = {
foo: 5,
dispose: vi.fn(),
};
disposables.track(item1);
const item2 = vi.fn();
disposables.track(item2);
disposables.dispose();
expect(item1.dispose).toHaveBeenCalledTimes(1);
expect(item2).toHaveBeenCalledTimes(1);
});
it("Throws error if acting on already disposed disposables", () => {
const disposables = new Disposables();
disposables.dispose();
expect(() => {
disposables.track(vi.fn);
}).toThrow();
});
it("Removes tracked event listeners on dispose", () => {
const disposables = new Disposables();
const emitter = new EventEmitter();
const fn = vi.fn();
disposables.trackListener(emitter, "FooEvent", fn);
emitter.emit("FooEvent");
expect(fn).toHaveBeenCalled();
disposables.dispose();
expect(emitter.listenerCount("FooEvent", fn)).toEqual(0);
});
});
@@ -0,0 +1,59 @@
/*
Copyright 2025 New Vector 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, vi, expect } from "vitest";
import { Snapshot } from "..";
interface TestSnapshot {
key1: string;
key2: number;
key3: boolean;
}
describe("Snapshot", () => {
it("should accept an initial value", () => {
const snapshot = new Snapshot<TestSnapshot>({ key1: "foo", key2: 5, key3: false }, vi.fn());
expect(snapshot.current).toStrictEqual({ key1: "foo", key2: 5, key3: false });
});
it("should call emit callback when state changes", () => {
const emit = vi.fn();
const snapshot = new Snapshot<TestSnapshot>({ key1: "foo", key2: 5, key3: false }, emit);
snapshot.merge({ key3: true });
expect(emit).toHaveBeenCalledTimes(1);
});
it("should swap out entire snapshot on set call", () => {
const snapshot = new Snapshot<TestSnapshot>({ key1: "foo", key2: 5, key3: false }, vi.fn());
const newValue = { key1: "bar", key2: 8, key3: true };
snapshot.set(newValue);
expect(snapshot.current).toStrictEqual(newValue);
});
it("should merge partial snapshot on merge call", () => {
const snapshot = new Snapshot<TestSnapshot>({ key1: "foo", key2: 5, key3: false }, vi.fn());
snapshot.merge({ key2: 10 });
expect(snapshot.current).toStrictEqual({ key1: "foo", key2: 10, key3: false });
});
it("should not emit when merged values are unchanged", () => {
const emit = vi.fn();
const snapshot = new Snapshot<TestSnapshot>({ key1: "foo", key2: 5, key3: false }, emit);
snapshot.merge({ key1: "foo", key2: 5 });
expect(emit).not.toHaveBeenCalled();
expect(snapshot.current).toStrictEqual({ key1: "foo", key2: 5, key3: false });
});
it("should emit when at least one merged value differs", () => {
const emit = vi.fn();
const snapshot = new Snapshot<TestSnapshot>({ key1: "foo", key2: 5, key3: false }, emit);
snapshot.merge({ key1: "foo", key2: 10 });
expect(emit).toHaveBeenCalledTimes(1);
expect(snapshot.current).toStrictEqual({ key1: "foo", key2: 10, key3: false });
});
});
@@ -0,0 +1,48 @@
/*
Copyright 2025 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 { renderHook } from "@test-utils";
import { describe, it, expect } from "vitest";
import { BaseViewModel } from "../BaseViewModel";
import { useCreateAutoDisposedViewModel } from "../useCreateAutoDisposedViewModel";
class TestViewModel extends BaseViewModel<{ count: number }, { initial: number }> {
public constructor(props: { initial: number }) {
super(props, { count: props.initial });
}
public increment(): void {
const newCount = this.getSnapshot().count + 1;
this.snapshot.set({ count: newCount });
}
}
describe("useAutoDisposedViewModel", () => {
it("should return view-model", () => {
const vmCreator = (): TestViewModel => new TestViewModel({ initial: 0 });
const { result } = renderHook(() => useCreateAutoDisposedViewModel(vmCreator));
const vm = result.current;
expect(vm).toBeInstanceOf(TestViewModel);
expect(vm.isDisposed).toStrictEqual(false);
});
it("should dispose view-model on unmount", () => {
const vmCreator = (): TestViewModel => new TestViewModel({ initial: 0 });
const { result, unmount } = renderHook(() => useCreateAutoDisposedViewModel(vmCreator));
const vm = result.current;
vm.increment();
unmount();
expect(vm.isDisposed).toStrictEqual(true);
});
it("should recreate view-model on react strict mode", async () => {
const vmCreator = (): TestViewModel => new TestViewModel({ initial: 0 });
const output = renderHook(() => useCreateAutoDisposedViewModel(vmCreator), { reactStrictMode: true });
const vm = output.result.current;
expect(vm.isDisposed).toStrictEqual(false);
});
});
@@ -0,0 +1,68 @@
/*
Copyright 2025 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 { useEffect, useState } from "react";
import type { BaseViewModel } from "./BaseViewModel";
type VmCreator<B extends BaseViewModel<unknown, unknown>> = () => B;
/**
* Instantiate a view-model that gets disposed when the calling react component unmounts.
* In other words, this hook ties the lifecycle of a view-model to the lifecycle of a
* react component.
*
* @param vmCreator A function that returns a view-model instance
* @returns view-model instance from vmCreator
* @example
* const vm = useCreateAutoDisposedViewModel(() => new FooViewModel({prop1, prop2, ...});
*/
export function useCreateAutoDisposedViewModel<B extends BaseViewModel<unknown, unknown>>(vmCreator: VmCreator<B>): B {
/**
* The view-model instance may be replaced by a different instance in some scenarios.
* We want to be sure that whichever react component called this hook gets re-rendered
* when this happens, hence the state.
*/
const [viewModel, setViewModel] = useState<B>(vmCreator);
/**
* Our intention here is to ensure that the dispose method of the view-model gets called
* when the component that uses this hook unmounts.
* We can do that by combining a useEffect cleanup with an empty dependency array.
*/
useEffect(() => {
let toDispose = viewModel;
/**
* Because we use react strict mode, react will run our effects twice in dev mode to make
* sure that they are pure.
* This presents a complication - the vm instance that we created in our state initializer
* will get disposed on the first cleanup.
* So we'll recreate the view-model if it's already disposed.
*/
if (viewModel.isDisposed) {
const newViewModel = vmCreator();
// Change toDispose so that we don't end up disposing the already disposed vm.
toDispose = newViewModel;
setViewModel(newViewModel);
}
return () => {
// Dispose the view-model when this component unmounts
toDispose.dispose();
};
/**
* We explicitly provide an empty dependency array as we don't expect the viewModel/viewCreator to
* change.
* Or to put it in another way, the only reason to use this hook is to create/dispose the view-model
* and that is something that should only happen at the start/end of the lifecycle of this component.
*/
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return viewModel;
}
@@ -0,0 +1,26 @@
/*
* Copyright 2025 New Vector 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 { useMemo } from "react";
import { MockViewModel } from "./MockViewModel";
import { type ViewModel } from "./ViewModel";
/**
* Hook helper to return a mocked view model created with the given snapshot and actions.
* This is useful for testing components in isolation with a mocked view model and allows to use primitive types in stories.
*
* @param snapshot
* @param actions
*/
export function useMockedViewModel<S, A>(snapshot: S, actions: A): ViewModel<S> & A {
return useMemo(() => {
const vm = new MockViewModel<S>(snapshot);
Object.assign(vm, actions);
return vm as unknown as ViewModel<S> & A;
}, [snapshot, actions]);
}
@@ -0,0 +1,21 @@
/*
Copyright 2025 New Vector 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 { useSyncExternalStore } from "react";
import { type ViewModel } from "./ViewModel";
/**
* A small wrapper around useSyncExternalStore to use a view model in a shared component view
* @param vm The view model to use
* @returns The current snapshot
*/
export function useViewModel<T>(vm: ViewModel<T, unknown>): T {
// We need to pass the same getSnapshot function as getServerSnapshot as this
// is used when making the HTML chat export.
return useSyncExternalStore(vm.subscribe, vm.getSnapshot, vm.getSnapshot);
}