Skip to content

Commit 4b7f380

Browse files
committed
Display suggestions popover when at-mentioning
1 parent d0aed76 commit 4b7f380

File tree

6 files changed

+256
-11
lines changed

6 files changed

+256
-11
lines changed

src/sidebar/components/Annotation/AnnotationEditor.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ function AnnotationEditor({
179179
label={isReplyAnno ? 'Enter reply' : 'Enter comment'}
180180
text={text}
181181
onEditText={onEditText}
182+
atMentionsEnabled={store.isFeatureEnabled('at_mentions')}
182183
/>
183184
<TagEditor
184185
onAddTag={onAddTag}

src/sidebar/components/Annotation/test/AnnotationEditor-test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ describe('AnnotationEditor', () => {
6161
setDefault: sinon.stub(),
6262
removeDraft: sinon.stub(),
6363
removeAnnotations: sinon.stub(),
64+
isFeatureEnabled: sinon.stub().returns(false),
6465
};
6566

6667
$imports.$mock(mockImportedComponents());

src/sidebar/components/MarkdownEditor.tsx

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { Button, IconButton, Link } from '@hypothesis/frontend-shared';
1+
import {
2+
Button,
3+
IconButton,
4+
Link,
5+
Popover,
6+
useSyncedRef,
7+
} from '@hypothesis/frontend-shared';
28
import {
39
EditorLatexIcon,
410
EditorQuoteIcon,
@@ -16,6 +22,7 @@ import classnames from 'classnames';
1622
import type { Ref, JSX } from 'preact';
1723
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
1824

25+
import { ListenerCollection } from '../../shared/listener-collection';
1926
import { isMacOS } from '../../shared/user-agent';
2027
import {
2128
LinkType,
@@ -24,6 +31,7 @@ import {
2431
toggleSpanStyle,
2532
} from '../markdown-commands';
2633
import type { EditorState } from '../markdown-commands';
34+
import { termBeforePosition } from '../util/term-before-position';
2735
import MarkdownView from './MarkdownView';
2836

2937
/**
@@ -176,24 +184,69 @@ function ToolbarButton({
176184
type TextAreaProps = {
177185
classes?: string;
178186
containerRef?: Ref<HTMLTextAreaElement>;
187+
atMentionsEnabled: boolean;
179188
};
180189

181190
function TextArea({
182191
classes,
183192
containerRef,
193+
atMentionsEnabled,
184194
...restProps
185195
}: TextAreaProps & JSX.TextareaHTMLAttributes<HTMLTextAreaElement>) {
196+
const [popoverOpen, setPopoverOpen] = useState(false);
197+
const textareaRef = useSyncedRef(containerRef);
198+
199+
useEffect(() => {
200+
if (!atMentionsEnabled) {
201+
return () => {};
202+
}
203+
204+
const textarea = textareaRef.current!;
205+
const listenerCollection = new ListenerCollection();
206+
207+
// We listen for `keyup` to make sure the text in the textarea reflects the
208+
// just-pressed key when we evaluate it
209+
listenerCollection.add(textarea, 'keyup', e => {
210+
// `Esc` key is used to close the popover. Do nothing and let users close
211+
// it that way, even if the caret is in a mention
212+
if (e.key === 'Escape') {
213+
return;
214+
}
215+
setPopoverOpen(
216+
termBeforePosition(textarea.value, textarea.selectionStart).startsWith(
217+
'@',
218+
),
219+
);
220+
});
221+
222+
return () => {
223+
listenerCollection.removeAll();
224+
};
225+
}, [atMentionsEnabled, popoverOpen, textareaRef]);
226+
186227
return (
187-
<textarea
188-
className={classnames(
189-
'border rounded p-2',
190-
'text-color-text-light bg-grey-0',
191-
'focus:bg-white focus:outline-none focus:shadow-focus-inner',
192-
classes,
228+
<div className="relative">
229+
<textarea
230+
className={classnames(
231+
'border rounded p-2',
232+
'text-color-text-light bg-grey-0',
233+
'focus:bg-white focus:outline-none focus:shadow-focus-inner',
234+
classes,
235+
)}
236+
{...restProps}
237+
ref={textareaRef}
238+
/>
239+
{atMentionsEnabled && (
240+
<Popover
241+
open={popoverOpen}
242+
onClose={() => setPopoverOpen(false)}
243+
anchorElementRef={textareaRef}
244+
classes="p-2"
245+
>
246+
Suggestions
247+
</Popover>
193248
)}
194-
{...restProps}
195-
ref={containerRef}
196-
/>
249+
</div>
197250
);
198251
}
199252

@@ -322,6 +375,12 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }: ToolbarProps) {
322375
}
323376

324377
export type MarkdownEditorProps = {
378+
/**
379+
* Whether the at-mentions feature ir enabled or not.
380+
* Defaults to false.
381+
*/
382+
atMentionsEnabled?: boolean;
383+
325384
/** An accessible label for the input field */
326385
label: string;
327386

@@ -338,6 +397,7 @@ export type MarkdownEditorProps = {
338397
* Viewer/editor for the body of an annotation in markdown format.
339398
*/
340399
export default function MarkdownEditor({
400+
atMentionsEnabled = false,
341401
label,
342402
onEditText = () => {},
343403
text,
@@ -411,6 +471,7 @@ export default function MarkdownEditor({
411471
}
412472
value={text}
413473
style={textStyle}
474+
atMentionsEnabled={atMentionsEnabled}
414475
/>
415476
)}
416477
</div>

src/sidebar/components/test/MarkdownEditor-test.js

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,20 @@ describe('MarkdownEditor', () => {
5050

5151
function createComponent(props = {}, mountProps = {}) {
5252
return mount(
53-
<MarkdownEditor label="Test editor" text="test" {...props} />,
53+
<MarkdownEditor
54+
label="Test editor"
55+
text="test"
56+
atMentionsEnabled={false}
57+
{...props}
58+
/>,
5459
mountProps,
5560
);
5661
}
5762

63+
function createConnectedComponent(props = {}) {
64+
return createComponent(props, { connected: true });
65+
}
66+
5867
const commands = [
5968
{
6069
command: 'Bold',
@@ -373,6 +382,98 @@ describe('MarkdownEditor', () => {
373382
assert.deepEqual(wrapper.find('MarkdownView').prop('style'), textStyle);
374383
});
375384

385+
context('when @mentions are enabled', () => {
386+
function typeInTextarea(wrapper, text, key = undefined) {
387+
const textarea = wrapper.find('textarea');
388+
const textareaDOMNode = textarea.getDOMNode();
389+
390+
textareaDOMNode.value = text;
391+
act(() =>
392+
textareaDOMNode.dispatchEvent(new KeyboardEvent('keyup', { key })),
393+
);
394+
wrapper.update();
395+
}
396+
397+
[true, false].forEach(atMentionsEnabled => {
398+
it('renders Popover if @mentions are enabled', () => {
399+
const wrapper = createComponent({ atMentionsEnabled });
400+
assert.equal(wrapper.exists('Popover'), atMentionsEnabled);
401+
});
402+
});
403+
404+
it('opens Popover when an @mention is typed in textarea', () => {
405+
const wrapper = createConnectedComponent({ atMentionsEnabled: true });
406+
typeInTextarea(wrapper, '@johndoe');
407+
408+
assert.isTrue(wrapper.find('Popover').prop('open'));
409+
});
410+
411+
it('closes Popover when cursor moves away from @mention', () => {
412+
const wrapper = createConnectedComponent({ atMentionsEnabled: true });
413+
414+
// Popover is open after typing the at-mention
415+
typeInTextarea(wrapper, '@johndoe');
416+
assert.isTrue(wrapper.find('Popover').prop('open'));
417+
418+
// Once a space is typed after the at-mention, the popover is closed
419+
typeInTextarea(wrapper, '@johndoe ');
420+
assert.isFalse(wrapper.find('Popover').prop('open'));
421+
});
422+
423+
it('closes Popover when @mention is removed', () => {
424+
const wrapper = createConnectedComponent({ atMentionsEnabled: true });
425+
426+
// Popover is open after typing the at-mention
427+
typeInTextarea(wrapper, '@johndoe');
428+
assert.isTrue(wrapper.find('Popover').prop('open'));
429+
430+
// Once the at-mention is removed, the popover is closed
431+
typeInTextarea(wrapper, '');
432+
assert.isFalse(wrapper.find('Popover').prop('open'));
433+
});
434+
435+
it('opens Popover when cursor moves into an @mention', () => {
436+
const text = '@johndoe ';
437+
const wrapper = createConnectedComponent({
438+
text,
439+
atMentionsEnabled: true,
440+
});
441+
442+
const textarea = wrapper.find('textarea');
443+
const textareaDOMNode = textarea.getDOMNode();
444+
445+
// Popover is initially closed
446+
assert.isFalse(wrapper.find('Popover').prop('open'));
447+
448+
// Move cursor to the left
449+
textareaDOMNode.selectionStart = text.length - 1;
450+
act(() => textareaDOMNode.dispatchEvent(new KeyboardEvent('keyup')));
451+
wrapper.update();
452+
453+
assert.isTrue(wrapper.find('Popover').prop('open'));
454+
});
455+
456+
it('closes Popover when onClose is called', () => {
457+
const wrapper = createConnectedComponent({ atMentionsEnabled: true });
458+
459+
// Popover is initially open
460+
typeInTextarea(wrapper, '@johndoe');
461+
assert.isTrue(wrapper.find('Popover').prop('open'));
462+
463+
wrapper.find('Popover').props().onClose();
464+
wrapper.update();
465+
assert.isFalse(wrapper.find('Popover').prop('open'));
466+
});
467+
468+
it('ignores `Escape` key press in textarea', () => {
469+
const wrapper = createConnectedComponent({ atMentionsEnabled: true });
470+
471+
// Popover is still closed if the key is `Escape`
472+
typeInTextarea(wrapper, '@johndoe', 'Escape');
473+
assert.isFalse(wrapper.find('Popover').prop('open'));
474+
});
475+
});
476+
376477
it(
377478
'should pass a11y checks',
378479
checkAccessibility([
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Returns the "word" right before a specific position in an input string.
3+
*
4+
* In this context, a word is anything between a space or newline, and provided
5+
* position.
6+
*/
7+
export function termBeforePosition(text: string, position: number): string {
8+
return text.slice(0, position).match(/\S+$/)?.[0] ?? '';
9+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { termBeforePosition } from '../term-before-position';
2+
3+
describe('term-before-position', () => {
4+
// To make these tests more predictable, we place the `$` sign in the position
5+
// to be checked. That way it's easier to see what is the "word" preceding it.
6+
// The test will then get the `$` sign index and remove it from the text
7+
// before passing it to `termBeforePosition`.
8+
[
9+
// First and last positions
10+
{
11+
text: '$Hello world',
12+
expectedTerm: '',
13+
},
14+
{
15+
text: 'Hello world$',
16+
expectedTerm: 'world',
17+
},
18+
19+
// Position in the middle of words
20+
{
21+
text: 'Hell$o world',
22+
expectedTerm: 'Hell',
23+
},
24+
{
25+
text: 'Hello wor$ld',
26+
expectedTerm: 'wor',
27+
},
28+
29+
// Position preceded by "empty space"
30+
{
31+
text: 'Hello $world',
32+
expectedTerm: '',
33+
},
34+
{
35+
text: `Text with
36+
multiple
37+
$
38+
lines
39+
`,
40+
expectedTerm: '',
41+
},
42+
43+
// Position preceded by/in the middle of a word for multi-line text
44+
{
45+
text: `Text with$
46+
multiple
47+
48+
lines
49+
`,
50+
expectedTerm: 'with',
51+
},
52+
{
53+
text: `Text with
54+
multiple
55+
56+
li$nes
57+
`,
58+
expectedTerm: 'li',
59+
},
60+
].forEach(({ text, expectedTerm }) => {
61+
it('returns the term right before provided position', () => {
62+
// Get the position of the `$` sign in the text, then remove it
63+
const position = text.indexOf('$');
64+
const textWithoutDollarSign = text.replace('$', '');
65+
66+
assert.equal(
67+
termBeforePosition(textWithoutDollarSign, position),
68+
expectedTerm,
69+
);
70+
});
71+
});
72+
});

0 commit comments

Comments
 (0)