@jupyter/chat 0.24.0 → 0.24.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/__tests__/writing-indicator.spec.d.ts +1 -0
- package/lib/__tests__/writing-indicator.spec.js +60 -0
- package/lib/components/messages/message-renderer.js +8 -1
- package/lib/components/writing-indicator.js +11 -1
- package/package.json +1 -1
- package/src/__tests__/writing-indicator.spec.tsx +78 -0
- package/src/components/messages/message-renderer.tsx +11 -1
- package/src/components/writing-indicator.tsx +12 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
import React, { act } from 'react';
|
|
6
|
+
import { createRoot } from 'react-dom/client';
|
|
7
|
+
// React 18 asks test environments to declare themselves, otherwise every
|
|
8
|
+
// `act` call warns.
|
|
9
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
import { WritingIndicator } from '../components/writing-indicator';
|
|
11
|
+
const alice = { username: 'a', name: 'Alice', display_name: 'Alice' };
|
|
12
|
+
const bob = { username: 'b', name: 'Bob', display_name: 'Bob' };
|
|
13
|
+
const writer = (user) => ({ user });
|
|
14
|
+
describe('WritingIndicator accessibility', () => {
|
|
15
|
+
let container;
|
|
16
|
+
let root;
|
|
17
|
+
const render = (writers) => {
|
|
18
|
+
act(() => {
|
|
19
|
+
root.render(React.createElement(WritingIndicator, { writers: writers }));
|
|
20
|
+
});
|
|
21
|
+
return container.querySelector('.jp-chat-writers');
|
|
22
|
+
};
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
container = document.createElement('div');
|
|
25
|
+
document.body.appendChild(container);
|
|
26
|
+
root = createRoot(container);
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
act(() => root.unmount());
|
|
30
|
+
container.remove();
|
|
31
|
+
});
|
|
32
|
+
it('is a polite live region, so a reply on its way is announced', () => {
|
|
33
|
+
// The indicator is the only signal that someone is replying. Without a
|
|
34
|
+
// live region it is visible text that assistive technology never speaks.
|
|
35
|
+
const el = render([]);
|
|
36
|
+
expect(el.getAttribute('role')).toBe('status');
|
|
37
|
+
expect(el.getAttribute('aria-live')).toBe('polite');
|
|
38
|
+
});
|
|
39
|
+
it('reads the whole phrase rather than a fragment of it', () => {
|
|
40
|
+
// Without aria-atomic, a change to part of the text can be announced on
|
|
41
|
+
// its own, so a reader hears a bare name with no context.
|
|
42
|
+
expect(render([]).getAttribute('aria-atomic')).toBe('true');
|
|
43
|
+
});
|
|
44
|
+
it('announces who is writing', () => {
|
|
45
|
+
expect(render([writer(alice)]).textContent).toContain('Alice is typing');
|
|
46
|
+
});
|
|
47
|
+
it('announces every writer, not just the first', () => {
|
|
48
|
+
const el = render([writer(alice), writer(bob)]);
|
|
49
|
+
expect(el.textContent).toContain('Alice');
|
|
50
|
+
expect(el.textContent).toContain('Bob');
|
|
51
|
+
});
|
|
52
|
+
it('says nothing when nobody is writing', () => {
|
|
53
|
+
var _a;
|
|
54
|
+
// The container is always rendered to reserve space, and holds a
|
|
55
|
+
// non-breaking space as a placeholder. That placeholder must not be
|
|
56
|
+
// announced as if it were a message.
|
|
57
|
+
const text = ((_a = render([]).textContent) !== null && _a !== void 0 ? _a : '').replace(/ /g, '').trim();
|
|
58
|
+
expect(text).toBe('');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -12,6 +12,11 @@ import { useChatContext } from '../../context';
|
|
|
12
12
|
import { replaceMentionToSpan } from '../../utils';
|
|
13
13
|
const RENDERED_CLASS = 'jp-chat-rendered-message';
|
|
14
14
|
const DEFAULT_MIME_TYPE = 'text/markdown';
|
|
15
|
+
/**
|
|
16
|
+
* The class name added to an output area widget. This is required to display cell
|
|
17
|
+
* output as a message in the chat.
|
|
18
|
+
*/
|
|
19
|
+
const OUTPUT_AREA_CLASS = 'jp-OutputArea';
|
|
15
20
|
/**
|
|
16
21
|
* The message renderer base component.
|
|
17
22
|
*/
|
|
@@ -22,6 +27,7 @@ function MessageRendererBase(props) {
|
|
|
22
27
|
const [renderedContent, setRenderedContent] = useState(null);
|
|
23
28
|
// Allow edition only on text messages.
|
|
24
29
|
const [canEdit, setCanEdit] = useState(false);
|
|
30
|
+
const [isOutputArea, setIsOutputArea] = useState(false);
|
|
25
31
|
// Each element is a two-tuple with the structure [codeToolbarRoot, codeToolbarProps].
|
|
26
32
|
const [codeToolbarDefns, setCodeToolbarDefns] = useState([]);
|
|
27
33
|
useEffect(() => {
|
|
@@ -86,6 +92,7 @@ function MessageRendererBase(props) {
|
|
|
86
92
|
// never been attached, only the node.
|
|
87
93
|
// This is necessary to render latex.
|
|
88
94
|
MessageLoop.sendMessage(renderer, Widget.Msg.AfterAttach);
|
|
95
|
+
setIsOutputArea(!isMarkdownRenderer);
|
|
89
96
|
// Add code toolbar if markdown has been rendered.
|
|
90
97
|
if (isMarkdownRenderer) {
|
|
91
98
|
const newCodeToolbarDefns = [];
|
|
@@ -110,7 +117,7 @@ function MessageRendererBase(props) {
|
|
|
110
117
|
renderContent();
|
|
111
118
|
}, [message.body, message.mime_model, message.mentions, rmRegistry]);
|
|
112
119
|
return (React.createElement(React.Fragment, null,
|
|
113
|
-
renderedContent && (React.createElement("div", { className: RENDERED_CLASS
|
|
120
|
+
renderedContent && (React.createElement("div", { className: `${RENDERED_CLASS}${isOutputArea ? ` ${OUTPUT_AREA_CLASS}` : ''}`, ref: node => node && node.replaceChildren(renderedContent) })),
|
|
114
121
|
React.createElement(MessageToolbar, { edit: canEdit ? props.edit : undefined, delete: props.delete }),
|
|
115
122
|
// Render a `CodeToolbar` element underneath each code block.
|
|
116
123
|
// We use ReactDOM.createPortal() so each `CodeToolbar` element is able
|
|
@@ -41,7 +41,17 @@ export function WritingIndicator(props) {
|
|
|
41
41
|
const trans = useTranslator();
|
|
42
42
|
// Always render the container to reserve space, even if no writers
|
|
43
43
|
const writersText = writers.length > 0 ? formatWritersText(writers, trans) : '';
|
|
44
|
-
return (React.createElement(Box, { className: WRITERS_ELEMENT_CLASSNAME,
|
|
44
|
+
return (React.createElement(Box, { className: WRITERS_ELEMENT_CLASSNAME,
|
|
45
|
+
// The indicator already says something useful, "Alice is typing..." or
|
|
46
|
+
// "Jupyternaut is running `ripgrep`", but only on screen. Announcing it
|
|
47
|
+
// politely means a screen reader user learns a reply is coming without
|
|
48
|
+
// being interrupted mid-sentence.
|
|
49
|
+
//
|
|
50
|
+
// The region is the container rather than the text, so it is present in
|
|
51
|
+
// the accessibility tree before a writer appears and the change is
|
|
52
|
+
// announced. `aria-atomic` keeps the phrase together: without it a name
|
|
53
|
+
// change alone can be read out on its own, stripped of its context.
|
|
54
|
+
role: "status", "aria-live": "polite", "aria-atomic": "true", sx: {
|
|
45
55
|
...props.sx,
|
|
46
56
|
minHeight: '16px'
|
|
47
57
|
} },
|
package/package.json
CHANGED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { act } from 'react';
|
|
7
|
+
import { createRoot, Root } from 'react-dom/client';
|
|
8
|
+
|
|
9
|
+
// React 18 asks test environments to declare themselves, otherwise every
|
|
10
|
+
// `act` call warns.
|
|
11
|
+
(
|
|
12
|
+
globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }
|
|
13
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
|
+
|
|
15
|
+
import { WritingIndicator } from '../components/writing-indicator';
|
|
16
|
+
import { IChatModel } from '../model';
|
|
17
|
+
import { IUser } from '../types';
|
|
18
|
+
|
|
19
|
+
const alice: IUser = { username: 'a', name: 'Alice', display_name: 'Alice' };
|
|
20
|
+
const bob: IUser = { username: 'b', name: 'Bob', display_name: 'Bob' };
|
|
21
|
+
|
|
22
|
+
const writer = (user: IUser): IChatModel.IWriter =>
|
|
23
|
+
({ user }) as IChatModel.IWriter;
|
|
24
|
+
|
|
25
|
+
describe('WritingIndicator accessibility', () => {
|
|
26
|
+
let container: HTMLDivElement;
|
|
27
|
+
let root: Root;
|
|
28
|
+
|
|
29
|
+
const render = (writers: IChatModel.IWriter[]) => {
|
|
30
|
+
act(() => {
|
|
31
|
+
root.render(<WritingIndicator writers={writers} />);
|
|
32
|
+
});
|
|
33
|
+
return container.querySelector('.jp-chat-writers') as HTMLElement;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
container = document.createElement('div');
|
|
38
|
+
document.body.appendChild(container);
|
|
39
|
+
root = createRoot(container);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
act(() => root.unmount());
|
|
44
|
+
container.remove();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('is a polite live region, so a reply on its way is announced', () => {
|
|
48
|
+
// The indicator is the only signal that someone is replying. Without a
|
|
49
|
+
// live region it is visible text that assistive technology never speaks.
|
|
50
|
+
const el = render([]);
|
|
51
|
+
expect(el.getAttribute('role')).toBe('status');
|
|
52
|
+
expect(el.getAttribute('aria-live')).toBe('polite');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('reads the whole phrase rather than a fragment of it', () => {
|
|
56
|
+
// Without aria-atomic, a change to part of the text can be announced on
|
|
57
|
+
// its own, so a reader hears a bare name with no context.
|
|
58
|
+
expect(render([]).getAttribute('aria-atomic')).toBe('true');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('announces who is writing', () => {
|
|
62
|
+
expect(render([writer(alice)]).textContent).toContain('Alice is typing');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('announces every writer, not just the first', () => {
|
|
66
|
+
const el = render([writer(alice), writer(bob)]);
|
|
67
|
+
expect(el.textContent).toContain('Alice');
|
|
68
|
+
expect(el.textContent).toContain('Bob');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('says nothing when nobody is writing', () => {
|
|
72
|
+
// The container is always rendered to reserve space, and holds a
|
|
73
|
+
// non-breaking space as a placeholder. That placeholder must not be
|
|
74
|
+
// announced as if it were a message.
|
|
75
|
+
const text = (render([]).textContent ?? '').replace(/ /g, '').trim();
|
|
76
|
+
expect(text).toBe('');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -19,6 +19,12 @@ import { replaceMentionToSpan } from '../../utils';
|
|
|
19
19
|
const RENDERED_CLASS = 'jp-chat-rendered-message';
|
|
20
20
|
const DEFAULT_MIME_TYPE = 'text/markdown';
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The class name added to an output area widget. This is required to display cell
|
|
24
|
+
* output as a message in the chat.
|
|
25
|
+
*/
|
|
26
|
+
const OUTPUT_AREA_CLASS = 'jp-OutputArea';
|
|
27
|
+
|
|
22
28
|
/**
|
|
23
29
|
* The type of the props for the MessageRenderer component.
|
|
24
30
|
*/
|
|
@@ -56,6 +62,8 @@ function MessageRendererBase(props: MessageRendererProps): JSX.Element {
|
|
|
56
62
|
// Allow edition only on text messages.
|
|
57
63
|
const [canEdit, setCanEdit] = useState<boolean>(false);
|
|
58
64
|
|
|
65
|
+
const [isOutputArea, setIsOutputArea] = useState<boolean>(false);
|
|
66
|
+
|
|
59
67
|
// Each element is a two-tuple with the structure [codeToolbarRoot, codeToolbarProps].
|
|
60
68
|
const [codeToolbarDefns, setCodeToolbarDefns] = useState<
|
|
61
69
|
Array<[HTMLDivElement, CodeToolbarProps]>
|
|
@@ -129,6 +137,8 @@ function MessageRendererBase(props: MessageRendererProps): JSX.Element {
|
|
|
129
137
|
// This is necessary to render latex.
|
|
130
138
|
MessageLoop.sendMessage(renderer, Widget.Msg.AfterAttach);
|
|
131
139
|
|
|
140
|
+
setIsOutputArea(!isMarkdownRenderer);
|
|
141
|
+
|
|
132
142
|
// Add code toolbar if markdown has been rendered.
|
|
133
143
|
if (isMarkdownRenderer) {
|
|
134
144
|
const newCodeToolbarDefns: [HTMLDivElement, CodeToolbarProps][] = [];
|
|
@@ -164,7 +174,7 @@ function MessageRendererBase(props: MessageRendererProps): JSX.Element {
|
|
|
164
174
|
<>
|
|
165
175
|
{renderedContent && (
|
|
166
176
|
<div
|
|
167
|
-
className={RENDERED_CLASS}
|
|
177
|
+
className={`${RENDERED_CLASS}${isOutputArea ? ` ${OUTPUT_AREA_CLASS}` : ''}`}
|
|
168
178
|
ref={node => node && node.replaceChildren(renderedContent)}
|
|
169
179
|
/>
|
|
170
180
|
)}
|
|
@@ -76,6 +76,18 @@ export function WritingIndicator(
|
|
|
76
76
|
return (
|
|
77
77
|
<Box
|
|
78
78
|
className={WRITERS_ELEMENT_CLASSNAME}
|
|
79
|
+
// The indicator already says something useful, "Alice is typing..." or
|
|
80
|
+
// "Jupyternaut is running `ripgrep`", but only on screen. Announcing it
|
|
81
|
+
// politely means a screen reader user learns a reply is coming without
|
|
82
|
+
// being interrupted mid-sentence.
|
|
83
|
+
//
|
|
84
|
+
// The region is the container rather than the text, so it is present in
|
|
85
|
+
// the accessibility tree before a writer appears and the change is
|
|
86
|
+
// announced. `aria-atomic` keeps the phrase together: without it a name
|
|
87
|
+
// change alone can be read out on its own, stripped of its context.
|
|
88
|
+
role="status"
|
|
89
|
+
aria-live="polite"
|
|
90
|
+
aria-atomic="true"
|
|
79
91
|
sx={{
|
|
80
92
|
...props.sx,
|
|
81
93
|
minHeight: '16px'
|