Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions Documentation/Chat/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +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. |
| `renderMessageExtra` | `(message) => ReactNode` | — | Content rendered under a message's body, such as reactions or a failed-reply notice. |
| `renderHeaderActions` | `(openTopic) => ReactNode` | — | Content rendered in the header between the title and the close button, such as a rename control. |
| `actions`, `quickReply` | `ChatMessageAction[]`, `boolean` | —, `true` | Message actions and quick reply; see [Message actions](./message-actions.md). |
| `topicActions` | `ChatTopicAction<TTopic>[]` | — | 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. |
Expand Down Expand Up @@ -184,6 +186,25 @@ The built-in avatar shows initials on a color derived from the id. It shows an i
/>
```

## Add your own content to messages and the header

The chat family stays small, so richer per-message features belong to your application. `renderMessageExtra` renders content under each message's body: reactions, a notice for a reply that failed, or anything else keyed off your own message type. `ChatSidebar` forwards it to the conversation. `renderHeaderActions` renders content in the sidebar header, next to the title; it receives the open topic, or `undefined` while the topic list is shown. Returning `null` renders nothing, and the chat's markup is unchanged when you leave both unset.

```tsx
type AppMessage = ChatMessage & { failed?: boolean; reactions?: string[] };

<ChatSidebar<AppMessage>
renderMessageExtra={(message) =>
message.failed ? <p role='status'>The agent could not answer.</p> :
message.reactions?.length ? <MyReactions reactions={message.reactions} /> : null}
renderHeaderActions={(openTopic) =>
openTopic ? <MyRenameButton topic={openTopic} /> : null}
/* ...the rest of your props */
/>
```

`MyReactions` and `MyRenameButton` are your own components. The content you render keeps its own semantics and styling; give interactive controls accessible names.

## Rendering a message body directly

`ChatConversation` uses `ChatMessageBody` internally. Import it directly when an application-owned message list, notification, or transcript needs the same mention rendering without the rest of the conversation UI.
Expand Down
4 changes: 2 additions & 2 deletions Documentation/Common/action-menubar.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The root is a `div` with `role='toolbar'` and `data-cratis-part='root'`. Pass `a

Each action renders as a native ghost `Button`. By default, `focusMode={ToolbarFocusMode.Arrows}` moves between actions with Left/Right (reversed in RTL), and Home/End move to the first/last available action. Navigation follows DOM order, skips disabled, hidden, and inert actions, and does not wrap. Every action remains its own Tab stop, preserving Tab and Shift+Tab behavior.

Import `ToolbarFocusMode` from `@cratis/components/Common` and set `focusMode={ToolbarFocusMode.None}` to disable toolbar key handling while retaining native Tab stops. Widgets in templates keep their own keys. Child handlers and `pt.root.onKeyDown` can prevent navigation. A single-Tab-stop mode is planned ([#353](https://github.com/Cratis/Components/issues/353)).
Import `ToolbarFocusMode` from `@cratis/components/Common`. Set `focusMode={ToolbarFocusMode.SingleTabStop}` to give the menubar's own actions one Tab stop that follows the arrow keys and returns to the last focused action; template content keeps its own Tab stop. `pt` applies to every action, so setting `pt.root.tabIndex` gives every action that tab index and turns the single Tab stop off. Set `focusMode={ToolbarFocusMode.None}` to disable toolbar key handling while retaining native Tab stops. Widgets in templates keep their own keys. Child handlers and `pt.root.onKeyDown` can prevent navigation. `SingleTabStop` becomes the default in the next major release ([#353](https://github.com/Cratis/Components/issues/353)).

An item's visible `label` is also its accessible name, and `ActionMenuItem` has no separate `aria-label`, so give every item a `label` or use `template` for an icon-only action that names itself.

Expand All @@ -52,7 +52,7 @@ When `template` is present, `ActionMenubar` renders its result directly instead
| Prop | Type | Required | Purpose |
| ------------ | ------------------ | -------- | ---------------------------------------------------------- |
| `model` | `ActionMenuItem[]` | Yes | Actions rendered from left to right. |
| `focusMode` | `ToolbarFocusMode` | No | Keyboard focus mode; defaults to `Arrows`. |
| `focusMode` | `ToolbarFocusMode` | No | `Arrows` (default), `SingleTabStop`, or `None`. |
| `className` | `string` | No | Extra class name for the toolbar root. |
| `aria-label` | `string` | No | Accessible name for the toolbar. |
| `pt` | `ButtonParts` | No | Part attributes applied to every non-template action button. |
Expand Down
4 changes: 4 additions & 0 deletions Documentation/DataTables/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ The DataTables module provides a semantic local-array table plus specialized Arc

`DataTableCore` accepts a `status` from `DataTableStatus` (`Ready`, `Loading`, `Failed`, or `Unauthorized`) (import `DataTableStatus` from `@cratis/components/DataTables`); it defaults to `Ready`. For a loading table with no rows, it renders a status row; with rows, it retains them and marks the table busy. Failed or unauthorized states render an alert row. Override the text with `loadingMessage`, `failureMessage`, and `unauthorizedMessage` or configure `CratisComponentsProvider`'s `messages.dataTable`.

`DataTableCore` keeps its own sort, column filters, and search text unless you control them. Pass `sort` with `onSortChange`, `filters` with `onFilter`, and `globalFilter` with `onGlobalFilterChange` to own that state, for example to persist it or to send it to the server. `sort={null}` means not sorted; leaving a prop undefined lets the table keep that piece of state itself, and the change callbacks report every change either way. Import `DataTableSort`, `DataTableSortDirection`, and `DataTableRowProcessing` from `@cratis/components/DataTables`.

When the rows already reflect that state, because your query filtered and sorted them on the server, set `rowProcessing={DataTableRowProcessing.None}` so the table renders the rows as given instead of filtering and sorting them a second time. The default, `DataTableRowProcessing.Loaded`, filters and sorts the rows the table was given. The bound query tables do not take these props yet; server-side sorting and filtering for them is tracked in [#178](https://github.com/Cratis/Components/issues/178).

## When to Use

Use DataTableCore when:
Expand Down
3 changes: 2 additions & 1 deletion Documentation/Toolbar/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import { Toolbar, ToolbarButton } from '@cratis/components/Toolbar';

- The root renders `role='toolbar'`, `aria-orientation`, and an accessible name. The name falls back to the provider's `messages.toolbar.label`, then `Tools`; pass `aria-label` or `aria-labelledby` to name each toolbar.
- `focusMode` defaults to `ToolbarFocusMode.Arrows`: Up/Down move between tools in the default vertical orientation; a horizontal toolbar uses Left/Right (reversed in RTL). Home/End move to the first/last available tool in DOM order. Navigation skips disabled, hidden, and inert tools and stops at either end; it does not wrap. Each tool remains its own Tab stop, so Tab and Shift+Tab work as before.
- Set `focusMode={ToolbarFocusMode.None}` to keep native Tab behavior without toolbar key handling. Import `ToolbarFocusMode` from `@cratis/components/Toolbar`. A single-Tab-stop mode is planned ([#353](https://github.com/Cratis/Components/issues/353)).
- Set `focusMode={ToolbarFocusMode.SingleTabStop}` for the WAI-ARIA toolbar pattern: the toolbar's own tools (`ToolbarButton`, folder and fan-out triggers) share one Tab stop, which starts at the first available tool, follows the arrow keys, and returns to the last focused tool. When that tool is disabled, hidden, or removed, the Tab stop moves to the first available tool. A tool rendered outside the toolbar's own markup, such as through a portal or inside a nested element with `role="toolbar"`, keeps its own Tab stop. Tools in an open folder or fan-out panel belong to the same Tab stop, so you reach them with the arrow keys rather than Tab. Controls you place in the toolbar yourself, widgets such as inputs and sliders, a `ToolbarButton` with an explicit `pt.root.tabIndex`, and a folder or fan-out trigger with an explicit `pt.trigger.tabIndex` keep their own Tab stop; with a tab index of 0 or higher the arrow keys still reach them, while `-1` removes the tool from both Tab and arrow-key navigation. Until the toolbar has chosen its active tool (for example, in server-rendered markup before hydration), every tool keeps its own Tab stop.
- Set `focusMode={ToolbarFocusMode.None}` to keep native Tab behavior without toolbar key handling. Import `ToolbarFocusMode` from `@cratis/components/Toolbar`. `SingleTabStop` becomes the default in the next major release ([#353](https://github.com/Cratis/Components/issues/353)).
- Arrows and Home/End inside inputs, sliders, selects, and custom widgets retain their own behavior. Nested toolbars handle their own keys. Key handlers on a tool or `pt.root` can cancel navigation with `preventDefault()` or `stopPropagation()`.
- `title` is required on `ToolbarButton` and `ToolbarFolder`, and `tooltip` on `ToolbarFanOutItem`. That text becomes the button's `aria-label` and its tooltip, which appears on hover and on keyboard focus.
- Passing `active` sets `aria-pressed` to `true` or `false` as well as the visual state (`data-active`, `data-selected`); omitting `active` leaves it unset. See [Active state](active-state.md).
Expand Down
10 changes: 10 additions & 0 deletions Source/Chat/ChatConversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@ export interface ChatConversationProps<TMessage extends ChatMessage = ChatMessag
*/
renderAuthorName?: (authorId: ChatIdentifier, author: ChatAuthor) => ReactNode;

/**
* Renders host content under a message's body, for example reactions or a failed-reply notice.
* Nothing extra is rendered when it returns null or undefined.
* @param message The message being rendered.
* @returns What to render under the message body.
*/
renderMessageExtra?: (message: TMessage) => ReactNode;

/** The host's own actions, offered as buttons on every message each is available for. */
actions?: ChatMessageAction<TMessage>[];

Expand Down Expand Up @@ -228,6 +236,7 @@ export const ChatConversation = <TMessage extends ChatMessage = ChatMessage>({
authorOf,
renderAvatar,
renderAuthorName,
renderMessageExtra,
actions,
mentionCandidates,
resolveMentionCandidates,
Expand Down Expand Up @@ -377,6 +386,7 @@ export const ChatConversation = <TMessage extends ChatMessage = ChatMessage>({
</div>
)}
</div>
{renderMessageExtra?.(message)}
{showTimestamp && (
<span className='cratis-chat-message__time'>
{relativeTimestamp(
Expand Down
12 changes: 11 additions & 1 deletion Source/Chat/ChatSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { useEffect, useRef, useState } from 'react';
import type { ButtonHTMLAttributes, HTMLAttributes } from 'react';
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { Modal, ModalOverlay } from 'react-aria-components';
import type { ChatConversationLabels, ChatConversationProps } from './ChatConversation';
Expand Down Expand Up @@ -89,6 +89,14 @@ export interface ChatSidebarProps<
ChatConversationProps<TMessage>,
'messages' | 'onSendMessage' | 'labels' | 'className' | 'status'
> {
/**
* Renders host content in the header, between the title and the close button, for example a
* rename control for the open topic. Nothing is rendered when it returns null or undefined.
* @param openTopic The open topic, or undefined while the topic list is shown.
* @returns What to render in the header.
*/
renderHeaderActions?: (openTopic: TTopic | undefined) => ReactNode;

/** Whether the sidebar is open. */
open: boolean;

Expand Down Expand Up @@ -230,6 +238,7 @@ export const ChatSidebar = <
topics,
messages,
topicActions,
renderHeaderActions,
topicsStatus,
messagesStatus,
selectedTopicId,
Expand Down Expand Up @@ -379,6 +388,7 @@ export const ChatSidebar = <
>
{title}
</h2>
{renderHeaderActions?.(openTopic)}
<button
{...pt?.close}
type='button'
Expand Down
42 changes: 42 additions & 0 deletions Source/Chat/for_ChatConversation/when_rendering_message_extras.ts
Original file line number Diff line number Diff line change
@@ -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 { ChatConversation } from '../ChatConversation';
import type { ChatMessage } from '../ChatMessage';
import { render, unmount, type ConversationInTheDom } from './given/a_conversation_in_the_dom';

type FailableMessage = ChatMessage & { failed?: boolean };

const messages: FailableMessage[] = [
{ id: 'message-1', topicId: 'topic-1', authorId: 'user-1', body: 'Answered', timestamp: new Date('2026-01-01') },
{ id: 'message-2', topicId: 'topic-1', authorId: 'agent-1', body: 'Could not answer', timestamp: new Date('2026-01-02'), failed: true },
];

describe('when rendering message extras', () => {
let conversation: ConversationInTheDom;

beforeEach(async () => {
conversation = await render(createElement(ChatConversation<FailableMessage>, {
messages,
onSendMessage: () => undefined,
renderMessageExtra: (message: FailableMessage) => message.failed
? createElement('p', { className: 'failed-notice' }, `Failed: ${message.id}`)
: null,
}));
});

afterEach(async () => { await unmount(conversation); });

it('should render the extra content under the body of the message it returns content for', () => {
const notice = conversation.container.querySelector('.failed-notice')!;
notice.textContent!.should.equal('Failed: message-2');
notice.closest('.cratis-chat-message__content')!.textContent!.should.contain('Could not answer');
});

it('should render nothing extra for messages it returns null for', () => {
conversation.container.querySelectorAll('.failed-notice').length.should.equal(1);
});
});
49 changes: 49 additions & 0 deletions Source/Chat/for_ChatSidebar/when_rendering_header_actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// 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 rendering header actions', () => {
let sidebar: ChatSidebarInTheDom;
let calls: (ChatTopic | undefined)[];

beforeEach(async () => {
calls = [];
sidebar = await render(createElement(ChatSidebar, {
open: true,
onClose: () => {},
topics: [topic],
messages: [],
onSendMessage: () => {},
renderHeaderActions: (openTopic: ChatTopic | undefined) => {
calls.push(openTopic);
return openTopic
? createElement('button', { type: 'button', className: 'rename-topic' }, `Rename ${openTopic.name}`)
: null;
},
}));
});

afterEach(async () => { await unmount(sidebar); });

it('should call it without a topic while the topic list is shown and render nothing extra', () => {
(calls.at(-1) === undefined).should.be.true;
(document.querySelector('.rename-topic') === null).should.be.true;
});

it('should render the content between the title and the close button for the open topic', async () => {
await click(document.querySelector<HTMLButtonElement>('.cratis-chat-topics__topic')!);
calls.at(-1)!.should.equal(topic);
const rename = document.querySelector('.rename-topic')!;
rename.textContent!.should.equal('Rename Example topic');
rename.previousElementSibling!.classList.contains('cratis-chat-sidebar__title').should.be.true;
rename.nextElementSibling!.classList.contains('cratis-chat-sidebar__close').should.be.true;
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,9 @@ describe('when dismissal is configured on a command dialog', () => {
),
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
// Wait for the dialog itself rather than for a fixed delay.
await vi.waitFor(() => {
if (!document.querySelector('[role="dialog"]')) throw new Error('The dialog has not opened yet.');
});
};

Expand Down
24 changes: 24 additions & 0 deletions Source/Common/ActionMenubar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,30 @@ export const Default: Story = {
},
};

/** One Tab stop for the actions: arrows move it, and Tab leaves the menubar from the last focused action. */
export const SingleTabStop: Story = {
args: { focusMode: ToolbarFocusMode.SingleTabStop },
render: (args) => (
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<button type='button'>Before</button>
<ActionMenubar {...args} />
<button type='button'>After</button>
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
canvas.getByRole('button', { name: 'Before' }).focus();
await userEvent.tab();
await expect(canvas.getByRole('button', { name: 'New' })).toHaveFocus();
await userEvent.keyboard('{ArrowRight}');
await expect(canvas.getByRole('button', { name: 'Save' })).toHaveFocus();
await userEvent.tab();
await expect(canvas.getByRole('button', { name: 'After' })).toHaveFocus();
await userEvent.tab({ shift: true });
await expect(canvas.getByRole('button', { name: 'Save' })).toHaveFocus();
},
};

export const ArrowsWithWidgets: Story = {
args: {
focusMode: ToolbarFocusMode.Arrows,
Expand Down
Loading
Loading