Skip to content

Commit

Permalink
Display suggestions popover when at-mentioning
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Dec 19, 2024
1 parent d0aed76 commit 07ebc15
Show file tree
Hide file tree
Showing 6 changed files with 256 additions and 11 deletions.
1 change: 1 addition & 0 deletions src/sidebar/components/Annotation/AnnotationEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ function AnnotationEditor({
label={isReplyAnno ? 'Enter reply' : 'Enter comment'}
text={text}
onEditText={onEditText}
atMentionsEnabled={store.isFeatureEnabled('at_mentions')}
/>
<TagEditor
onAddTag={onAddTag}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ describe('AnnotationEditor', () => {
setDefault: sinon.stub(),
removeDraft: sinon.stub(),
removeAnnotations: sinon.stub(),
isFeatureEnabled: sinon.stub().returns(false),
};

$imports.$mock(mockImportedComponents());
Expand Down
81 changes: 71 additions & 10 deletions src/sidebar/components/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { Button, IconButton, Link } from '@hypothesis/frontend-shared';
import {
Button,
IconButton,
Link,
Popover,
useSyncedRef,
} from '@hypothesis/frontend-shared';
import {
EditorLatexIcon,
EditorQuoteIcon,
Expand All @@ -16,6 +22,7 @@ import classnames from 'classnames';
import type { Ref, JSX } from 'preact';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';

import { ListenerCollection } from '../../shared/listener-collection';
import { isMacOS } from '../../shared/user-agent';
import {
LinkType,
Expand All @@ -24,6 +31,7 @@ import {
toggleSpanStyle,
} from '../markdown-commands';
import type { EditorState } from '../markdown-commands';
import { termBeforePosition } from '../util/term-before-position';
import MarkdownView from './MarkdownView';

/**
Expand Down Expand Up @@ -176,24 +184,69 @@ function ToolbarButton({
type TextAreaProps = {
classes?: string;
containerRef?: Ref<HTMLTextAreaElement>;
atMentionsEnabled: boolean;
};

function TextArea({
classes,
containerRef,
atMentionsEnabled,
...restProps
}: TextAreaProps & JSX.TextareaHTMLAttributes<HTMLTextAreaElement>) {
const [popoverOpen, setPopoverOpen] = useState(false);
const textareaRef = useSyncedRef(containerRef);

useEffect(() => {
if (!atMentionsEnabled) {
return () => {};
}

const textarea = textareaRef.current!;
const listenerCollection = new ListenerCollection();

// We listen for `keyup` to make sure the text in the textarea reflects the
// just-pressed key when we evaluate it
listenerCollection.add(textarea, 'keyup', e => {
// `Esc` key is used to close the popover. Do nothing and let users close
// it that way, even if the caret is in a mention
if (e.key === 'Escape') {
return;
}
setPopoverOpen(
termBeforePosition(textarea.value, textarea.selectionStart).startsWith(
'@',
),
);
});

return () => {
listenerCollection.removeAll();
};
}, [atMentionsEnabled, popoverOpen, textareaRef]);

return (
<textarea
className={classnames(
'border rounded p-2',
'text-color-text-light bg-grey-0',
'focus:bg-white focus:outline-none focus:shadow-focus-inner',
classes,
<div className="relative">
<textarea
className={classnames(
'border rounded p-2',
'text-color-text-light bg-grey-0',
'focus:bg-white focus:outline-none focus:shadow-focus-inner',
classes,
)}
{...restProps}
ref={textareaRef}
/>
{atMentionsEnabled && (
<Popover
open={popoverOpen}
onClose={() => setPopoverOpen(false)}
anchorElementRef={textareaRef}
classes="p-2"
>
Suggestions
</Popover>
)}
{...restProps}
ref={containerRef}
/>
</div>
);
}

Expand Down Expand Up @@ -322,6 +375,12 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }: ToolbarProps) {
}

export type MarkdownEditorProps = {
/**
* Whether the at-mentions feature ir enabled or not.
* Defaults to false.
*/
atMentionsEnabled?: boolean;

/** An accessible label for the input field */
label: string;

Expand All @@ -338,6 +397,7 @@ export type MarkdownEditorProps = {
* Viewer/editor for the body of an annotation in markdown format.
*/
export default function MarkdownEditor({
atMentionsEnabled = false,
label,
onEditText = () => {},
text,
Expand Down Expand Up @@ -411,6 +471,7 @@ export default function MarkdownEditor({
}
value={text}
style={textStyle}
atMentionsEnabled={atMentionsEnabled}
/>
)}
</div>
Expand Down
103 changes: 102 additions & 1 deletion src/sidebar/components/test/MarkdownEditor-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,20 @@ describe('MarkdownEditor', () => {

function createComponent(props = {}, mountProps = {}) {
return mount(
<MarkdownEditor label="Test editor" text="test" {...props} />,
<MarkdownEditor
label="Test editor"
text="test"
atMentionsEnabled={false}
{...props}
/>,
mountProps,
);
}

function createConnectedComponent(props = {}) {
return createComponent(props, { connected: true });
}

const commands = [
{
command: 'Bold',
Expand Down Expand Up @@ -373,6 +382,98 @@ describe('MarkdownEditor', () => {
assert.deepEqual(wrapper.find('MarkdownView').prop('style'), textStyle);
});

context('when @mentions are enabled', () => {
function typeInTextarea(wrapper, text, key = undefined) {
const textarea = wrapper.find('textarea');
const textareaDOMNode = textarea.getDOMNode();

textareaDOMNode.value = text;
act(() =>
textareaDOMNode.dispatchEvent(new KeyboardEvent('keyup', { key })),
);
wrapper.update();
}

[true, false].forEach(atMentionsEnabled => {
it('renders Popover if @mentions are enabled', () => {
const wrapper = createComponent({ atMentionsEnabled });
assert.equal(wrapper.exists('Popover'), atMentionsEnabled);
});
});

it('opens Popover when an @mention is typed in textarea', () => {
const wrapper = createConnectedComponent({ atMentionsEnabled: true });
typeInTextarea(wrapper, '@johndoe');

assert.isTrue(wrapper.find('Popover').prop('open'));
});

it('closes Popover when cursor moves away from @mention', () => {
const wrapper = createConnectedComponent({ atMentionsEnabled: true });

// Popover is open after typing the at-mention
typeInTextarea(wrapper, '@johndoe');
assert.isTrue(wrapper.find('Popover').prop('open'));

// Once a space is typed after the at-mention, the popover is closed
typeInTextarea(wrapper, '@johndoe ');
assert.isFalse(wrapper.find('Popover').prop('open'));
});

it('closes Popover when @mention is removed', () => {
const wrapper = createConnectedComponent({ atMentionsEnabled: true });

// Popover is open after typing the at-mention
typeInTextarea(wrapper, '@johndoe');
assert.isTrue(wrapper.find('Popover').prop('open'));

// Once the at-mention is removed, the popover is closed
typeInTextarea(wrapper, '');
assert.isFalse(wrapper.find('Popover').prop('open'));
});

it('opens Popover when cursor moves into an @mention', () => {
const text = '@johndoe ';
const wrapper = createConnectedComponent({
text,
atMentionsEnabled: true,
});

const textarea = wrapper.find('textarea');
const textareaDOMNode = textarea.getDOMNode();

// Popover is initially closed
assert.isFalse(wrapper.find('Popover').prop('open'));

// Move cursor to the left
textareaDOMNode.selectionStart = text.length - 1;
act(() => textareaDOMNode.dispatchEvent(new KeyboardEvent('keyup')));
wrapper.update();

assert.isTrue(wrapper.find('Popover').prop('open'));
});

it('closes Popover when onClose is called', () => {
const wrapper = createConnectedComponent({ atMentionsEnabled: true });

// Popover is initially open
typeInTextarea(wrapper, '@johndoe');
assert.isTrue(wrapper.find('Popover').prop('open'));

wrapper.find('Popover').props().onClose();
wrapper.update();
assert.isFalse(wrapper.find('Popover').prop('open'));
});

it('ignores `Escape` key press in textarea', () => {
const wrapper = createConnectedComponent({ atMentionsEnabled: true });

// Popover is still closed if the key is `Escape`
typeInTextarea(wrapper, '@johndoe', 'Escape');
assert.isFalse(wrapper.find('Popover').prop('open'));
});
});

it(
'should pass a11y checks',
checkAccessibility([
Expand Down
9 changes: 9 additions & 0 deletions src/sidebar/util/term-before-position.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Returns the "word" right before a specific position in an input string.
*
* In this context, a word is anything between a space or newline, and provided
* position.
*/
export function termBeforePosition(text: string, position: number): string {
return text.slice(0, position).match(/\S+$/)?.[0] ?? '';
}
72 changes: 72 additions & 0 deletions src/sidebar/util/test/term-before-position-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { termBeforePosition } from '../term-before-position';

describe('term-before-position', () => {
// To make these tests more predictable, we place the `$` sign in the position
// to be checked. That way it's easier to see what is the "word" preceding it.
// The test will then get the `$` sign index and remove it from the text
// before passing it to `termBeforePosition`.
[
// First and last positions
{
text: '$Hello world',
expectedTerm: '',
},
{
text: 'Hello world$',
expectedTerm: 'world',
},

// Position in the middle of words
{
text: 'Hell$o world',
expectedTerm: 'Hell',
},
{
text: 'Hello wor$ld',
expectedTerm: 'wor',
},

// Position preceded by "empty space"
{
text: 'Hello $world',
expectedTerm: '',
},
{
text: `Text with
multiple
$
lines
`,
expectedTerm: '',
},

// Position preceded by/in the middle of a word for multi-line text
{
text: `Text with$
multiple
lines
`,
expectedTerm: 'with',
},
{
text: `Text with
multiple
li$nes
`,
expectedTerm: 'li',
},
].forEach(({ text, expectedTerm }) => {
it('returns the term right before provided position', () => {
// Get the position of the `$` sign in the text, then remove it
const position = text.indexOf('$');
const textWithoutDollarSign = text.replace('$', '');

assert.equal(
termBeforePosition(textWithoutDollarSign, position),
expectedTerm,
);
});
});
});

0 comments on commit 07ebc15

Please sign in to comment.