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:
+113
@@ -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 = {};
|
||||
+58
@@ -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";
|
||||
+218
@@ -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 = {};
|
||||
+233
@@ -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,
|
||||
};
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
}
|
||||
+26
@@ -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 = {};
|
||||
+22
@@ -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>
|
||||
);
|
||||
}
|
||||
+28
@@ -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>
|
||||
);
|
||||
}
|
||||
+44
@@ -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>
|
||||
);
|
||||
}
|
||||
+129
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>
|
||||
);
|
||||
}
|
||||
+189
@@ -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;
|
||||
}
|
||||
+96
@@ -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 });
|
||||
});
|
||||
});
|
||||
+48
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user