From 8e7008e712c026bbf193e314ad84d7f824748954 Mon Sep 17 00:00:00 2001
From: woksin
Date: Mon, 28 Sep 2026 18:20:14 +0200
Subject: [PATCH 1/7] Add host-owned topic actions to chat lists
Expose topicActions on the standalone list and sidebar without changing action-free markup or message actions. Reuse the existing hover/focus action styling to keep packed CSS within its budget. Closes #206.
---
Documentation/Chat/index.md | 6 +-
Documentation/Chat/toc.yml | 2 +
Documentation/Chat/topic-actions.md | 41 ++++++
Documentation/Chat/topics-and-naming.md | 12 ++
Source/Chat/ChatSidebar.stories.tsx | 3 +-
Source/Chat/ChatSidebar.tsx | 6 +
Source/Chat/ChatTopicAction.ts | 30 ++++
Source/Chat/ChatTopicList.stories.tsx | 21 +++
Source/Chat/ChatTopicList.tsx | 133 +++++++++++-------
.../when_topic_actions_are_offered.ts | 42 ++++++
.../when_no_topic_actions_are_offered.ts | 25 ++++
.../when_topic_actions_are_offered.ts | 63 +++++++++
Source/Chat/index.ts | 1 +
Storybook/scripts/storybook-inventory.json | 1 +
14 files changed, 334 insertions(+), 52 deletions(-)
create mode 100644 Documentation/Chat/topic-actions.md
create mode 100644 Source/Chat/ChatTopicAction.ts
create mode 100644 Source/Chat/for_ChatSidebar/when_topic_actions_are_offered.ts
create mode 100644 Source/Chat/for_ChatTopicList/when_no_topic_actions_are_offered.ts
create mode 100644 Source/Chat/for_ChatTopicList/when_topic_actions_are_offered.ts
diff --git a/Documentation/Chat/index.md b/Documentation/Chat/index.md
index 91e1d614..a52c3898 100644
--- a/Documentation/Chat/index.md
+++ b/Documentation/Chat/index.md
@@ -20,7 +20,7 @@ Everything about _data_ stays with your application:
- `@`-mentions from a list you hold or a provider callback you resolve, rendered distinctly in message bodies
- Emoji picker in the composer, with a quick row of recently used emoji
- Host-supplied render callbacks for avatars and display names — messages carry only the author id
-- Extensible per-message actions shown on hover or keyboard focus
+- Extensible per-message and per-topic actions shown on hover or keyboard focus
- Every color from the `--cratis-*` token seam, so the chat follows your theme
## Components
@@ -97,7 +97,8 @@ export const Workspace = () => {
| `onRequestTopicName` / `isTopicUnnamed` | callbacks | — | The host-side naming contract. |
| `selectedTopicId` / `onTopicSelected` | `ChatIdentifier \| null`, callback | Internal | Owns or observes the open topic. |
| `authorOf`, `renderAvatar`, `renderAuthorName`, `buildAvatarUrl` | callbacks | — | Author resolution and rendering. Without `authorOf`, the id is shown as the name. |
-| `actions`, `quickReply` | `ChatMessageAction[]`, `boolean` | —, `true` | See [Message actions](./message-actions.md). |
+| `actions`, `quickReply` | `ChatMessageAction[]`, `boolean` | —, `true` | Message actions and quick reply; see [Message actions](./message-actions.md). |
+| `topicActions` | `ChatTopicAction[]` | — | Actions beside available topics; see [Topic actions](./topic-actions.md). |
| `mentionCandidates` / `resolveMentionCandidates` | array or callback | — | See [Mentions and emoji](./mentions-and-emoji.md). Omit both to turn mentions off. |
| `typingAuthors` | `ChatTypingAuthor[]` | `[]` | Who the conversation is waiting on. |
| `autoFocus` | `boolean` | `false` | Focuses the composer when a conversation mounts. |
@@ -218,4 +219,5 @@ Import `@cratis/components/styles` (or, with per-area stylesheets, `@cratis/comp
- [Topics and naming](./topics-and-naming.md) — the topic lifecycle and the host-side auto-naming contract
- [Mentions and emoji](./mentions-and-emoji.md) — candidate providers, how mentions travel and render
- [Message actions](./message-actions.md) — offering your own actions on messages
+- [Topic actions](./topic-actions.md) — offering your own actions on topics
- [Observable queries](./observable-queries.md) — the optional Arc-aware wrapper
diff --git a/Documentation/Chat/toc.yml b/Documentation/Chat/toc.yml
index 1950d7f5..d75d6d53 100644
--- a/Documentation/Chat/toc.yml
+++ b/Documentation/Chat/toc.yml
@@ -6,5 +6,7 @@
href: mentions-and-emoji.md
- name: Message actions
href: message-actions.md
+- name: Topic actions
+ href: topic-actions.md
- name: Observable queries
href: observable-queries.md
diff --git a/Documentation/Chat/topic-actions.md b/Documentation/Chat/topic-actions.md
new file mode 100644
index 00000000..c64ee66b
--- /dev/null
+++ b/Documentation/Chat/topic-actions.md
@@ -0,0 +1,41 @@
+---
+title: Topic actions
+description: Offer host-owned actions next to each topic without opening its conversation.
+---
+
+Pass `topicActions` to `ChatSidebar` or the standalone `ChatTopicList` when a person needs to rename, archive, or otherwise act on a topic without opening it. Unlike the sidebar's `actions` prop, which applies to **messages**, `topicActions` applies to topics only.
+
+## Descriptor
+
+| Field | Type | Required | Behavior |
+| --- | --- | --- | --- |
+| `id` | `string` | Yes | Unique rendering key among the actions. |
+| `label` | `string` | Yes | Tooltip and accessible name (combined with the topic's name). Shown as button text if no icon is supplied. |
+| `icon` | `string \| ReactNode` | No | An icon-font CSS class name or a ready element. |
+| `isAvailable` | `(topic: TTopic) => boolean` | No | Return `false` to omit the action for a topic; omitted means available on every topic. |
+| `onInvoke` | `(topic: TTopic) => void` | Yes | Receives the full host topic when the control is activated. |
+
+The buttons sit beside, not inside, the button that opens the conversation. They appear on hover or keyboard focus. Tab reaches each available control, and activating one does not open the topic.
+
+## Offer an action
+
+This excerpt assumes `sidebarProps` supplies `open`, `onClose`, `topics`, `messages`, and `onSendMessage` as in [basic usage](./index.md#basic-usage). The handlers belong to your application.
+
+```tsx
+import { ChatSidebar } from '@cratis/components/Chat';
+
+ Boolean(topic.name?.trim()),
+ onInvoke: topic => renameTopic(topic.id),
+ },
+ ]}
+/>
+```
+
+The same `topicActions` prop works on `ChatTopicList`. Omit it to leave the existing topic markup and opening behavior unchanged.
diff --git a/Documentation/Chat/topics-and-naming.md b/Documentation/Chat/topics-and-naming.md
index 1207003b..07909a10 100644
--- a/Documentation/Chat/topics-and-naming.md
+++ b/Documentation/Chat/topics-and-naming.md
@@ -22,6 +22,18 @@ interface ChatTopic {
The list orders topics by `lastActivity` (falling back to `started`) itself — hand it topics in any order.
+## ChatTopicList props
+
+| Prop | Type | Default | Behavior |
+| --- | --- | --- | --- |
+| `topics`, `onOpen` | `TTopic[]`, `(topic: TTopic) => void` | Required | Topics and the callback for opening one. |
+| `onStart` | `() => void` | — | Shows the new-topic button when supplied. |
+| `topicActions` | `ChatTopicAction[]` | — | Host actions beside topics where they are available. See [Topic actions](./topic-actions.md). |
+| `status` | `ChatStatus` | `Ready` | List query display state. |
+| `authorOf`, `renderAvatar`, `buildAvatarUrl` | callbacks | — | Resolve and render the topic starter. |
+| `isTopicUnnamed` | `(topic: TTopic) => boolean` | Blank name | Decides when to show the pending placeholder. |
+| `labels`, `className` | `ChatTopicListLabels`, `string` | — | Label overrides and root class name. |
+
## Starting a topic
`onStartTopic` raises the intent; creating the topic is the application's business. Answer with the new topic's id — directly or through a promise — and the sidebar opens it, ready for the first message:
diff --git a/Source/Chat/ChatSidebar.stories.tsx b/Source/Chat/ChatSidebar.stories.tsx
index b62de131..38110b8b 100644
--- a/Source/Chat/ChatSidebar.stories.tsx
+++ b/Source/Chat/ChatSidebar.stories.tsx
@@ -4,7 +4,7 @@
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from 'storybook/test';
import { useRef, useState } from 'react';
-import { FaCopy } from 'react-icons/fa6';
+import { FaCopy, FaPen } from 'react-icons/fa6';
import { ChatAuthorKind } from './Kit/ChatAuthorKind';
import type { ChatAuthor } from './ChatAuthor';
import type { ChatIdentifier } from './ChatIdentifier';
@@ -72,6 +72,7 @@ export const Playground: Story = {
onStartTopic: fn(),
onRequestTopicName: fn(),
onTopicSelected: fn(),
+ topicActions: [{ id: 'rename', label: 'Rename', icon: , onInvoke: fn() }],
authorOf,
mentionCandidates,
},
diff --git a/Source/Chat/ChatSidebar.tsx b/Source/Chat/ChatSidebar.tsx
index 96b9727a..3f3ae177 100644
--- a/Source/Chat/ChatSidebar.tsx
+++ b/Source/Chat/ChatSidebar.tsx
@@ -11,6 +11,7 @@ import { sameChatIdentifier, type ChatIdentifier } from './ChatIdentifier';
import type { ChatMention } from './ChatMention';
import type { ChatMessage } from './ChatMessage';
import type { ChatTopic } from './ChatTopic';
+import type { ChatTopicAction } from './ChatTopicAction';
import type { ChatStatus } from './ChatStatus';
import type { ChatTopicListLabels } from './ChatTopicList';
import { ChatTopicList } from './ChatTopicList';
@@ -107,6 +108,9 @@ export interface ChatSidebarProps<
*/
messages: TMessage[];
+ /** The host's actions to offer on each topic where they are available. The inherited `actions` prop applies only to messages. */
+ topicActions?: ChatTopicAction[];
+
/** Topic-list query display state. Defaults to ready when omitted. */
topicsStatus?: ChatStatus;
@@ -225,6 +229,7 @@ export const ChatSidebar = <
onClose,
topics,
messages,
+ topicActions,
topicsStatus,
messagesStatus,
selectedTopicId,
@@ -399,6 +404,7 @@ export const ChatSidebar = <
{openTopicId === undefined ? (
topics={topics}
+ topicActions={topicActions}
status={topicsStatus}
onOpen={(topic) => select(topic.id, topic)}
onStart={
diff --git a/Source/Chat/ChatTopicAction.ts b/Source/Chat/ChatTopicAction.ts
new file mode 100644
index 00000000..799df2a7
--- /dev/null
+++ b/Source/Chat/ChatTopicAction.ts
@@ -0,0 +1,30 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import type { ReactNode } from 'react';
+import type { ChatTopic } from './ChatTopic';
+
+/** An action a host offers on topics. The library ships no topic actions of its own.
+ * @typeParam TTopic The host's topic type, so {@link onInvoke} receives the full topic back.
+ */
+export interface ChatTopicAction {
+ /** Identifies the action among its siblings — used as the rendering key. */
+ id: string;
+
+ /** The label, used in the tooltip and accessible name alongside the topic's name. */
+ label: string;
+
+ /** An icon element or a CSS class name for the host's icon font. Omit to show the label. */
+ icon?: string | ReactNode;
+
+ /** Decides which topics the action is offered on. Omit to offer it on all topics.
+ * @param topic The topic the action would apply to.
+ * @returns True when the action should be offered.
+ */
+ isAvailable?: (topic: TTopic) => boolean;
+
+ /** Invoked when the action is picked on a topic.
+ * @param topic The topic it was picked on.
+ */
+ onInvoke: (topic: TTopic) => void;
+}
diff --git a/Source/Chat/ChatTopicList.stories.tsx b/Source/Chat/ChatTopicList.stories.tsx
index d9e42edf..4671799a 100644
--- a/Source/Chat/ChatTopicList.stories.tsx
+++ b/Source/Chat/ChatTopicList.stories.tsx
@@ -3,6 +3,7 @@
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from 'storybook/test';
+import { FaPen, FaBoxArchive } from 'react-icons/fa6';
import { ChatAuthorKind } from './Kit/ChatAuthorKind';
import type { ChatAuthor } from './ChatAuthor';
import type { ChatIdentifier } from './ChatIdentifier';
@@ -74,6 +75,26 @@ export const Playground: Story = {
),
};
+/** Hover a topic, or Tab from its open button, to see the host's available actions. */
+export const WithActions: Story = {
+ args: {
+ topics: [
+ { id: 'topic-1', name: 'Example topic' },
+ { id: 'topic-2', name: 'Pinned topic', metadata: { pinned: true } },
+ ],
+ onOpen: fn(),
+ topicActions: [
+ { id: 'rename', label: 'Rename', icon: , onInvoke: fn() },
+ { id: 'archive', label: 'Archive', icon: , isAvailable: (topic) => topic.metadata?.pinned !== true, onInvoke: fn() },
+ ],
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
/** Nothing to pick from yet — only the empty message and the way to start the first topic. */
export const Empty: Story = {
args: {
diff --git a/Source/Chat/ChatTopicList.tsx b/Source/Chat/ChatTopicList.tsx
index 3bfb24bf..733e222b 100644
--- a/Source/Chat/ChatTopicList.tsx
+++ b/Source/Chat/ChatTopicList.tsx
@@ -7,6 +7,7 @@ import { ChatAuthorKind } from './Kit/ChatAuthorKind';
import type { ChatAuthor } from './ChatAuthor';
import { chatIdentifierString, type ChatIdentifier } from './ChatIdentifier';
import type { ChatTopic } from './ChatTopic';
+import type { ChatTopicAction } from './ChatTopicAction';
import { ChatStatus } from './ChatStatus';
import { isTopicUnnamed as defaultIsTopicUnnamed } from './isTopicUnnamed';
import { relativeTimestamp, type RelativeTimestampLabels } from './relativeTimestamp';
@@ -112,6 +113,9 @@ export interface ChatTopicListProps {
/** Overrides for every label rendered. Unset fields fall back to literal English defaults. */
labels?: ChatTopicListLabels;
+ /** The host's actions to offer on each topic where they are available. */
+ topicActions?: ChatTopicAction[];
+
/** Additional class name for the list's root element. */
className?: string;
}
@@ -132,6 +136,7 @@ export const ChatTopicList = ({
isTopicUnnamed,
buildAvatarUrl,
labels,
+ topicActions,
className,
}: ChatTopicListProps) => {
const unnamed = isTopicUnnamed ?? defaultIsTopicUnnamed;
@@ -176,59 +181,89 @@ export const ChatTopicList = ({
: authorFor(topic.startedBy);
const isPending = unnamed(topic);
const activity = topic.lastActivity ?? topic.started;
+ const topicName = isPending ? (labels?.unnamedTopic ?? 'New topic') : topic.name;
+ const availableActions = (topicActions ?? []).filter(
+ (action) => action.isAvailable?.(topic) ?? true,
+ );
- return (
-
- onOpen(topic)}
- >
- {topic.startedBy !== undefined &&
- starter &&
- (renderAvatar ? (
- renderAvatar(topic.startedBy, starter)
- ) : (
-
- ))}
-
-
- {isPending
- ? (labels?.unnamedTopic ?? 'New topic')
- : topic.name}
-
- {starter && (
-
- {(
- labels?.startedBy ?? 'Started by {name}'
- ).replace('{name}', starter.name)}
-
- )}
+ const topicButton = (
+ onOpen(topic)}
+ >
+ {topic.startedBy !== undefined &&
+ starter &&
+ (renderAvatar ? (
+ renderAvatar(topic.startedBy, starter)
+ ) : (
+
+ ))}
+
+
+ {topicName}
- {activity !== undefined && (
-
- {relativeTimestamp(
- activity,
- new Date(),
- labels?.timestamps,
- )}
+ {starter && (
+
+ {(
+ labels?.startedBy ?? 'Started by {name}'
+ ).replace('{name}', starter.name)}
)}
-
+
+ {activity !== undefined && (
+
+ {relativeTimestamp(
+ activity,
+ new Date(),
+ labels?.timestamps,
+ )}
+
+ )}
+
+ );
+
+ // Reuse the message action overlay so keyboard focus reveals controls without adding CSS.
+ return (
+ 0 ? 'cratis-chat-message cratis-chat-topics__row' : undefined}
+ style={availableActions.length > 0 ? { position: 'relative' } : undefined}
+ >
+ {topicButton}
+ {availableActions.length > 0 && (
+
+ {availableActions.map((action) => (
+ action.onInvoke(topic)}
+ >
+ {typeof action.icon === 'string' ? (
+
+ ) : (action.icon ?? action.label)}
+
+ ))}
+
+ )}
);
})}
diff --git a/Source/Chat/for_ChatSidebar/when_topic_actions_are_offered.ts b/Source/Chat/for_ChatSidebar/when_topic_actions_are_offered.ts
new file mode 100644
index 00000000..6681eeb3
--- /dev/null
+++ b/Source/Chat/for_ChatSidebar/when_topic_actions_are_offered.ts
@@ -0,0 +1,42 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+// @vitest-environment jsdom
+
+import { createElement } from 'react';
+import { ChatSidebar } from '../ChatSidebar';
+import type { ChatTopic } from '../ChatTopic';
+import { click, render, unmount, type ChatSidebarInTheDom } from './given/a_chat_sidebar_in_the_dom';
+
+const topic: ChatTopic = { id: 'topic-1', name: 'Example topic' };
+
+describe('when the sidebar offers topic actions', () => {
+ let sidebar: ChatSidebarInTheDom;
+ let invokedWith: ChatTopic | undefined;
+ let selected: boolean;
+
+ beforeEach(async () => {
+ invokedWith = undefined;
+ selected = false;
+ sidebar = await render(createElement(ChatSidebar, {
+ open: true,
+ onClose: () => {},
+ topics: [topic],
+ messages: [],
+ onSendMessage: () => {},
+ onTopicSelected: () => { selected = true; },
+ topicActions: [{ id: 'rename', label: 'Rename', onInvoke: (value) => { invokedWith = value; } }],
+ }));
+ });
+
+ afterEach(async () => { await unmount(sidebar); });
+
+ it('should forward the topic actions without selecting the topic', async () => {
+ const action = document.querySelector('[aria-label="Rename Example topic"]')!;
+ (action !== null).should.be.true;
+ await click(action);
+ invokedWith!.should.equal(topic);
+ selected.should.be.false;
+ document.querySelector('.cratis-chat-sidebar__title')!.textContent!.should.equal('Topics');
+ });
+});
diff --git a/Source/Chat/for_ChatTopicList/when_no_topic_actions_are_offered.ts b/Source/Chat/for_ChatTopicList/when_no_topic_actions_are_offered.ts
new file mode 100644
index 00000000..67e7cb4e
--- /dev/null
+++ b/Source/Chat/for_ChatTopicList/when_no_topic_actions_are_offered.ts
@@ -0,0 +1,25 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+// @vitest-environment jsdom
+
+import { createElement } from 'react';
+import { ChatTopicList } from '../ChatTopicList';
+import { render, unmount, type TopicListInTheDom } from './given/a_topic_list_in_the_dom';
+
+describe('when no topic actions are offered', () => {
+ let list: TopicListInTheDom;
+
+ beforeEach(async () => {
+ list = await render(createElement(ChatTopicList, {
+ topics: [{ id: 'topic-1', name: 'Example topic' }],
+ onOpen: () => {},
+ }));
+ });
+
+ afterEach(async () => { await unmount(list); });
+
+ it('should retain the existing topic markup', () => {
+ list.container.innerHTML.should.equal('');
+ });
+});
diff --git a/Source/Chat/for_ChatTopicList/when_topic_actions_are_offered.ts b/Source/Chat/for_ChatTopicList/when_topic_actions_are_offered.ts
new file mode 100644
index 00000000..846cc45b
--- /dev/null
+++ b/Source/Chat/for_ChatTopicList/when_topic_actions_are_offered.ts
@@ -0,0 +1,63 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+// @vitest-environment jsdom
+
+import { createElement } from 'react';
+import userEvent from '@testing-library/user-event';
+import { ChatTopicList } from '../ChatTopicList';
+import type { ChatTopic } from '../ChatTopic';
+import { click, render, unmount, type TopicListInTheDom } from './given/a_topic_list_in_the_dom';
+
+const topic: ChatTopic = { id: 'topic-1', name: 'Example topic' };
+
+describe('when topic actions are offered', () => {
+ let list: TopicListInTheDom;
+ let invokedWith: ChatTopic | undefined;
+ let openedWith: ChatTopic | undefined;
+
+ beforeEach(async () => {
+ invokedWith = undefined;
+ openedWith = undefined;
+ list = await render(createElement(ChatTopicList, {
+ topics: [topic],
+ onOpen: (opened) => { openedWith = opened; },
+ topicActions: [
+ { id: 'rename', label: 'Rename', icon: 'icon-rename', onInvoke: (selected) => { invokedWith = selected; } },
+ { id: 'hide', label: 'Hide', isAvailable: () => false, onInvoke: () => {} },
+ ],
+ }));
+ });
+
+ afterEach(async () => { await unmount(list); });
+
+ it('should render a labeled button identifying its topic beside the open button', () => {
+ const action = list.container.querySelector('[aria-label="Rename Example topic"]')!;
+ (action !== null).should.be.true;
+ action.type.should.equal('button');
+ (action.closest('.cratis-chat-topics__topic') === null).should.be.true;
+ action.closest('li')!.querySelector('.cratis-chat-topics__topic')!.tagName.should.equal('BUTTON');
+ });
+
+ it('should omit unavailable actions', () => {
+ (list.container.querySelector('[aria-label="Hide Example topic"]') === null).should.be.true;
+ });
+
+ it('should invoke the action with the full topic without opening it', async () => {
+ await click(list.container.querySelector('[aria-label="Rename Example topic"]')!);
+ invokedWith!.should.equal(topic);
+ (openedWith === undefined).should.be.true;
+ });
+
+ it('should reach and activate the action with the keyboard without opening the topic', async () => {
+ const user = userEvent.setup();
+ await user.tab();
+ document.activeElement!.classList.contains('cratis-chat-topics__topic').should.be.true;
+ await user.tab();
+ const action = list.container.querySelector('[aria-label="Rename Example topic"]')!;
+ (document.activeElement === action).should.be.true;
+ await user.keyboard('{Enter}');
+ invokedWith!.should.equal(topic);
+ (openedWith === undefined).should.be.true;
+ });
+});
diff --git a/Source/Chat/index.ts b/Source/Chat/index.ts
index ff465e35..ed058553 100644
--- a/Source/Chat/index.ts
+++ b/Source/Chat/index.ts
@@ -21,6 +21,7 @@ export type {
export { ChatSidebarForObservableQueries } from './ChatSidebarForObservableQueries';
export type { ChatSidebarForObservableQueriesProps } from './ChatSidebarForObservableQueries';
export type { ChatTopic } from './ChatTopic';
+export type { ChatTopicAction } from './ChatTopicAction';
export { ChatTopicList } from './ChatTopicList';
export type { ChatTopicListProps, ChatTopicListLabels } from './ChatTopicList';
export { isTopicUnnamed } from './isTopicUnnamed';
diff --git a/Storybook/scripts/storybook-inventory.json b/Storybook/scripts/storybook-inventory.json
index a2dd3ea4..c77df34e 100644
--- a/Storybook/scripts/storybook-inventory.json
+++ b/Storybook/scripts/storybook-inventory.json
@@ -305,6 +305,7 @@
"chat-chatsidebarforobservablequeries--playground",
"chat-chattopiclist--empty",
"chat-chattopiclist--playground",
+ "chat-chattopiclist--with-actions",
"chat-kit-chat--basic-thread",
"chat-kit-chat--with-failed-turn",
"chat-kit-chatbubble--preview",
From 82ed0ff0813d70ec4b0519a1cb159b8cfd91cbcc Mon Sep 17 00:00:00 2001
From: woksin
Date: Mon, 28 Sep 2026 18:31:45 +0200
Subject: [PATCH 2/7] Make Arc-provided metadata and injection peers optional
Arc React already installs tsyringe, and Arc initializes Fundamentals reflection without reflect-metadata. Keep both peer ranges but mark them optional, and exercise the packed root, Display, and an Arc query in absent-peer consumers across CI installers. Closes #248.
---
.github/workflows/javascript-build.yml | 22 ++++---
Documentation/getting-started.mdx | 6 +-
Source/package.json | 6 ++
.../lib/peer-absent-consumer-probe.mjs | 61 +++++++++++++++++++
Source/scripts/verify-no-pixi-consumer.mjs | 35 ++++++++---
yarn.lock | 4 ++
6 files changed, 113 insertions(+), 21 deletions(-)
create mode 100644 Source/scripts/lib/peer-absent-consumer-probe.mjs
diff --git a/.github/workflows/javascript-build.yml b/.github/workflows/javascript-build.yml
index 88753f32..d67069d5 100644
--- a/.github/workflows/javascript-build.yml
+++ b/.github/workflows/javascript-build.yml
@@ -697,12 +697,13 @@ jobs:
CONSUMER="$(mktemp -d)"
cd "$CONSUMER"
printf '{"private":true,"type":"module"}\n' > package.json
- npm install --ignore-scripts \
+ npm install --ignore-scripts --omit=peer \
/tmp/cratis-components.tgz \
"@cratis/arc@${{ matrix.arc-version }}" \
"@cratis/arc.react@${{ matrix.arc-version }}" \
- @cratis/fundamentals react@19 react-dom@19 \
- reflect-metadata tsyringe
+ @cratis/fundamentals react@19 react-dom@19
+ cp "$GITHUB_WORKSPACE/Source/scripts/lib/peer-absent-consumer-probe.mjs" verify-peers.mjs
+ node verify-peers.mjs
node --input-type=module - <<'NODE'
const commandDialog = await import('@cratis/components/CommandDialog');
const dataPage = await import('@cratis/components/DataPage');
@@ -777,6 +778,8 @@ jobs:
JSON
cp Source/scripts/lib/package-manager-consumer-probe.mjs \
/tmp/components-consumer/verify.mjs
+ cp Source/scripts/lib/peer-absent-consumer-probe.mjs \
+ /tmp/components-consumer/verify-peers.mjs
- name: Verify npm consumer
if: matrix.manager == 'npm'
@@ -784,13 +787,13 @@ jobs:
run: |
PIXI=()
if [ "${{ matrix.pixi }}" = 'present' ]; then PIXI+=(pixi.js@8.20.0); fi
- npm install --ignore-scripts --no-audit --no-fund \
+ npm install --ignore-scripts --omit=peer --no-audit --no-fund \
/tmp/cratis-components.tgz \
@cratis/arc@22.16.0 @cratis/arc.react@22.16.0 \
@cratis/fundamentals@7.19.2 @types/react@19.2.18 \
- react@19.2.8 react-dom@19.2.8 \
- reflect-metadata@0.2.2 tsyringe@4.10.0 "${PIXI[@]}"
+ react@19.2.8 react-dom@19.2.8 "${PIXI[@]}"
node verify.mjs "${{ matrix.pixi }}"
+ node verify-peers.mjs
- name: Verify pnpm consumer
if: matrix.manager == 'pnpm'
@@ -803,9 +806,9 @@ jobs:
/tmp/cratis-components.tgz \
@cratis/arc@22.16.0 @cratis/arc.react@22.16.0 \
@cratis/fundamentals@7.19.2 @types/react@19.2.18 \
- react@19.2.8 react-dom@19.2.8 \
- reflect-metadata@0.2.2 tsyringe@4.10.0 "${PIXI[@]}"
+ react@19.2.8 react-dom@19.2.8 "${PIXI[@]}"
pnpm exec node verify.mjs "${{ matrix.pixi }}"
+ pnpm exec node verify-peers.mjs
- name: Verify Yarn PnP consumer
if: matrix.manager == 'yarn-pnp'
@@ -822,8 +825,9 @@ jobs:
@cratis/arc@22.16.0 @cratis/arc.react@22.16.0 \
@cratis/fundamentals@7.19.2 @types/react@19.2.18 \
react@19.2.8 react-dom@19.2.8 \
- reflect-metadata@0.2.2 tsyringe@4.10.0 rxjs@7.8.2 "${PIXI[@]}"
+ rxjs@7.8.2 "${PIXI[@]}"
yarn node verify.mjs "${{ matrix.pixi }}"
+ yarn node verify-peers.mjs
verify-renderer-adapters:
name: Renderer adapter (${{ matrix.adapter }}, ${{ matrix.boundary }}, ${{ matrix.manager }})
diff --git a/Documentation/getting-started.mdx b/Documentation/getting-started.mdx
index 6d877fea..6f01ec3a 100644
--- a/Documentation/getting-started.mdx
+++ b/Documentation/getting-started.mdx
@@ -27,19 +27,19 @@ The examples use the proxies from the Arc tutorial: `RegisterAuthor` has a requi
React Aria, the internationalized date implementation, and React Icons are internal dependencies. The package does not depend on PrimeReact or any other UI kit or theme runtime, and you don't configure an icon package. The optional MUI and PrimeReact integrations are separate [renderer adapter](/components/renderers/) packages.
- The package declares React, Arc, Fundamentals, `reflect-metadata`, and `tsyringe` as peers. Keep `@cratis/arc` and `@cratis/arc.react` on the same version your generated proxies were produced with. A strict installer can install every peer explicitly (replace the Arc version with the one your backend uses):
+ The package requires React, Arc, and Fundamentals as peers. `reflect-metadata` and `tsyringe` remain declared peers but are optional for Components: Arc React installs `tsyringe` as its own dependency, and Arc loads Fundamentals' Reflect metadata implementation. Keep `@cratis/arc` and `@cratis/arc.react` on the same version your generated proxies were produced with. This example still installs `reflect-metadata` because its entry point imports it explicitly; omit both that install and the import if your application does not use the package. For a strict installer, install the required peers explicitly (replace the Arc version with the one your backend uses):
```bash title="Install explicit peers"
ARC_VERSION=22.16.0
npm install @cratis/components@^4 \
"@cratis/arc@$ARC_VERSION" "@cratis/arc.react@$ARC_VERSION" \
@cratis/fundamentals@^7.19.2 react@^19 react-dom@^19 \
- reflect-metadata@0.2.2 tsyringe@4.10.0
+ reflect-metadata@0.2.2
```
Use Arc 22.16.0 or newer if you plan to use `AutoCommandForm`'s explicit field binding.
- `pixi.js@^8.20.0` is an additional **optional** peer, needed only if you use `Canvas` or `PivotViewer`. Every other component needs nothing beyond the peers above; install Pixi later, when you reach a spatial workspace or card-grid screen. See [Choosing a component](/components/choosing-a-component/#spatial-workspaces).
+ `pixi.js@^8.20.0` is another **optional** peer, needed only if you use `Canvas` or `PivotViewer`. Every other component needs nothing beyond the required peers above; install Pixi later, when you reach a spatial workspace or card-grid screen. See [Choosing a component](/components/choosing-a-component/#spatial-workspaces).
2. **Import the stylesheets once**, at your application entry point, after `reflect-metadata`:
diff --git a/Source/package.json b/Source/package.json
index 5e25889e..3c0a1e47 100644
--- a/Source/package.json
+++ b/Source/package.json
@@ -310,6 +310,12 @@
"peerDependenciesMeta": {
"pixi.js": {
"optional": true
+ },
+ "reflect-metadata": {
+ "optional": true
+ },
+ "tsyringe": {
+ "optional": true
}
}
}
diff --git a/Source/scripts/lib/peer-absent-consumer-probe.mjs b/Source/scripts/lib/peer-absent-consumer-probe.mjs
new file mode 100644
index 00000000..80665123
--- /dev/null
+++ b/Source/scripts/lib/peer-absent-consumer-probe.mjs
@@ -0,0 +1,61 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+/** Run inside a packed consumer with neither reflect-metadata nor tsyringe installed explicitly. */
+import { createRequire } from 'node:module';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+
+const resolveIfPresent = (specifier, resolver) => {
+ try {
+ return resolver.resolve(specifier);
+ } catch (error) {
+ if (error?.code === 'MODULE_NOT_FOUND' || error?.code === 'ERR_MODULE_NOT_FOUND') return undefined;
+ throw error;
+ }
+};
+const consumerRequire = createRequire(import.meta.url);
+const componentsRequire = createRequire(import.meta.resolve('@cratis/components'));
+if (resolveIfPresent('reflect-metadata', consumerRequire) ||
+ resolveIfPresent('reflect-metadata', componentsRequire)) {
+ throw new Error('reflect-metadata is installed; this is not the absent-peer case.');
+}
+console.log('reflect-metadata absent from consumer and Components');
+
+const root = await import('@cratis/components');
+if (!root.CratisComponentsProvider) throw new Error('Root import did not export the provider.');
+console.log('root import passed');
+const display = await import('@cratis/components/Display');
+if (!display.Badge) throw new Error('Non-Arc Display subpath failed.');
+console.log('non-Arc Display import passed');
+
+// tsyringe must resolve from Arc React, not from Components' optional peer declaration.
+const arcReactRequire = createRequire(import.meta.resolve('@cratis/arc.react'));
+if (!resolveIfPresent('tsyringe', arcReactRequire)) {
+ throw new Error('Arc React did not bring tsyringe as its own dependency.');
+}
+console.log('Arc React resolves its own tsyringe dependency');
+await import('@cratis/arc');
+if (typeof Reflect.getMetadata !== 'function') {
+ throw new Error('Arc did not initialize the Fundamentals Reflect metadata implementation.');
+}
+console.log('Arc initializes Reflect metadata without reflect-metadata');
+
+const dataTables = await import('@cratis/components/DataTables');
+if (!dataTables.DataTableForQuery) throw new Error('Arc-backed DataTables import failed.');
+const { QueryFor } = await import('@cratis/arc/queries');
+class ExampleQuery extends QueryFor {
+ constructor() { super(Object, true); }
+ route = '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/api/example';
+ parameterDescriptors = [];
+ requiredRequestParameters = [];
+ defaultValue = [];
+}
+const markup = renderToStaticMarkup(createElement(dataTables.DataTableForQuery, {
+ query: ExampleQuery,
+ emptyMessage: 'No examples',
+}));
+if (!markup.includes('cratis-datatable__table')) {
+ throw new Error('Arc-backed DataTables query did not render a table.');
+}
+console.log('Arc-backed DataTables query rendered without optional peers');
diff --git a/Source/scripts/verify-no-pixi-consumer.mjs b/Source/scripts/verify-no-pixi-consumer.mjs
index bee06290..36cb1a2e 100644
--- a/Source/scripts/verify-no-pixi-consumer.mjs
+++ b/Source/scripts/verify-no-pixi-consumer.mjs
@@ -4,12 +4,12 @@
/*
* Packed, no-Pixi consumer verification for the setup-only root architecture
* (Cratis/Components root architecture). Proves that a consumer who installs only the
- * *mandatory* peers - never `pixi.js` - can fully use the package root and every
+ * *mandatory* peers - never `pixi.js` or `reflect-metadata` - can fully use the package root and every
* non-spatial subpath (everything except `./Canvas` and `./PivotViewer`).
*
* Unlike `verify-public-types.mjs`, which symlinks the whole monorepo `node_modules`
* (including the `pixi.js` devDependency) into its scratch consumer, this script
- * deliberately BUILDS A NODE_MODULES WITHOUT `pixi.js`, `@pixi/*`, or `@webgpu/*` -
+ * deliberately BUILDS A NODE_MODULES WITHOUT `pixi.js`, `reflect-metadata`, `@pixi/*`, or `@webgpu/*` -
* so "pixi is absent" is a real `ERR_MODULE_NOT_FOUND`/module-resolution failure, not
* an assumption. What is checked against that scratch consumer:
*
@@ -86,24 +86,24 @@ if (!existsSync(esmRoot)) {
process.exit(1);
}
-/** Mandatory peers only - `pixi.js` is intentionally never installed by this fixture. */
+/** Mandatory peers only. Arc React supplies tsyringe; pixi.js and reflect-metadata are absent. */
const EXPECTED_MANDATORY_PEERS = new Set([
'@cratis/arc',
'@cratis/arc.react',
'@cratis/fundamentals',
'react',
'react-dom',
- 'reflect-metadata',
- 'tsyringe',
]);
const optionalPeers = new Set(
Object.entries(pkg.peerDependenciesMeta ?? {})
.filter(([, metadata]) => metadata?.optional === true)
.map(([name]) => name),
);
-if (optionalPeers.size !== 1 || !optionalPeers.has('pixi.js')) {
+const expectedOptionalPeers = new Set(['pixi.js', 'reflect-metadata', 'tsyringe']);
+if (optionalPeers.size !== expectedOptionalPeers.size ||
+ [...expectedOptionalPeers].some((peer) => !optionalPeers.has(peer))) {
console.error(
- 'The no-Pixi contract requires pixi.js to be the only optional peer. ' +
+ 'The no-Pixi contract requires pixi.js, reflect-metadata, and tsyringe to be optional peers. ' +
`Found: ${[...optionalPeers].join(', ') || '(none)'}.`,
);
process.exit(1);
@@ -168,7 +168,7 @@ const { packedComponentsDir } = buildScratchNodeModules({
monorepoRoot,
scratchRoot,
packedEntries,
- excludeTopLevel: new Set(['pixi.js']),
+ excludeTopLevel: new Set(['pixi.js', 'reflect-metadata']),
excludeScoped: new Set(['@pixi', '@webgpu']),
});
@@ -202,6 +202,23 @@ if (
);
}
+const reflectProbe = spawnSync(
+ process.execPath,
+ [
+ '--input-type',
+ 'module',
+ '--eval',
+ "try { import.meta.resolve('reflect-metadata'); console.log('RESOLVED'); } catch (error) { console.log('ERR ' + error.code); }",
+ ],
+ { cwd: scratchRoot, encoding: 'utf8', timeout: 30_000 },
+);
+if (reflectProbe.status === 0 && (reflectProbe.stdout ?? '').trim() === 'ERR ERR_MODULE_NOT_FOUND') {
+ pass('reflect-metadata is absent from the consumer node_modules');
+} else {
+ fail('reflect-metadata must be absent from the no-Pixi consumer',
+ (reflectProbe.stdout || reflectProbe.stderr || '').trim());
+}
+
// --- 3. Runtime: every non-spatial subpath imports cleanly with no pixi installed ----------------
for (const { subpath, specifier } of nonSpatialSubpaths) {
@@ -380,7 +397,7 @@ if (failures.length > 0) {
}
console.log(
- `All no-pixi consumer checks passed: mandatory peers only, pixi.js genuinely absent, ` +
+ `All no-pixi consumer checks passed: mandatory peers only, pixi.js and reflect-metadata genuinely absent, ` +
`${nonSpatialSubpaths.length} non-spatial subpath(s) load, strict Bundler+NodeNext root ` +
'type-checks, SSR smoke passes, and the packed root closure is clean.',
);
diff --git a/yarn.lock b/yarn.lock
index 50aae7e5..764854af 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -457,6 +457,10 @@ __metadata:
peerDependenciesMeta:
pixi.js:
optional: true
+ reflect-metadata:
+ optional: true
+ tsyringe:
+ optional: true
languageName: unknown
linkType: soft
From 1b8ab83c304abbc39c3b07b3b61bea6824f0e9bd Mon Sep 17 00:00:00 2001
From: woksin
Date: Mon, 28 Sep 2026 22:16:04 +0200
Subject: [PATCH 3/7] Keep reflect-metadata a required peer and guard against
direct peer imports
Making reflect-metadata optional would stop npm from installing it for applications
that relied on the documented single-package install and still import it at their
entry point. Only tsyringe, which Components never imports and Arc React installs,
becomes optional. The package archive check now fails if a published file imports
tsyringe or reflect-metadata, and one npm consumer leg installs peers as npm does.
---
.github/workflows/javascript-build.yml | 12 ++---
Documentation/getting-started.mdx | 2 +-
README.md | 4 +-
Source/README.md | 4 +-
Source/package.json | 3 --
.../lib/peer-absent-consumer-probe.mjs | 21 ++++-----
Source/scripts/verify-no-pixi-consumer.mjs | 32 ++++---------
Source/scripts/verify-package-archive.mjs | 46 +++++++++++++++++++
8 files changed, 74 insertions(+), 50 deletions(-)
diff --git a/.github/workflows/javascript-build.yml b/.github/workflows/javascript-build.yml
index d67069d5..74a52764 100644
--- a/.github/workflows/javascript-build.yml
+++ b/.github/workflows/javascript-build.yml
@@ -697,11 +697,11 @@ jobs:
CONSUMER="$(mktemp -d)"
cd "$CONSUMER"
printf '{"private":true,"type":"module"}\n' > package.json
- npm install --ignore-scripts --omit=peer \
+ npm install --ignore-scripts \
/tmp/cratis-components.tgz \
"@cratis/arc@${{ matrix.arc-version }}" \
"@cratis/arc.react@${{ matrix.arc-version }}" \
- @cratis/fundamentals react@19 react-dom@19
+ @cratis/fundamentals react@19 react-dom@19 reflect-metadata
cp "$GITHUB_WORKSPACE/Source/scripts/lib/peer-absent-consumer-probe.mjs" verify-peers.mjs
node verify-peers.mjs
node --input-type=module - <<'NODE'
@@ -787,11 +787,11 @@ jobs:
run: |
PIXI=()
if [ "${{ matrix.pixi }}" = 'present' ]; then PIXI+=(pixi.js@8.20.0); fi
- npm install --ignore-scripts --omit=peer --no-audit --no-fund \
+ npm install --ignore-scripts --no-audit --no-fund \
/tmp/cratis-components.tgz \
@cratis/arc@22.16.0 @cratis/arc.react@22.16.0 \
@cratis/fundamentals@7.19.2 @types/react@19.2.18 \
- react@19.2.8 react-dom@19.2.8 "${PIXI[@]}"
+ react@19.2.8 react-dom@19.2.8 reflect-metadata@0.2.2 "${PIXI[@]}"
node verify.mjs "${{ matrix.pixi }}"
node verify-peers.mjs
@@ -806,7 +806,7 @@ jobs:
/tmp/cratis-components.tgz \
@cratis/arc@22.16.0 @cratis/arc.react@22.16.0 \
@cratis/fundamentals@7.19.2 @types/react@19.2.18 \
- react@19.2.8 react-dom@19.2.8 "${PIXI[@]}"
+ react@19.2.8 react-dom@19.2.8 reflect-metadata@0.2.2 "${PIXI[@]}"
pnpm exec node verify.mjs "${{ matrix.pixi }}"
pnpm exec node verify-peers.mjs
@@ -825,7 +825,7 @@ jobs:
@cratis/arc@22.16.0 @cratis/arc.react@22.16.0 \
@cratis/fundamentals@7.19.2 @types/react@19.2.18 \
react@19.2.8 react-dom@19.2.8 \
- rxjs@7.8.2 "${PIXI[@]}"
+ reflect-metadata@0.2.2 rxjs@7.8.2 "${PIXI[@]}"
yarn node verify.mjs "${{ matrix.pixi }}"
yarn node verify-peers.mjs
diff --git a/Documentation/getting-started.mdx b/Documentation/getting-started.mdx
index 6f01ec3a..45dcf48b 100644
--- a/Documentation/getting-started.mdx
+++ b/Documentation/getting-started.mdx
@@ -27,7 +27,7 @@ The examples use the proxies from the Arc tutorial: `RegisterAuthor` has a requi
React Aria, the internationalized date implementation, and React Icons are internal dependencies. The package does not depend on PrimeReact or any other UI kit or theme runtime, and you don't configure an icon package. The optional MUI and PrimeReact integrations are separate [renderer adapter](/components/renderers/) packages.
- The package requires React, Arc, and Fundamentals as peers. `reflect-metadata` and `tsyringe` remain declared peers but are optional for Components: Arc React installs `tsyringe` as its own dependency, and Arc loads Fundamentals' Reflect metadata implementation. Keep `@cratis/arc` and `@cratis/arc.react` on the same version your generated proxies were produced with. This example still installs `reflect-metadata` because its entry point imports it explicitly; omit both that install and the import if your application does not use the package. For a strict installer, install the required peers explicitly (replace the Arc version with the one your backend uses):
+ The package requires React, Arc, Fundamentals, and `reflect-metadata` as peers. `tsyringe` is an optional peer: Components never imports it, and Arc React installs it as its own dependency. Keep `@cratis/arc` and `@cratis/arc.react` on the same version your generated proxies were produced with. A strict installer can install the required peers explicitly (replace the Arc version with the one your backend uses):
```bash title="Install explicit peers"
ARC_VERSION=22.16.0
diff --git a/README.md b/README.md
index 4952b10e..b519312d 100644
--- a/README.md
+++ b/README.md
@@ -58,8 +58,8 @@ npm install @cratis/components@^4
Components 4 needs React 19 and Arc. Its peers are `react` and `react-dom` `^19.0.0`,
`@cratis/arc` and `@cratis/arc.react` `>=20.3.1 <23`, `@cratis/fundamentals` `^7.10.3`,
-`reflect-metadata` `0.2.2`, and `tsyringe` `4.10.0`; `pixi.js` `^8.20.0` is an optional peer
-needed only by `Canvas` and `PivotViewer`. This repository builds and tests against Arc 22.16.0.
+and `reflect-metadata` `0.2.2`. `tsyringe` `4.10.0` is an optional peer that Arc React already
+installs, and `pixi.js` `^8.20.0` is an optional peer needed only by `Canvas` and `PivotViewer`. This repository builds and tests against Arc 22.16.0.
The package does not depend on PrimeReact; the optional MUI and PrimeReact renderer adapters are
separate packages.
diff --git a/Source/README.md b/Source/README.md
index f32019ec..364a471f 100644
--- a/Source/README.md
+++ b/Source/README.md
@@ -27,7 +27,7 @@ The current package manifest declares these peer dependencies:
- optional `pixi.js` `^8.20.0`
- `react` and `react-dom` `^19.0.0`
- `reflect-metadata` `0.2.2`
-- `tsyringe` `4.10.0`
+- optional `tsyringe` `4.10.0` (Arc React installs it as its own dependency)
Strict installers can declare them explicitly; keep both Arc packages on the version your generated proxies were produced with. This repository builds and tests against Arc 22.16.0:
@@ -36,7 +36,7 @@ ARC_VERSION=22.16.0
npm install @cratis/components@^4 \
"@cratis/arc@$ARC_VERSION" "@cratis/arc.react@$ARC_VERSION" \
@cratis/fundamentals@^7.19.2 react@^19 react-dom@^19 \
- reflect-metadata@0.2.2 tsyringe@4.10.0
+ reflect-metadata@0.2.2
```
The current manifest does not declare PrimeReact, PrimeIcons, or PrimeUI packages as dependencies or peers. Applications retaining direct dependencies keep their own package, provider, styling, and license boundaries.
diff --git a/Source/package.json b/Source/package.json
index 3c0a1e47..e589af97 100644
--- a/Source/package.json
+++ b/Source/package.json
@@ -311,9 +311,6 @@
"pixi.js": {
"optional": true
},
- "reflect-metadata": {
- "optional": true
- },
"tsyringe": {
"optional": true
}
diff --git a/Source/scripts/lib/peer-absent-consumer-probe.mjs b/Source/scripts/lib/peer-absent-consumer-probe.mjs
index 80665123..c0459715 100644
--- a/Source/scripts/lib/peer-absent-consumer-probe.mjs
+++ b/Source/scripts/lib/peer-absent-consumer-probe.mjs
@@ -1,7 +1,11 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
-/** Run inside a packed consumer with neither reflect-metadata nor tsyringe installed explicitly. */
+/**
+ * Run inside a packed consumer that does not install tsyringe explicitly. tsyringe is an
+ * optional peer of Components: Components never imports it, and Arc React brings it as its
+ * own dependency. reflect-metadata remains a required peer and is expected to be present.
+ */
import { createRequire } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
@@ -15,12 +19,10 @@ const resolveIfPresent = (specifier, resolver) => {
}
};
const consumerRequire = createRequire(import.meta.url);
-const componentsRequire = createRequire(import.meta.resolve('@cratis/components'));
-if (resolveIfPresent('reflect-metadata', consumerRequire) ||
- resolveIfPresent('reflect-metadata', componentsRequire)) {
- throw new Error('reflect-metadata is installed; this is not the absent-peer case.');
+if (!resolveIfPresent('reflect-metadata', consumerRequire)) {
+ throw new Error('reflect-metadata is a required peer and must be installed in the consumer.');
}
-console.log('reflect-metadata absent from consumer and Components');
+console.log('required peer reflect-metadata is installed');
const root = await import('@cratis/components');
if (!root.CratisComponentsProvider) throw new Error('Root import did not export the provider.');
@@ -35,11 +37,6 @@ if (!resolveIfPresent('tsyringe', arcReactRequire)) {
throw new Error('Arc React did not bring tsyringe as its own dependency.');
}
console.log('Arc React resolves its own tsyringe dependency');
-await import('@cratis/arc');
-if (typeof Reflect.getMetadata !== 'function') {
- throw new Error('Arc did not initialize the Fundamentals Reflect metadata implementation.');
-}
-console.log('Arc initializes Reflect metadata without reflect-metadata');
const dataTables = await import('@cratis/components/DataTables');
if (!dataTables.DataTableForQuery) throw new Error('Arc-backed DataTables import failed.');
@@ -58,4 +55,4 @@ const markup = renderToStaticMarkup(createElement(dataTables.DataTableForQuery,
if (!markup.includes('cratis-datatable__table')) {
throw new Error('Arc-backed DataTables query did not render a table.');
}
-console.log('Arc-backed DataTables query rendered without optional peers');
+console.log('Arc-backed DataTables query rendered without installing tsyringe explicitly');
diff --git a/Source/scripts/verify-no-pixi-consumer.mjs b/Source/scripts/verify-no-pixi-consumer.mjs
index 36cb1a2e..e20cfd12 100644
--- a/Source/scripts/verify-no-pixi-consumer.mjs
+++ b/Source/scripts/verify-no-pixi-consumer.mjs
@@ -4,12 +4,12 @@
/*
* Packed, no-Pixi consumer verification for the setup-only root architecture
* (Cratis/Components root architecture). Proves that a consumer who installs only the
- * *mandatory* peers - never `pixi.js` or `reflect-metadata` - can fully use the package root and every
+ * *mandatory* peers - never `pixi.js` - can fully use the package root and every
* non-spatial subpath (everything except `./Canvas` and `./PivotViewer`).
*
* Unlike `verify-public-types.mjs`, which symlinks the whole monorepo `node_modules`
* (including the `pixi.js` devDependency) into its scratch consumer, this script
- * deliberately BUILDS A NODE_MODULES WITHOUT `pixi.js`, `reflect-metadata`, `@pixi/*`, or `@webgpu/*` -
+ * deliberately BUILDS A NODE_MODULES WITHOUT `pixi.js`, `@pixi/*`, or `@webgpu/*` -
* so "pixi is absent" is a real `ERR_MODULE_NOT_FOUND`/module-resolution failure, not
* an assumption. What is checked against that scratch consumer:
*
@@ -86,24 +86,25 @@ if (!existsSync(esmRoot)) {
process.exit(1);
}
-/** Mandatory peers only. Arc React supplies tsyringe; pixi.js and reflect-metadata are absent. */
+/** Mandatory peers only. pixi.js is absent, and Arc React supplies the optional tsyringe peer. */
const EXPECTED_MANDATORY_PEERS = new Set([
'@cratis/arc',
'@cratis/arc.react',
'@cratis/fundamentals',
'react',
'react-dom',
+ 'reflect-metadata',
]);
const optionalPeers = new Set(
Object.entries(pkg.peerDependenciesMeta ?? {})
.filter(([, metadata]) => metadata?.optional === true)
.map(([name]) => name),
);
-const expectedOptionalPeers = new Set(['pixi.js', 'reflect-metadata', 'tsyringe']);
+const expectedOptionalPeers = new Set(['pixi.js', 'tsyringe']);
if (optionalPeers.size !== expectedOptionalPeers.size ||
[...expectedOptionalPeers].some((peer) => !optionalPeers.has(peer))) {
console.error(
- 'The no-Pixi contract requires pixi.js, reflect-metadata, and tsyringe to be optional peers. ' +
+ 'The no-Pixi contract requires exactly pixi.js and tsyringe to be optional peers. ' +
`Found: ${[...optionalPeers].join(', ') || '(none)'}.`,
);
process.exit(1);
@@ -168,7 +169,7 @@ const { packedComponentsDir } = buildScratchNodeModules({
monorepoRoot,
scratchRoot,
packedEntries,
- excludeTopLevel: new Set(['pixi.js', 'reflect-metadata']),
+ excludeTopLevel: new Set(['pixi.js']),
excludeScoped: new Set(['@pixi', '@webgpu']),
});
@@ -202,23 +203,6 @@ if (
);
}
-const reflectProbe = spawnSync(
- process.execPath,
- [
- '--input-type',
- 'module',
- '--eval',
- "try { import.meta.resolve('reflect-metadata'); console.log('RESOLVED'); } catch (error) { console.log('ERR ' + error.code); }",
- ],
- { cwd: scratchRoot, encoding: 'utf8', timeout: 30_000 },
-);
-if (reflectProbe.status === 0 && (reflectProbe.stdout ?? '').trim() === 'ERR ERR_MODULE_NOT_FOUND') {
- pass('reflect-metadata is absent from the consumer node_modules');
-} else {
- fail('reflect-metadata must be absent from the no-Pixi consumer',
- (reflectProbe.stdout || reflectProbe.stderr || '').trim());
-}
-
// --- 3. Runtime: every non-spatial subpath imports cleanly with no pixi installed ----------------
for (const { subpath, specifier } of nonSpatialSubpaths) {
@@ -397,7 +381,7 @@ if (failures.length > 0) {
}
console.log(
- `All no-pixi consumer checks passed: mandatory peers only, pixi.js and reflect-metadata genuinely absent, ` +
+ `All no-pixi consumer checks passed: mandatory peers only, pixi.js genuinely absent, ` +
`${nonSpatialSubpaths.length} non-spatial subpath(s) load, strict Bundler+NodeNext root ` +
'type-checks, SSR smoke passes, and the packed root closure is clean.',
);
diff --git a/Source/scripts/verify-package-archive.mjs b/Source/scripts/verify-package-archive.mjs
index 78ebf545..c7740e84 100644
--- a/Source/scripts/verify-package-archive.mjs
+++ b/Source/scripts/verify-package-archive.mjs
@@ -184,6 +184,52 @@ if (runtimeWithoutDocs.includes(apiDocMarker)) {
process.exit(1);
}
+// tsyringe is an optional peer: Arc React brings it, and Components must never import it itself,
+// or strict installers (pnpm, Yarn PnP) fail for consumers that did not install it. reflect-metadata
+// is initialized by the application entry point, not by Components.
+const consumerOwnedModules = ['tsyringe', 'reflect-metadata'];
+const importsOf = (text) => [
+ ...text.matchAll(/\b(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/gu),
+ ...text.matchAll(/\brequire\s*\(\s*['"]([^'"]+)['"]/gu),
+].map((match) => match[1]);
+const importsConsumerOwnedModule = (text) =>
+ importsOf(text).some((specifier) =>
+ consumerOwnedModules.some((module) => specifier === module || specifier.startsWith(`${module}/`)));
+for (const planted of [
+ "import { container } from 'tsyringe';",
+ "import 'reflect-metadata';",
+ "const module = await import('tsyringe');",
+ "const reflect = require('reflect-metadata');",
+ "export type { InjectionToken } from 'tsyringe/dist/typings/types';",
+]) {
+ if (!importsConsumerOwnedModule(planted)) {
+ console.error(`The consumer-owned import check missed a planted import: ${planted}`);
+ process.exit(1);
+ }
+}
+if (importsConsumerOwnedModule("import { useQuery } from '@cratis/arc.react/queries';")) {
+ console.error('The consumer-owned import check flagged an unrelated import.');
+ process.exit(1);
+}
+const scriptEntries = [...entries].filter((entry) =>
+ entry.startsWith('package/dist/') && /\.(?:c|m)?js$|\.d\.(?:c|m)?ts$/u.test(entry));
+if (scriptEntries.length === 0) {
+ console.error('The published archive contains no JavaScript or declaration files to check.');
+ process.exit(1);
+}
+const consumerOwnedImports = scriptEntries.filter((entry) => importsConsumerOwnedModule(readPackedText(entry)));
+if (consumerOwnedImports.length > 0) {
+ console.error(
+ `Published files import modules Components must not import (${consumerOwnedModules.join(', ')}):\n- ` +
+ consumerOwnedImports.join('\n- '),
+ );
+ process.exit(1);
+}
+console.log(
+ `No published JavaScript or declaration file imports ${consumerOwnedModules.join(' or ')} ` +
+ `(${scriptEntries.length} files checked).`,
+);
+
const stylesEntry = 'package/dist/esm/styles.css';
const styles = readPackedText(stylesEntry);
try {
From af9936812f0cc7ca24977f1f8d2cb6377d5955dd Mon Sep 17 00:00:00 2001
From: woksin
Date: Mon, 28 Sep 2026 22:18:22 +0200
Subject: [PATCH 4/7] Recover topic action focus and style topic rows
separately from messages
Invoking an action that hides itself or removes its topic returned focus to the
document body. Focus now moves to the same topic, the topic that took its place,
or the New topic button, unless the host already moved it. Topic rows no longer
carry the chat message class; row-specific rules center the reused action overlay.
---
Source/Chat/ChatTopicList.css | 18 ++-
Source/Chat/ChatTopicList.tsx | 39 ++++--
...when_a_focused_topic_action_disappears.tsx | 115 ++++++++++++++++++
3 files changed, 164 insertions(+), 8 deletions(-)
create mode 100644 Source/Chat/for_ChatTopicList/when_a_focused_topic_action_disappears.tsx
diff --git a/Source/Chat/ChatTopicList.css b/Source/Chat/ChatTopicList.css
index 053424f0..dfb3c107 100644
--- a/Source/Chat/ChatTopicList.css
+++ b/Source/Chat/ChatTopicList.css
@@ -69,10 +69,26 @@
transition: background 0.12s;
}
-.cratis-chat-topics__topic:hover {
+.cratis-chat-topics__topic:hover,
+.cratis-chat-topics__row:hover .cratis-chat-topics__topic {
background: var(--cratis-surface-hover);
}
+/* Topic actions reuse the message action overlay, centered on the row instead of hanging above it. */
+.cratis-chat-topics__row {
+ position: relative;
+}
+
+.cratis-chat-topics__row .cratis-chat-message__actions {
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+.cratis-chat-topics__row:hover .cratis-chat-message__actions {
+ opacity: 1;
+ pointer-events: auto;
+}
+
.cratis-chat-topics__details {
flex: 1;
min-width: 0;
diff --git a/Source/Chat/ChatTopicList.tsx b/Source/Chat/ChatTopicList.tsx
index 733e222b..d10e097d 100644
--- a/Source/Chat/ChatTopicList.tsx
+++ b/Source/Chat/ChatTopicList.tsx
@@ -1,7 +1,7 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
-import type { ReactNode } from 'react';
+import { useLayoutEffect, useRef, type ReactNode } from 'react';
import { PersonAvatarCircle, type BuildAvatarUrlParams } from './Kit/Avatar';
import { ChatAuthorKind } from './Kit/ChatAuthorKind';
import type { ChatAuthor } from './ChatAuthor';
@@ -140,6 +140,28 @@ export const ChatTopicList = ({
className,
}: ChatTopicListProps) => {
const unnamed = isTopicUnnamed ?? defaultIsTopicUnnamed;
+ const listRef = useRef(null);
+ // The topic action that last held focus. If invoking it hides the action or removes its topic,
+ // the focused button unmounts and focus would fall to the document body.
+ const focusedActionRef = useRef<{ element: HTMLElement; topicKey: string; index: number } | null>(null);
+
+ const orderedTopics = status === ChatStatus.Unauthorized ? [] : topicsByActivity(topics);
+ const orderedTopicKeys = orderedTopics.map((topic) => chatIdentifierString(topic.id));
+
+ useLayoutEffect(() => {
+ const focused = focusedActionRef.current;
+ if (!focused || focused.element.isConnected) return;
+ focusedActionRef.current = null;
+ const active = document.activeElement;
+ // Respect focus the host moved on purpose, for example into a rename dialog.
+ if (active && active !== document.body) return;
+ const rows = Array.from(listRef.current?.children ?? []);
+ const sameTopic = orderedTopicKeys.indexOf(focused.topicKey);
+ const row = sameTopic >= 0 ? rows[sameTopic] : rows[Math.min(focused.index, rows.length - 1)];
+ const target = row?.querySelector('.cratis-chat-topics__topic') ??
+ listRef.current?.parentElement?.querySelector('.cratis-chat-topics__start');
+ target?.focus();
+ });
const authorFor = (authorId: ChatIdentifier): ChatAuthor =>
authorOf?.(authorId) ?? {
@@ -173,8 +195,8 @@ export const ChatTopicList = ({
)}
)}
-
- {status !== ChatStatus.Unauthorized && topicsByActivity(topics).map((topic) => {
+
+ {status !== ChatStatus.Unauthorized && orderedTopics.map((topic, index) => {
const starter =
topic.startedBy === undefined
? undefined
@@ -237,12 +259,12 @@ export const ChatTopicList = ({
);
- // Reuse the message action overlay so keyboard focus reveals controls without adding CSS.
+ const topicKey = chatIdentifierString(topic.id);
+ // Reuses the message action overlay styling; keyboard focus reveals the controls.
return (
0 ? 'cratis-chat-message cratis-chat-topics__row' : undefined}
- style={availableActions.length > 0 ? { position: 'relative' } : undefined}
+ key={topicKey}
+ className={availableActions.length > 0 ? 'cratis-chat-topics__row' : undefined}
>
{topicButton}
{availableActions.length > 0 && (
@@ -255,6 +277,9 @@ export const ChatTopicList = ({
style={action.icon == null ? { width: 'auto', padding: '0 0.375rem' } : undefined}
title={action.label}
aria-label={`${action.label} ${topicName}`}
+ onFocus={(event) => {
+ focusedActionRef.current = { element: event.currentTarget, topicKey, index };
+ }}
onClick={() => action.onInvoke(topic)}
>
{typeof action.icon === 'string' ? (
diff --git a/Source/Chat/for_ChatTopicList/when_a_focused_topic_action_disappears.tsx b/Source/Chat/for_ChatTopicList/when_a_focused_topic_action_disappears.tsx
new file mode 100644
index 00000000..7d10c531
--- /dev/null
+++ b/Source/Chat/for_ChatTopicList/when_a_focused_topic_action_disappears.tsx
@@ -0,0 +1,115 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+// @vitest-environment jsdom
+
+import { act, useRef, useState } from 'react';
+import { ChatTopicList } from '../ChatTopicList';
+import type { ChatTopic } from '../ChatTopic';
+import { render, unmount, type TopicListInTheDom } from './given/a_topic_list_in_the_dom';
+
+type PinnableTopic = ChatTopic & { pinned?: boolean };
+
+const Host = ({ moveFocusTo }: { moveFocusTo?: 'outside' }) => {
+ const outside = useRef(null);
+ const [topics, setTopics] = useState([
+ { id: 'topic-1', name: 'First topic', lastActivity: new Date('2026-01-03') },
+ { id: 'topic-2', name: 'Second topic', lastActivity: new Date('2026-01-02') },
+ { id: 'topic-3', name: 'Third topic', lastActivity: new Date('2026-01-01') },
+ ]);
+ return <>
+
+
+ topics={topics}
+ onOpen={() => undefined}
+ onStart={() => undefined}
+ topicActions={[
+ {
+ id: 'pin', label: 'Pin', isAvailable: (topic) => !topic.pinned,
+ onInvoke: (topic) => {
+ setTopics((current) => current.map((candidate) =>
+ candidate.id === topic.id ? { ...candidate, pinned: true } : candidate));
+ if (moveFocusTo === 'outside') outside.current!.focus();
+ },
+ },
+ {
+ id: 'archive', label: 'Archive',
+ onInvoke: (topic) => setTopics((current) => current.filter((candidate) => candidate.id !== topic.id)),
+ },
+ ]}
+ />
+ >;
+};
+
+const invoke = async (list: TopicListInTheDom, label: string) => {
+ const action = list.container.querySelector(`[aria-label="${label}"]`)!;
+ action.focus();
+ await act(async () => { action.click(); });
+};
+
+const focusedTopicName = () =>
+ document.activeElement?.classList.contains('cratis-chat-topics__topic')
+ ? document.activeElement.querySelector('.cratis-chat-topics__name')?.textContent
+ : undefined;
+
+describe('when a focused topic action disappears', () => {
+ let list: TopicListInTheDom;
+
+ afterEach(async () => { await unmount(list); });
+
+ describe('because the action is no longer available for its topic', () => {
+ beforeEach(async () => {
+ list = await render( );
+ await invoke(list, 'Pin Second topic');
+ });
+
+ it('should move focus to the button that opens the same topic', () => {
+ focusedTopicName()!.should.equal('Second topic');
+ });
+ });
+
+ describe('because the action removed its topic', () => {
+ beforeEach(async () => {
+ list = await render( );
+ await invoke(list, 'Archive Second topic');
+ });
+
+ it('should move focus to the topic that took its place', () => {
+ focusedTopicName()!.should.equal('Third topic');
+ });
+ });
+
+ describe('because the action removed the last topic in the list', () => {
+ beforeEach(async () => {
+ list = await render( );
+ await invoke(list, 'Archive Third topic');
+ });
+
+ it('should move focus to the topic before it', () => {
+ focusedTopicName()!.should.equal('Second topic');
+ });
+ });
+
+ describe('after the host moved focus elsewhere', () => {
+ beforeEach(async () => {
+ list = await render( );
+ await invoke(list, 'Pin Second topic');
+ });
+
+ it('should leave focus where the host put it', () => {
+ document.activeElement!.getAttribute('aria-label')!.should.equal('Outside');
+ });
+ });
+});
+
+describe('when rendering a topic row with actions', () => {
+ let list: TopicListInTheDom;
+
+ beforeEach(async () => { list = await render( ); });
+ afterEach(async () => { await unmount(list); });
+
+ it('should not mark the row as a chat message', () => {
+ (list.container.querySelector('li.cratis-chat-message') === null).should.equal(true);
+ list.container.querySelectorAll('li.cratis-chat-topics__row').length.should.equal(3);
+ });
+});
From ba8440ff677e4377b653728d8df43c7cbba413a9 Mon Sep 17 00:00:00 2001
From: woksin
Date: Mon, 28 Sep 2026 22:55:46 +0200
Subject: [PATCH 5/7] Record reflect-metadata as a required peer in the
lockfile
---
yarn.lock | 2 --
1 file changed, 2 deletions(-)
diff --git a/yarn.lock b/yarn.lock
index 764854af..80c462fe 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -457,8 +457,6 @@ __metadata:
peerDependenciesMeta:
pixi.js:
optional: true
- reflect-metadata:
- optional: true
tsyringe:
optional: true
languageName: unknown
From 4d7efc44f96b7c855b983554727374416c7934cb Mon Sep 17 00:00:00 2001
From: woksin
Date: Mon, 28 Sep 2026 22:55:46 +0200
Subject: [PATCH 6/7] Forget a topic action that lost focus while mounted
Focus recovery only applies when the focused action unmounts. An action that
lost focus normally no longer pulls focus back into the list when a later
update removes it. Specs also cover a re-sorted topic and the only topic.
---
Source/Chat/ChatTopicList.tsx | 10 ++++
...when_a_focused_topic_action_disappears.tsx | 58 +++++++++++++++++--
2 files changed, 62 insertions(+), 6 deletions(-)
diff --git a/Source/Chat/ChatTopicList.tsx b/Source/Chat/ChatTopicList.tsx
index d10e097d..72f8ebed 100644
--- a/Source/Chat/ChatTopicList.tsx
+++ b/Source/Chat/ChatTopicList.tsx
@@ -280,6 +280,16 @@ export const ChatTopicList = ({
onFocus={(event) => {
focusedActionRef.current = { element: event.currentTarget, topicKey, index };
}}
+ onBlur={(event) => {
+ // Focus left a still-mounted action on purpose; there is nothing to recover.
+ // A button removed while focused is already disconnected here.
+ const element = event.currentTarget;
+ queueMicrotask(() => {
+ if (focusedActionRef.current?.element === element && element.isConnected) {
+ focusedActionRef.current = null;
+ }
+ });
+ }}
onClick={() => action.onInvoke(topic)}
>
{typeof action.icon === 'string' ? (
diff --git a/Source/Chat/for_ChatTopicList/when_a_focused_topic_action_disappears.tsx b/Source/Chat/for_ChatTopicList/when_a_focused_topic_action_disappears.tsx
index 7d10c531..ee45b071 100644
--- a/Source/Chat/for_ChatTopicList/when_a_focused_topic_action_disappears.tsx
+++ b/Source/Chat/for_ChatTopicList/when_a_focused_topic_action_disappears.tsx
@@ -10,13 +10,18 @@ import { render, unmount, type TopicListInTheDom } from './given/a_topic_list_in
type PinnableTopic = ChatTopic & { pinned?: boolean };
-const Host = ({ moveFocusTo }: { moveFocusTo?: 'outside' }) => {
+const threeTopics: PinnableTopic[] = [
+ { id: 'topic-1', name: 'First topic', lastActivity: new Date('2026-01-03') },
+ { id: 'topic-2', name: 'Second topic', lastActivity: new Date('2026-01-02') },
+ { id: 'topic-3', name: 'Third topic', lastActivity: new Date('2026-01-01') },
+];
+
+let removeTopic: (id: string) => void = () => undefined;
+
+const Host = ({ moveFocusTo, initialTopics = threeTopics }: { moveFocusTo?: 'outside'; initialTopics?: PinnableTopic[] }) => {
const outside = useRef(null);
- const [topics, setTopics] = useState([
- { id: 'topic-1', name: 'First topic', lastActivity: new Date('2026-01-03') },
- { id: 'topic-2', name: 'Second topic', lastActivity: new Date('2026-01-02') },
- { id: 'topic-3', name: 'Third topic', lastActivity: new Date('2026-01-01') },
- ]);
+ const [topics, setTopics] = useState(initialTopics);
+ removeTopic = (id) => setTopics((current) => current.filter((candidate) => candidate.id !== id));
return <>
@@ -32,6 +37,12 @@ const Host = ({ moveFocusTo }: { moveFocusTo?: 'outside' }) => {
if (moveFocusTo === 'outside') outside.current!.focus();
},
},
+ {
+ id: 'bump', label: 'Bump',
+ onInvoke: (topic) => setTopics((current) => current.map((candidate) =>
+ candidate.id === topic.id ? { ...candidate, pinned: true, lastActivity: new Date('2026-02-01') } : candidate)),
+ isAvailable: (topic) => !topic.pinned,
+ },
{
id: 'archive', label: 'Archive',
onInvoke: (topic) => setTopics((current) => current.filter((candidate) => candidate.id !== topic.id)),
@@ -90,6 +101,41 @@ describe('when a focused topic action disappears', () => {
});
});
+ describe('because the action moved its topic to the top of the list', () => {
+ beforeEach(async () => {
+ list = await render( );
+ await invoke(list, 'Bump Third topic');
+ });
+
+ it('should move focus to the same topic in its new position', () => {
+ focusedTopicName()!.should.equal('Third topic');
+ });
+ });
+
+ describe('because the action removed the only topic', () => {
+ beforeEach(async () => {
+ list = await render( );
+ await invoke(list, 'Archive First topic');
+ });
+
+ it('should move focus to the new topic button', () => {
+ document.activeElement!.classList.contains('cratis-chat-topics__start').should.equal(true);
+ });
+ });
+
+ describe('after focus had already left the action', () => {
+ beforeEach(async () => {
+ list = await render( );
+ list.container.querySelector('[aria-label="Archive Second topic"]')!.focus();
+ await act(async () => { (document.activeElement as HTMLElement).blur(); });
+ await act(async () => { removeTopic('topic-2'); });
+ });
+
+ it('should not move focus back into the list', () => {
+ (document.activeElement === document.body).should.equal(true);
+ });
+ });
+
describe('after the host moved focus elsewhere', () => {
beforeEach(async () => {
list = await render( );
From 33e7053e8672bafcd67a22385c512fc4bbaacfe0 Mon Sep 17 00:00:00 2001
From: woksin
Date: Mon, 28 Sep 2026 23:13:38 +0200
Subject: [PATCH 7/7] Recover topic action focus from the last rendered order
A topic that re-sorted after its action gained focus sent recovery to the wrong
row. The fallback position now comes from the order last rendered. The focus
recovery moves into useTopicActionFocusRecovery and the action buttons into
ChatTopicActions, with no behavior change beyond that fix.
---
Source/Chat/ChatTopicActions.tsx | 55 +++++++++++++++
Source/Chat/ChatTopicList.tsx | 67 +++++--------------
...when_a_focused_topic_action_disappears.tsx | 18 +++++
Source/Chat/useTopicActionFocusRecovery.ts | 61 +++++++++++++++++
4 files changed, 149 insertions(+), 52 deletions(-)
create mode 100644 Source/Chat/ChatTopicActions.tsx
create mode 100644 Source/Chat/useTopicActionFocusRecovery.ts
diff --git a/Source/Chat/ChatTopicActions.tsx b/Source/Chat/ChatTopicActions.tsx
new file mode 100644
index 00000000..0a3c4d3d
--- /dev/null
+++ b/Source/Chat/ChatTopicActions.tsx
@@ -0,0 +1,55 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import type { FocusEventHandler } from 'react';
+import type { ChatTopic } from './ChatTopic';
+import type { ChatTopicAction } from './ChatTopicAction';
+
+/**
+ * Props for {@link ChatTopicActions}.
+ * @typeParam TTopic The topic type the list renders.
+ */
+export interface ChatTopicActionsProps {
+ /** The topic the actions act on. */
+ topic: TTopic;
+ /** The topic's visible name, used to give each action an accessible name for its topic. */
+ topicName: string | undefined;
+ /** The actions available for this topic. */
+ actions: ChatTopicAction[];
+ /** Called when an action button gains focus. */
+ onActionFocus: FocusEventHandler;
+ /** Called when an action button loses focus. */
+ onActionBlur: FocusEventHandler;
+}
+
+/**
+ * The action buttons shown beside a topic's opening button. Reuses the message action overlay,
+ * which keyboard focus reveals.
+ */
+export const ChatTopicActions = ({
+ topic,
+ topicName,
+ actions,
+ onActionFocus,
+ onActionBlur,
+}: ChatTopicActionsProps) => (
+
+ {actions.map((action) => (
+ action.onInvoke(topic)}
+ >
+ {typeof action.icon === 'string' ? (
+
+ ) : (action.icon ?? action.label)}
+
+ ))}
+
+);
diff --git a/Source/Chat/ChatTopicList.tsx b/Source/Chat/ChatTopicList.tsx
index 72f8ebed..b6f94856 100644
--- a/Source/Chat/ChatTopicList.tsx
+++ b/Source/Chat/ChatTopicList.tsx
@@ -1,16 +1,18 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
-import { useLayoutEffect, useRef, type ReactNode } from 'react';
+import { useRef, type ReactNode } from 'react';
import { PersonAvatarCircle, type BuildAvatarUrlParams } from './Kit/Avatar';
import { ChatAuthorKind } from './Kit/ChatAuthorKind';
import type { ChatAuthor } from './ChatAuthor';
import { chatIdentifierString, type ChatIdentifier } from './ChatIdentifier';
import type { ChatTopic } from './ChatTopic';
import type { ChatTopicAction } from './ChatTopicAction';
+import { ChatTopicActions } from './ChatTopicActions';
import { ChatStatus } from './ChatStatus';
import { isTopicUnnamed as defaultIsTopicUnnamed } from './isTopicUnnamed';
import { relativeTimestamp, type RelativeTimestampLabels } from './relativeTimestamp';
+import { useTopicActionFocusRecovery } from './useTopicActionFocusRecovery';
import { topicsByActivity } from './topicsByActivity';
const PlusIcon = () => (
@@ -141,27 +143,11 @@ export const ChatTopicList = ({
}: ChatTopicListProps) => {
const unnamed = isTopicUnnamed ?? defaultIsTopicUnnamed;
const listRef = useRef(null);
- // The topic action that last held focus. If invoking it hides the action or removes its topic,
- // the focused button unmounts and focus would fall to the document body.
- const focusedActionRef = useRef<{ element: HTMLElement; topicKey: string; index: number } | null>(null);
-
const orderedTopics = status === ChatStatus.Unauthorized ? [] : topicsByActivity(topics);
- const orderedTopicKeys = orderedTopics.map((topic) => chatIdentifierString(topic.id));
-
- useLayoutEffect(() => {
- const focused = focusedActionRef.current;
- if (!focused || focused.element.isConnected) return;
- focusedActionRef.current = null;
- const active = document.activeElement;
- // Respect focus the host moved on purpose, for example into a rename dialog.
- if (active && active !== document.body) return;
- const rows = Array.from(listRef.current?.children ?? []);
- const sameTopic = orderedTopicKeys.indexOf(focused.topicKey);
- const row = sameTopic >= 0 ? rows[sameTopic] : rows[Math.min(focused.index, rows.length - 1)];
- const target = row?.querySelector('.cratis-chat-topics__topic') ??
- listRef.current?.parentElement?.querySelector('.cratis-chat-topics__start');
- target?.focus();
- });
+ const { onActionFocus, onActionBlur } = useTopicActionFocusRecovery(
+ listRef,
+ orderedTopics.map((topic) => chatIdentifierString(topic.id)),
+ );
const authorFor = (authorId: ChatIdentifier): ChatAuthor =>
authorOf?.(authorId) ?? {
@@ -196,7 +182,7 @@ export const ChatTopicList = ({
)}
- {status !== ChatStatus.Unauthorized && orderedTopics.map((topic, index) => {
+ {status !== ChatStatus.Unauthorized && orderedTopics.map((topic) => {
const starter =
topic.startedBy === undefined
? undefined
@@ -268,36 +254,13 @@ export const ChatTopicList = ({
>
{topicButton}
{availableActions.length > 0 && (
-
- {availableActions.map((action) => (
- {
- focusedActionRef.current = { element: event.currentTarget, topicKey, index };
- }}
- onBlur={(event) => {
- // Focus left a still-mounted action on purpose; there is nothing to recover.
- // A button removed while focused is already disconnected here.
- const element = event.currentTarget;
- queueMicrotask(() => {
- if (focusedActionRef.current?.element === element && element.isConnected) {
- focusedActionRef.current = null;
- }
- });
- }}
- onClick={() => action.onInvoke(topic)}
- >
- {typeof action.icon === 'string' ? (
-
- ) : (action.icon ?? action.label)}
-
- ))}
-
+
+ topic={topic}
+ topicName={topicName}
+ actions={availableActions}
+ onActionFocus={onActionFocus(topicKey)}
+ onActionBlur={onActionBlur}
+ />
)}
);
diff --git a/Source/Chat/for_ChatTopicList/when_a_focused_topic_action_disappears.tsx b/Source/Chat/for_ChatTopicList/when_a_focused_topic_action_disappears.tsx
index ee45b071..64f40af9 100644
--- a/Source/Chat/for_ChatTopicList/when_a_focused_topic_action_disappears.tsx
+++ b/Source/Chat/for_ChatTopicList/when_a_focused_topic_action_disappears.tsx
@@ -17,11 +17,16 @@ const threeTopics: PinnableTopic[] = [
];
let removeTopic: (id: string) => void = () => undefined;
+let addNewestTopic: () => void = () => undefined;
const Host = ({ moveFocusTo, initialTopics = threeTopics }: { moveFocusTo?: 'outside'; initialTopics?: PinnableTopic[] }) => {
const outside = useRef(null);
const [topics, setTopics] = useState(initialTopics);
removeTopic = (id) => setTopics((current) => current.filter((candidate) => candidate.id !== id));
+ addNewestTopic = () => setTopics((current) => [
+ ...current,
+ { id: 'topic-0', name: 'Newest topic', lastActivity: new Date('2026-03-01') },
+ ]);
return <>
@@ -123,6 +128,19 @@ describe('when a focused topic action disappears', () => {
});
});
+ describe('because the action removed its topic after the list re-sorted', () => {
+ beforeEach(async () => {
+ list = await render( );
+ list.container.querySelector('[aria-label="Archive Second topic"]')!.focus();
+ await act(async () => { addNewestTopic(); });
+ await act(async () => { (document.activeElement as HTMLButtonElement).click(); });
+ });
+
+ it('should move focus to the topic that took its place in the current order', () => {
+ focusedTopicName()!.should.equal('Third topic');
+ });
+ });
+
describe('after focus had already left the action', () => {
beforeEach(async () => {
list = await render( );
diff --git a/Source/Chat/useTopicActionFocusRecovery.ts b/Source/Chat/useTopicActionFocusRecovery.ts
new file mode 100644
index 00000000..6edfc59c
--- /dev/null
+++ b/Source/Chat/useTopicActionFocusRecovery.ts
@@ -0,0 +1,61 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { useLayoutEffect, useRef, type FocusEvent, type RefObject } from 'react';
+
+/**
+ * Keeps keyboard focus in a topic list when a focused topic action disappears, because
+ * invoking it made the action unavailable or removed its topic. Without this, the unmounted
+ * button drops focus to the document body.
+ *
+ * Focus moves to the same topic's opening button, or, when the topic is gone, to the topic that
+ * took its place in the order last rendered, then to the list's New topic button. Focus that the
+ * host already moved elsewhere, such as into a rename dialog, is left alone, and an action that
+ * lost focus while still mounted is forgotten.
+ * @param listRef The list whose rows each hold one topic.
+ * @param orderedTopicKeys The topic keys in rendered row order.
+ * @returns Focus and blur handlers to attach to each topic action button.
+ */
+export const useTopicActionFocusRecovery = (
+ listRef: RefObject,
+ orderedTopicKeys: readonly string[],
+) => {
+ const focusedActionRef = useRef<{ element: HTMLElement; topicKey: string } | null>(null);
+ // The row order from the last commit, so a removed topic's position reflects any re-sorting
+ // that happened after its action gained focus.
+ const committedTopicKeysRef = useRef([]);
+
+ useLayoutEffect(() => {
+ const previousTopicKeys = committedTopicKeysRef.current;
+ committedTopicKeysRef.current = orderedTopicKeys;
+ const focused = focusedActionRef.current;
+ if (!focused || focused.element.isConnected) return;
+ focusedActionRef.current = null;
+ const active = document.activeElement;
+ if (active && active !== document.body) return;
+ const rows = Array.from(listRef.current?.children ?? []);
+ const sameTopic = orderedTopicKeys.indexOf(focused.topicKey);
+ const row = sameTopic >= 0
+ ? rows[sameTopic]
+ : rows[Math.min(Math.max(previousTopicKeys.indexOf(focused.topicKey), 0), rows.length - 1)];
+ const target = row?.querySelector('.cratis-chat-topics__topic') ??
+ listRef.current?.parentElement?.querySelector('.cratis-chat-topics__start');
+ target?.focus();
+ });
+
+ return {
+ onActionFocus: (topicKey: string) => (event: FocusEvent) => {
+ focusedActionRef.current = { element: event.currentTarget, topicKey };
+ },
+ onActionBlur: (event: FocusEvent) => {
+ // A button removed while focused is already disconnected here, so only a deliberate
+ // focus change on a still-mounted action clears the tracking.
+ const element = event.currentTarget;
+ queueMicrotask(() => {
+ if (focusedActionRef.current?.element === element && element.isConnected) {
+ focusedActionRef.current = null;
+ }
+ });
+ },
+ };
+};