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 17, 2024
1 parent d0aed76 commit 0ac0205
Show file tree
Hide file tree
Showing 2 changed files with 182 additions and 11 deletions.
83 changes: 73 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 { useSidebarStore } from '../store';
import MarkdownView from './MarkdownView';

/**
Expand Down Expand Up @@ -173,6 +181,20 @@ function ToolbarButton({
);
}

/**
* Get the word right before the caret in a textarea.
* A word is anything delimited by spaces or newlines.
*/
function getTermBeforeCaret(textarea: HTMLTextAreaElement): string {
const caretPosition = textarea.selectionStart;
const textBeforeCaret = textarea.value
.slice(0, caretPosition)
.replace(/\n/g, ' ');
const lastSpaceIndex = textBeforeCaret.lastIndexOf(' ');

return textBeforeCaret.slice(lastSpaceIndex + 1);
}

type TextAreaProps = {
classes?: string;
containerRef?: Ref<HTMLTextAreaElement>;
Expand All @@ -183,17 +205,58 @@ function TextArea({
containerRef,
...restProps
}: TextAreaProps & JSX.TextareaHTMLAttributes<HTMLTextAreaElement>) {
const [popoverOpen, setPopoverOpen] = useState(false);
const textareaRef = useSyncedRef(containerRef);
const store = useSidebarStore();
const atMentionsEnabled = store.isFeatureEnabled('at_mentions');

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(getTermBeforeCaret(textarea).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
110 changes: 109 additions & 1 deletion src/sidebar/components/test/MarkdownEditor-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
checkAccessibility,
mockImportedComponents,
} from '@hypothesis/frontend-testing';
import { mount } from '@hypothesis/frontend-testing';
import { mount, unmountAll } from '@hypothesis/frontend-testing';
import { render } from 'preact';
import { act } from 'preact/test-utils';

Expand All @@ -23,13 +23,18 @@ describe('MarkdownEditor', () => {
};
let fakeIsMacOS;
let MarkdownView;
let fakeStore;

beforeEach(() => {
fakeMarkdownCommands.convertSelectionToLink.resetHistory();
fakeMarkdownCommands.toggleBlockStyle.resetHistory();
fakeMarkdownCommands.toggleSpanStyle.resetHistory();
fakeIsMacOS = sinon.stub().returns(false);

fakeStore = {
isFeatureEnabled: sinon.stub().returns(false),
};

MarkdownView = function MarkdownView() {
return null;
};
Expand All @@ -41,11 +46,13 @@ describe('MarkdownEditor', () => {
'../../shared/user-agent': {
isMacOS: fakeIsMacOS,
},
'../store': { useSidebarStore: () => fakeStore },
});
});

afterEach(() => {
$imports.$restore();
unmountAll();
});

function createComponent(props = {}, mountProps = {}) {
Expand All @@ -55,6 +62,10 @@ describe('MarkdownEditor', () => {
);
}

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

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

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

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

beforeEach(() => {
fakeStore.isFeatureEnabled.withArgs('at_mentions').returns(true);
});

[true, false].forEach(atMentionsEnabled => {
it('renders Popover if @mentions are enabled', () => {
fakeStore.isFeatureEnabled
.withArgs('at_mentions')
.returns(atMentionsEnabled);
const wrapper = createComponent();

assert.equal(wrapper.exists('Popover'), atMentionsEnabled);
});
});

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

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

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

// Popover is open after typing the at-mention
typeInTextarea(wrapper);
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();

// Popover is open after typing the at-mention
typeInTextarea(wrapper);
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 });

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();

// Popover is initially open
typeInTextarea(wrapper);
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();

// 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

0 comments on commit 0ac0205

Please sign in to comment.