From 76a7ed6dbe26cd6c87ee897ecb25108ae325dd99 Mon Sep 17 00:00:00 2001 From: Suzanne Aitchison Date: Wed, 17 Mar 2021 15:49:16 +0000 Subject: [PATCH] Update storybook autocomplete component (#12980) --- app/assets/stylesheets/base/helpers.scss | 9 + .../stylesheets/components/autocomplete.scss | 3 +- .../MentionAutocomplete.jsx | 141 ---------- .../MentionAutocompleteCombobox.jsx | 177 ------------ .../MentionAutocomplete.stories.jsx | 75 ----- .../__stories__/mention-autocomplete.md | 14 - .../__tests__/MentionAutocomplete.test.jsx | 38 --- .../MentionAutocompleteCombobox.test.jsx | 110 -------- .../MentionAutocompleteCombobox.test.jsx.snap | 3 - .../crayons/MentionAutocomplete/index.js | 1 - .../MentionAutocompleteTextArea.jsx | 265 ++++++++++++++++++ .../MentionAutocompleteTextArea.stories.jsx | 99 +++++++ .../__stories__/mention-autocomplete.md | 18 ++ .../MentionAutocompleteTextArea.test.jsx | 52 ++++ .../MentionAutocompleteTextArea.test.jsx.snap | 3 + .../MentionAutocompleteTextArea/index.js | 1 + .../utilities/__tests__/textAreaUtils.test.js | 68 +++++ app/javascript/utilities/textAreaUtils.js | 73 ++++- config/webpack/environment.js | 2 + 19 files changed, 587 insertions(+), 565 deletions(-) delete mode 100644 app/javascript/crayons/MentionAutocomplete/MentionAutocomplete.jsx delete mode 100644 app/javascript/crayons/MentionAutocomplete/MentionAutocompleteCombobox.jsx delete mode 100644 app/javascript/crayons/MentionAutocomplete/__stories__/MentionAutocomplete.stories.jsx delete mode 100644 app/javascript/crayons/MentionAutocomplete/__stories__/mention-autocomplete.md delete mode 100644 app/javascript/crayons/MentionAutocomplete/__tests__/MentionAutocomplete.test.jsx delete mode 100644 app/javascript/crayons/MentionAutocomplete/__tests__/MentionAutocompleteCombobox.test.jsx delete mode 100644 app/javascript/crayons/MentionAutocomplete/__tests__/__snapshots__/MentionAutocompleteCombobox.test.jsx.snap delete mode 100644 app/javascript/crayons/MentionAutocomplete/index.js create mode 100644 app/javascript/crayons/MentionAutocompleteTextArea/MentionAutocompleteTextArea.jsx create mode 100644 app/javascript/crayons/MentionAutocompleteTextArea/__stories__/MentionAutocompleteTextArea.stories.jsx create mode 100644 app/javascript/crayons/MentionAutocompleteTextArea/__stories__/mention-autocomplete.md create mode 100644 app/javascript/crayons/MentionAutocompleteTextArea/__tests__/MentionAutocompleteTextArea.test.jsx create mode 100644 app/javascript/crayons/MentionAutocompleteTextArea/__tests__/__snapshots__/MentionAutocompleteTextArea.test.jsx.snap create mode 100644 app/javascript/crayons/MentionAutocompleteTextArea/index.js create mode 100644 app/javascript/utilities/__tests__/textAreaUtils.test.js diff --git a/app/assets/stylesheets/base/helpers.scss b/app/assets/stylesheets/base/helpers.scss index abc7db77e..f3a664adb 100644 --- a/app/assets/stylesheets/base/helpers.scss +++ b/app/assets/stylesheets/base/helpers.scss @@ -36,6 +36,15 @@ border-top: var(--su-7) solid var(--accent-brand); } +.screen-reader-only { + position: absolute; + left: -10000px; + top: auto; + width: 1px; + height: 1px; + overflow: hidden; +} + @media print { .print-hidden { display: none; diff --git a/app/assets/stylesheets/components/autocomplete.scss b/app/assets/stylesheets/components/autocomplete.scss index e3d3323c1..933a4afe3 100644 --- a/app/assets/stylesheets/components/autocomplete.scss +++ b/app/assets/stylesheets/components/autocomplete.scss @@ -2,8 +2,9 @@ .crayons-autocomplete { &__popover { - min-width: 300px; + max-width: 300px; padding: var(--su-1); + z-index: var(--z-elevate); &[data-reach-popover] { @include generate-box( diff --git a/app/javascript/crayons/MentionAutocomplete/MentionAutocomplete.jsx b/app/javascript/crayons/MentionAutocomplete/MentionAutocomplete.jsx deleted file mode 100644 index 3c541c3e8..000000000 --- a/app/javascript/crayons/MentionAutocomplete/MentionAutocomplete.jsx +++ /dev/null @@ -1,141 +0,0 @@ -import { h, render } from 'preact'; -import { useState, useEffect, useCallback } from 'preact/hooks'; -import { getCursorXY } from '@utilities/textAreaUtils'; -import { useMediaQuery, BREAKPOINTS } from '@components/useMediaQuery'; - -/** - * A component which listens for an '@' keypress, and encompasses the MentionAutocomplete functionality. - * When an autocomplete suggestion is selected, it is inserted into the textarea. - * - * @param {object} props - * @param {object} props.textAreaRef A reference to the text area where an '@' mention can take place - * @param {function} props.fetchSuggestions The callback to search for suggestions - * - * @example - * - * "`; diff --git a/app/javascript/crayons/MentionAutocompleteTextArea/index.js b/app/javascript/crayons/MentionAutocompleteTextArea/index.js new file mode 100644 index 000000000..566452145 --- /dev/null +++ b/app/javascript/crayons/MentionAutocompleteTextArea/index.js @@ -0,0 +1 @@ +export * from './MentionAutocompleteTextArea'; diff --git a/app/javascript/utilities/__tests__/textAreaUtils.test.js b/app/javascript/utilities/__tests__/textAreaUtils.test.js new file mode 100644 index 000000000..e8e17979f --- /dev/null +++ b/app/javascript/utilities/__tests__/textAreaUtils.test.js @@ -0,0 +1,68 @@ +import { getMentionWordData } from '../textAreaUtils'; + +describe('getMentionWordData', () => { + it('returns userMention false for cursor at start of input', () => { + const inputState = { + selectionStart: 0, + value: 'text with @mention', + }; + + const { isUserMention, indexOfMentionStart } = getMentionWordData( + inputState, + ); + expect(isUserMention).toBe(false); + expect(indexOfMentionStart).toEqual(-1); + }); + + it('returns userMention false for empty input value', () => { + const inputState = { + selectionStart: 10, + value: '', + }; + + const { isUserMention, indexOfMentionStart } = getMentionWordData( + inputState, + ); + expect(isUserMention).toBe(false); + expect(indexOfMentionStart).toEqual(-1); + }); + + it('returns userMention false if no @ symbol exists at start of word', () => { + const inputState = { + selectionStart: 13, + value: 'text with no mention', + }; + + const { isUserMention, indexOfMentionStart } = getMentionWordData( + inputState, + ); + expect(isUserMention).toBe(false); + expect(indexOfMentionStart).toEqual(-1); + }); + + it('returns userMention true and correct index for an @ mention at beginning of input', () => { + const inputState = { + selectionStart: 3, + value: '@mention', + }; + + const { isUserMention, indexOfMentionStart } = getMentionWordData( + inputState, + ); + expect(isUserMention).toBe(true); + expect(indexOfMentionStart).toEqual(0); + }); + + it('returns userMention true and correct index for @ mention in middle of input', () => { + const inputState = { + selectionStart: 13, + value: 'text with @mention', + }; + + const { isUserMention, indexOfMentionStart } = getMentionWordData( + inputState, + ); + expect(isUserMention).toBe(true); + expect(indexOfMentionStart).toEqual(10); + }); +}); diff --git a/app/javascript/utilities/textAreaUtils.js b/app/javascript/utilities/textAreaUtils.js index 5efbb07d2..4768c44ed 100644 --- a/app/javascript/utilities/textAreaUtils.js +++ b/app/javascript/utilities/textAreaUtils.js @@ -11,15 +11,25 @@ * const coordinates = getCursorXY(elementRef.current, elementRef.current.selectionStart) */ export const getCursorXY = (input, selectionPoint) => { - const { offsetLeft: inputX, offsetTop: inputY } = input; + const bodyRect = document.body.getBoundingClientRect(); + const elementRect = input.getBoundingClientRect(); + + const inputY = elementRect.top - bodyRect.top; + const inputX = elementRect.left - bodyRect.left; // create a dummy element with the computed style of the input - const div = top.document.createElement('div'); + const div = document.createElement('div'); const copyStyle = getComputedStyle(input); for (const prop of copyStyle) { div.style[prop] = copyStyle[prop]; } + // set the div to the correct position + div.style['position'] = 'absolute'; + div.style['top'] = `${inputY}px`; + div.style['left'] = `${inputX}px`; + div.style['opacity'] = 0; + // replace whitespace with '.' when filling the dummy element if it's a single line const swap = '.'; const inputValue = @@ -33,22 +43,75 @@ export const getCursorXY = (input, selectionPoint) => { if (input.tagName === 'INPUT') div.style.width = 'auto'; // marker element to obtain caret position - const span = top.document.createElement('span'); + const span = document.createElement('span'); // give the span the textContent of remaining content so that the recreated dummy element is as close as possible span.textContent = inputValue.substr(selectionPoint) || '.'; // append the span marker to the div and the dummy element to the body div.appendChild(span); - top.document.body.appendChild(div); + document.body.appendChild(div); // get the marker position, this is the caret position top and left relative to the input const { offsetLeft: spanX, offsetTop: spanY } = span; // remove dummy element - top.document.body.removeChild(div); + document.body.removeChild(div); + // return object with the x and y of the caret. account for input positioning so that you don't need to wrap the input return { x: inputX + spanX, y: inputY + spanY, }; }; + +/** + * A helper function that searches back to the beginning of the currently typed word (indicated by cursor position) and verifies whether it begins with an '@' symbol for user mention + * + * @param {element} textArea The text area or input to inspect the current word of + * @returns {{isUserMention: boolean, indexOfMentionStart: number}} Object with the word's mention data + * + * @example + * const { isUserMention, indexOfMentionStart } = getMentionWordData(textArea); + * if (isUserMention) { + * // Do something + * } + */ +export const getMentionWordData = (textArea) => { + const { selectionStart, value: valueBeforeKeystroke } = textArea; + + if (selectionStart === 0 || valueBeforeKeystroke === '') { + return { + isUserMention: false, + indexOfMentionStart: -1, + }; + } + + const indexOfAutocompleteStart = getIndexOfCurrentWordAutocompleteSymbol( + valueBeforeKeystroke, + selectionStart, + ); + + return { + isUserMention: indexOfAutocompleteStart !== -1, + indexOfMentionStart: indexOfAutocompleteStart, + }; +}; + +const getIndexOfCurrentWordAutocompleteSymbol = (content, selectionIndex) => { + const currentCharacter = content.charAt(selectionIndex); + const previousCharacter = content.charAt(selectionIndex - 1); + + if ( + selectionIndex !== 0 && + previousCharacter !== ' ' && + previousCharacter !== '' + ) { + return getIndexOfCurrentWordAutocompleteSymbol(content, selectionIndex - 1); + } + + if (currentCharacter === '@') { + return selectionIndex; + } + + return -1; +}; diff --git a/config/webpack/environment.js b/config/webpack/environment.js index 842312300..ed747673c 100644 --- a/config/webpack/environment.js +++ b/config/webpack/environment.js @@ -26,6 +26,8 @@ environment.splitChunks((config) => { __dirname, '../../app/javascript/shared/components', ), + react: 'preact/compat', + 'react-dom': 'preact/compat', }, }, };