Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add InlineAutocomplete component #2157

Merged
merged 48 commits into from
Aug 2, 2022
Merged

Add InlineAutocomplete component #2157

merged 48 commits into from
Aug 2, 2022

Conversation

iansan5653
Copy link
Contributor

@iansan5653 iansan5653 commented Jun 29, 2022

Writeup adapted from github/memex#105801:

Component Description

A new InlineAutocomplete component that provides support for inline completion suggestions (for example, emojis) in textarea and input elements.

Screen recording of the component being used to mention a suggested user in a multiline text box:

InlineAutocomplete.Demo.mov

The component is similar in some ways to the existing Autocomplete component, but this is a somewhat different pattern in that, when a suggestion is accepted, the value is inserted into the text (hence 'inline') rather than replacing the content. This pattern is closer to an IDE-style autocomplete, where the existing Primer component uses more of a 'search suggestions' pattern.

Supported Features

  • Stateless: suggestion visibility is fully controlled by the parent component through events and props
  • Can wrap any input or textarea element as well as PRC Input and TextArea components
  • Suggestions have a default rendering, but can be fully customized (ie, to display user avatars)
  • Supports multiple trigger types so that different suggestion types can be combined (ie, to support both user mentions and emojis in the same input)
  • Predefined hooks provide reusable, mixable behavior for the most common types of suggestions: user mentions, issue references, and emojis
  • Keyboard control: select items with arrow keys, apply items with Tab or Enter
  • Mouse control: click to select items (maintains focus on the input)
  • Undo history is preserved when applying suggestions
  • Caret position is preserved when applying suggestions

Additional Work

While adding this component, I also added some new hooks to abstract out some of the underlying logic:

useCombobox hook

The useCombobox hook provides a React-compatible wrapper around the @github/combobox-nav class. This provides keyboard control and accessible roles for comboboxes (lists of items that can be selected to apply to an input). The hook provides support for comboboxes that can be opened and closed like this one, and calls an easy to use event callback when an option is selected.

useSyntheticChange hook

When a suggestion is applied, we need to emit a change event from the underlying input. It should look to the API consumer as though the input was typed in - they shouldn't have to add any additional event handlers to react to suggestion application. In addition, we want to preserve the undo stack and caret position - if we have a separate event handler and just update the input's value, the caret position will be reset.

So this hook provides an easy way to simulate a change that is as close as possible to the real thing. The change event preserves the undo stack and caret position and calls the onChange handler that is already bound to the underlying input.

Background / Context

In projects (beta), we are using this component to provide emoji suggestions across the entire application and also to provide support for multiple types of inline suggestions in the Markdown editor.

This component is a dependency of the MarkdownEditor1 (AKA CommentBox1) component as it provides the inline suggestions for # references, @ mentions, and emojis (:). Rather than open one giant PR, I wanted to split this work out into a separate task.

🚧 IMO, this component should be added as experimental status, just like the Markdown editor itself.

Closes github/memex#105801. There is not (at this time) a Primer tracking issue for this component.

Risks

New Dependencies

Two new external dependencies are introduced:

  • @github/combobox-nav: See the useCombobox hook note above.
    • Note: Due to the way this library uses the navigator global, it's incompatible with SSR so it doesn't work with the docs. I have an open PR to fix this.
  • @koddsson/textarea-caret: Detects the position of a character index in a textarea or input element. This is used to position the suggestions list and is already used in GitHub for the existing Markdown editor.

Accessibility

This component dynamically adds and removes a combobox as the user is typing. A combobox that is always visible is accessible, but one that is added and removed is not - the suggestions will likely never be read by a screen reader. This issue is already present in GitHub with the existing Markdown editor. When we have the resources to solve this, we should do so by fixing the @github/combobox-nav library so the fix will cascade down to the existing Markdown editor in addition to this component.

Storybook error

The story works in Storybook, but there is a console error being thrown that I have not been able to fix. I'm not familiar with Slots and could use some help here if anyone has any thoughts. I can't figure out where data-source might be getting applied. Is this a Storybook-specific issue?

Warning: Invalid prop data-source supplied to React.Fragment. React.Fragment can only have key and children props.

at Slots
at UserSuggestion
at SuggestionListItem
at ul
at StyledComponent
at List__ListBox
...

Merge checklist

  • Added/updated tests (working on this - it's tested as part of MarkdownEditor in projects beta; I need to extract those tests and make them specific to this component)
  • Added/updated documentation
  • Tested in Chrome
  • Tested in Firefox
  • Tested in Safari
  • Tested in Edge

Need to merge and update combobox-nav library to fix docs issue:

Footnotes

  1. :octocat: Link only accessible to GitHub employees 2 3 4

@changeset-bot
Copy link

changeset-bot bot commented Jun 29, 2022

🦋 Changeset detected

Latest commit: 0d4c896

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@primer/react Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

src/InlineAutocomplete/utils.ts Outdated Show resolved Hide resolved
src/InlineAutocomplete/InlineAutocomplete.tsx Outdated Show resolved Hide resolved
src/InlineAutocomplete/types.ts Outdated Show resolved Hide resolved
src/InlineAutocomplete/utils.ts Outdated Show resolved Hide resolved
src/hooks/useSyntheticChange.ts Outdated Show resolved Hide resolved
Copy link
Contributor

@mperrotti mperrotti left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work. I'll keep an eye on this PR and give it a closer look when it's ready for review.

docs/content/InlineAutocomplete.mdx Outdated Show resolved Hide resolved
src/InlineAutocomplete/InlineAutocomplete.tsx Outdated Show resolved Hide resolved
query: string
}

export type Suggestion =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're moving towards composable component APIs. ADR here - https://github.com/primer/react/blob/main/contributor-docs/adrs/adr-004-children-as-api.md

Could we pass suggestions as a component instead? Something like this...

<InlineAutocomplete>
  <InlineAutocomplete.Overlay loading={true|false}>
    <InlineAutocomplete.Item value="one" key={0}>One</InlineAutocomplete.Item>
    <InlineAutocomplete.Item value="two" key={1}>Two</InlineAutocomplete.Item>
    <InlineAutocomplete.Item value="three" key={2}>Three</InlineAutocomplete.Item>
  </InlineAutocomplete.Overlay>
</InlineAutocomplete>

@siddharthkp - please correct me if I'm misinterpreting the ADR.

Copy link
Contributor Author

@iansan5653 iansan5653 Jun 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that a lot. I am not a fan at all of the render property.

My only hesitation is that it can be a lot harder to read when suggestions are dynamic (which they always are):

  <InlineAutocomplete.Suggestions>
    {suggestions.map(s => <InlineAutocomplete.Suggestion value={s} key={s} />)}
  </InlineAutocomplete.Suggestions>

But I think the reduced readability probably is probably outweighed by the flexibility in the compositional API.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may push this API change off to the later work if that's OK? I think we are willing to accept unstable APIs in experimental/draft components and I'd like to get this PR merged before too long.

Copy link
Contributor

@mperrotti mperrotti Jul 20, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, we're ok with unstable APIs in experimental/draft components. I think your current API is fine for now, but not something we'd have in an Alpha component.

*/
key?: string
/** This must return an `ActionList.Item` instance. */
render: (props: ActionListItemProps) => React.ReactElement
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we went with this approach instead of using children, how would we restrict users from returning something that isn't an ActionList.item instance?

Copy link
Contributor Author

@iansan5653 iansan5653 Jun 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah at this point we don't - we let them use anything, which isn't ideal

src/hooks/useSyntheticChange.ts Outdated Show resolved Hide resolved
src/InlineAutocomplete/utils.ts Outdated Show resolved Hide resolved
@mperrotti
Copy link
Contributor

Could we move this work to drafts/ to start? We can move it back to the main bundle once we're ready to mature InlineAutocomplete to an "Alpha" status

@iansan5653

This comment was marked as duplicate.

@owenniblock
Copy link
Contributor

owenniblock commented Jun 30, 2022

@iansan5653 I would love to do some work on the screen reader support on this component. Currently if I activate the menu, because the screen reader is not aware that the menu appears, it's not announced by the screen reader and if the user presses down, because the menu is not associated with the input, the screenreader hijacks the down arrow and items cannot be selected.

Give me some time to discuss this in the Accessibility team but we might be able to resolve this by using aria-activedesendant

I'll have to double check if it's compatible with this use-case.

Update after speaking to a team-mate:

We would be interested to see if you can reuse the autocomplete element for the autocomplete portion of this work. If there are features you require to enable this, please let us know and we'll see what we can do.

Also, if there's some reason we can't use the auto-complete-element perhaps you could use useFocusZone hook as that can provide aria-activedecendant behavior.

I would thoroughly recommend familiarising yourself with the aria authoring practices guidance for combobox as this details a lot of the functionality we're trying to create here and how to make it accessible.

@iansan5653
Copy link
Contributor Author

iansan5653 commented Jun 30, 2022

@owenniblock thanks for the accessibility notes! I am 100% on board with fixing the accessibility issues if we can figure out how ❤️

At the moment, I'm using the combobox-nav tool (GitHub-owned) to bind keyboard shortcuts and accessible roles. This is the same pattern we are using on the existing Rails Markdown editor across dotcom. I know that the problem also exists in the Rails editor because @jscholes has discussed it before.

If I understand correctly from reading the docs, it seems that auto-complete-element is specifically designed for pulling autocomplete results from a server endpoint, which is not something we are necessarily doing here. Under the hood, the auto-complete-element is still dependent on combobox-nav. I'm not sure if it provides any additional functionality on top of combobox-nav beyond making the API request.

I think/hope the solution to this problem lies in improving that combobox-nav utility, which would allow the changes to cascade down to the whole site. I'm not 100% sure though - we may also have to change some things on this React end to avoid unmounting/remounting the menu?


Just for reference, expand below to see the (simplified) HTML when the menu is open vs closed. If we can figure out exactly what needs to change, we can then easily figure out whether the fix needs to be done inside combobox-nav or within this component (or both).

View HTML

When the list is not visible:

<label for="react-aria3864888683-1">Inline Autocomplete Demo</label>

<textarea
  aria-required="false"
  aria-invalid="false"
  id="react-aria3864888683-1"
  aria-describedby="react-aria3864888683-1-caption"
></textarea>

<span id="react-aria3864888683-1-caption">
  Try typing '@' to show user suggestions.
</span>

<div id="portal-root"></div>

When the list is visible (ie, after the user types the trigger character):

<label for="react-aria3864888683-1">Inline Autocomplete Demo</label>

<textarea
  aria-required="false"
  aria-invalid="false"
  id="react-aria3864888683-1"
  aria-describedby="react-aria3864888683-1-caption"

  <!-- new attributes: -->
  role="combobox"
  aria-controls="combobox-7750"
  aria-expanded="true"
  aria-autocomplete="list"
  aria-haspopup="listbox"
></textarea>

<span id="react-aria3864888683-1-caption">
  Try typing '@' to show user suggestions.
</span>

<div id="portal-root">
  <ul role="listbox" id="combobox-7750">
    <li
      tabindex="0"
      aria-labelledby="monalisa monalisa--inline-description"
      role="option"
      id="combobox-react-aria3864888683-2__option-0"
      aria-selected="true"
    >
      <span id="monalisa">Monalisa Octocat</span>
      <div id="monalisa--inline-description" title="monalisa">monalisa</div>
    </li>
    <li
      tabindex="0"
      aria-labelledby="primer primer--inline-description"
      role="option"
      id="combobox-react-aria3864888683-2__option-1"
      aria-selected="false"
    >
      <span id="primer">Primer</span>
      <div id="primer--inline-description" title="primer">primer</div>
    </li>
    <li
      tabindex="0"
      aria-labelledby="github github--inline-description"
      role="option"
      id="combobox-react-aria3864888683-2__option-2"
      aria-selected="false"
    >
      <span id="github">GitHub</span>
      <div id="github--inline-description" title="github">github</div>
    </li>
  </ul>
</div>

@owenniblock
Copy link
Contributor

Weird, I see that the combobox-nav example is actually setting the aria-activedecendant value correctly! I wonder why it's not in your case. I'll do a little digging 🙂

@iansan5653
Copy link
Contributor Author

iansan5653 commented Jun 30, 2022

Oh, it's my bad! It is actually setting it, but I had blurred the input (after disabling hiding the menu) to copy the HTML and that must have cleared it. When I focus the input and press down the activedescendant is set.

Screenshot of the HTML in the browser tools with the activedescendant property set.

Hmm, I wonder then if the issue is just that I am constructing and destroying the class when the menu visibility changes, rather than starting and stopping it? I'm not totally sure if that's what's happening and/or if that was intentional - I'll have to dig back into the code.

src/hooks/index.ts Outdated Show resolved Hide resolved
@iansan5653
Copy link
Contributor Author

I think we should be good to go with this now!

@siddharthkp siddharthkp self-assigned this Aug 1, 2022
Copy link
Member

@siddharthkp siddharthkp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to go for drafts 👍

@siddharthkp
Copy link
Member

@siddharthkp siddharthkp merged commit 77e7ab0 into primer:main Aug 2, 2022
@primer-css primer-css mentioned this pull request Aug 2, 2022
@iansan5653
Copy link
Contributor Author

Is this empty file intentional?

Yes, it doesn't compile without it. I guess every folder in that directory needs an index.d.ts file.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
a11y-eng-secondary 💓collab a vibrant hub of collaboration react
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants