-
Notifications
You must be signed in to change notification settings - Fork 632
Add InlineAutocomplete
component
#2157
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
Merged
siddharthkp
merged 48 commits into
primer:main
from
iansan5653:add-inline-autocomplete-component
Aug 2, 2022
Merged
Changes from 13 commits
Commits
Show all changes
48 commits
Select commit
Hold shift + click to select a range
ca40174
Add `useCombobox` hook, extending `@github/combobox-nav`
iansan5653 9ef18e3
Add `useSyntheticChange` hook
iansan5653 d14418e
Add `InlineAutocomplete` component
iansan5653 34ab6f3
Refactor and improve comments
iansan5653 f63e606
Remove extra type
iansan5653 2ef8d23
Add story and make it work with `FormControl`
iansan5653 02d8d1a
Add to main exports
iansan5653 ed4b12e
Add MDX file
iansan5653 c9b8ead
Merge branch 'main' of https://github.com/primer/react into add-inlin…
iansan5653 b110c98
Remove unecessary ID on textarea in story
iansan5653 c029876
Remove version-lock from new dependencies
iansan5653 d8dad97
Make type of render function more specific
iansan5653 375d6c9
Add unit tests
iansan5653 c53bf3d
Simplify `useCombobox` and use `navigate` to focus first item
iansan5653 7b55370
Fix tests by wrapping `userEvent.type` in `act`
iansan5653 1b39b54
Fix preventing blur when tabbing from loading state
iansan5653 0a30f05
Delete unused imports
iansan5653 9a97bdf
Change interfaces out for object types
iansan5653 af4ea26
Add accessible live status message to describe suggestions
iansan5653 431f1eb
Dynamically assign the combobox role to avoid treating the textarea a…
iansan5653 20b619e
Shorten & revise status message
iansan5653 010674c
Merge branch 'main' of https://github.com/primer/react into add-inlin…
iansan5653 b7ba947
Move to drafts
iansan5653 b3f0808
Move docs to drafts
iansan5653 de624b4
Fix import in docs
iansan5653 f0ccf34
Update combobox-nav dependency
iansan5653 ebd1c6f
Add option to control whether `Tab` key inserts suggestions
iansan5653 90bcff5
Style the defaulted-to first option differently from the selected option
iansan5653 82ba405
Update combobox-nav dependency
iansan5653 3373f33
Merge branch 'main' of https://github.com/primer/react into add-inlin…
iansan5653 0ee19db
Update and fix unit tests
iansan5653 af9bcc6
Remove unused import (fix lint error)
iansan5653 d37426d
docs: add drafts metastring
siddharthkp b6d2e28
Remove `selectionVariant` from suggestions list
iansan5653 841b41c
Merge branch 'add-inline-autocomplete-component' of https://github.co…
iansan5653 ac68a54
Merge branch 'main' of https://github.com/primer/react into add-inlin…
iansan5653 17b3747
Add `install:docs` script
iansan5653 1b7cb9c
Add more examples to docs
iansan5653 07af717
Add more stories
iansan5653 0a608ab
Fix _another_ bug with the caret-coordinates utility and single-line …
iansan5653 f48c8e5
Move component & hooks to drafts folder
iansan5653 dcfbffc
Move stories & tests into drafts
iansan5653 ed30cc6
Remove non-null assertions in tests
iansan5653 468b5a9
Move `textarea-caret` type declaration to `@types`
iansan5653 592b179
Add props table
iansan5653 69f3203
Fix TS issue
iansan5653 1456caf
Create cuddly-bags-sort.md
iansan5653 0d4c896
Merge branch 'main' into add-inline-autocomplete-component
siddharthkp File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
--- | ||
componentId: inline_autocomplete | ||
title: InlineAutocomplete | ||
status: Experimental | ||
description: Provides inline auto completion suggestions for an input or textarea. | ||
source: https://github.com/primer/react/tree/main/src/InlineAutocomplete | ||
storybook: '/react/storybook?path=/story/forms-inlineautocomplete--default' | ||
--- | ||
|
||
import {InlineAutocomplete} from '@primer/react' | ||
|
||
The `InlineAutocomplete` component extends an `Input` or `Textarea` component to provide inline suggestions, similar to those provided by a code editor. | ||
|
||
## Examples | ||
|
||
<Note variant="warning"> | ||
|
||
Input components **must always** be accompanied by a corresponding label to improve support for assistive | ||
technologies. Examples below are provided for conciseness and may not reflect accessibility best practices. | ||
|
||
`InlineAutocomplete` can be used with the [`FormControl`](/FormControl) component to render a corresponding label. | ||
|
||
</Note> | ||
|
||
### Simple Example | ||
|
||
```javascript live noinline | ||
const hashtags = ['javascript', 'typescript', 'css', 'html', 'webassembly'] | ||
|
||
const EmojiPickerExample = () => { | ||
const [suggestions, setSuggestions] = React.useState([]) | ||
|
||
const onShowSuggestions = ({trigger}) => setSuggestions(hashtags.filter(tag => tag.includes(trigger))) | ||
|
||
const onHideSuggestions = () => setSuggestions([]) | ||
|
||
return ( | ||
<InlineAutocomplete | ||
triggers={[{triggerChar: '#'}]} | ||
suggestions={suggestions} | ||
onShowSuggestions={onShowSuggestions} | ||
onHideSuggestions={onHideSuggestions} | ||
> | ||
<Textarea /> | ||
</InlineAutocomplete> | ||
) | ||
} | ||
|
||
render(EmojiPickerExample) | ||
``` | ||
|
||
## Status | ||
|
||
<ComponentChecklist | ||
items={{ | ||
propsDocumented: false, | ||
noUnnecessaryDeps: true, | ||
adaptsToThemes: true, | ||
adaptsToScreenSizes: true, | ||
fullTestCoverage: false, | ||
usedInProduction: true, | ||
usageExamplesDocumented: false, | ||
hasStorybookStories: false, | ||
designReviewed: false, | ||
a11yReviewed: false, | ||
stableApi: false, | ||
addressedApiFeedback: false, | ||
hasDesignGuidelines: false, | ||
hasFigmaComponent: false | ||
}} | ||
/> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,177 @@ | ||
import React, {cloneElement, useRef} from 'react' | ||
import Box from '../Box' | ||
import {useCombinedRefs} from '../hooks/useCombinedRefs' | ||
import {useSyntheticChange} from '../hooks/useSyntheticChange' | ||
import {BetterSystemStyleObject} from '../sx' | ||
|
||
import {ShowSuggestionsEvent, Suggestions, TextInputCompatibleChild, TextInputElement, Trigger} from './types' | ||
import { | ||
augmentHandler, | ||
calculateSuggestionsQuery, | ||
getAbsoluteCharacterCoordinates, | ||
requireChildrenToBeInput | ||
} from './utils' | ||
import AutocompleteSuggestions from './_AutocompleteSuggestions' | ||
|
||
export interface InlineAutocompleteProps { | ||
iansan5653 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
/** Register the triggers that can cause suggestions to appear. */ | ||
triggers: Array<Trigger> | ||
/** | ||
* Called when a valid suggestion query is updated. This should be handled by setting the | ||
* `suggestions` prop accordingly. | ||
*/ | ||
onShowSuggestions: (event: ShowSuggestionsEvent) => void | ||
/** Called when suggestions should be hidden. Set `suggestions` to `null` in this case. */ | ||
onHideSuggestions: () => void | ||
/** | ||
* The currently visible list of suggestions. If `loading`, a loading indicator will be | ||
* shown. If `null` or empty, the list will be hidden. Suggestion sort will be preserved. | ||
* | ||
* Typically, this should not contain more than five or so suggestions. | ||
*/ | ||
suggestions: Suggestions | null | ||
/** | ||
* The `AutocompleteTextarea` has a container for positioning the suggestions overlay. | ||
* This can break some layouts (ie, if the editor must expand with `flex: 1` to fill space) | ||
* so you can override container styles here. Usually this should not be necessary. | ||
* `position` may not be overriden. | ||
*/ | ||
sx?: Omit<BetterSystemStyleObject, 'position'> | ||
// Typing this as such makes it look like a compatible child internally, but it isn't actually | ||
// enforced externally so we have to resort to a runtime assertion. | ||
/** | ||
* An `input` or `textarea` compatible component to extend. A compatible component is any | ||
* component that forwards a ref and props to an underlying `input` or `textarea` element, | ||
* including but not limited to `Input`, `TextArea`, `input`, `textarea`, `styled.input`, | ||
* and `styled.textarea`. If the child is not compatible, a runtime `TypeError` will be | ||
* thrown. | ||
*/ | ||
children: TextInputCompatibleChild | ||
} | ||
|
||
const getSelectionStart = (element: TextInputElement) => { | ||
try { | ||
return element.selectionStart | ||
} catch (e: unknown) { | ||
// Safari throws an exception when trying to access selectionStart on date input element | ||
if (e instanceof TypeError) return null | ||
throw e | ||
} | ||
} | ||
|
||
const noop = () => { | ||
// don't do anything | ||
} | ||
|
||
/** | ||
* Shows suggestions to complete the current word/phrase the user is actively typing. | ||
*/ | ||
const InlineAutocomplete = ({ | ||
triggers, | ||
suggestions, | ||
onShowSuggestions, | ||
onHideSuggestions, | ||
sx, | ||
children, | ||
// Forward accessibility props so it works with FormControl | ||
...forwardProps | ||
iansan5653 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}: InlineAutocompleteProps & React.ComponentProps<'textarea' | 'input'>) => { | ||
const inputRef = useCombinedRefs(children.ref) | ||
const externalInput = requireChildrenToBeInput(children, inputRef) | ||
|
||
const emitSyntheticChange = useSyntheticChange({ | ||
inputRef, | ||
fallbackEventHandler: externalInput.props.onChange ?? noop | ||
}) | ||
|
||
/** Stores the query that caused the current suggestion list to appear. */ | ||
const showEventRef = useRef<ShowSuggestionsEvent | null>(null) | ||
|
||
const suggestionsVisible = suggestions !== null && suggestions.length > 0 | ||
|
||
// The suggestions don't usually move while open, so it seems as though this could be | ||
// optimized by only re-rendering when suggestionsVisible changes. However, the user | ||
// could move the cursor to a different location using arrow keys and then type a | ||
// trigger, which would move the suggestions without closing/reopening them. | ||
const suggestionsOffset = | ||
inputRef.current && showEventRef.current && suggestionsVisible | ||
? getAbsoluteCharacterCoordinates( | ||
inputRef.current, | ||
// Position the suggestions at the trigger character, not the current caret position | ||
(getSelectionStart(inputRef.current) ?? 0) - showEventRef.current.query.length | ||
) | ||
: {top: 0, left: 0} | ||
|
||
// User can blur while suggestions are visible with shift+tab | ||
const onBlur: React.FocusEventHandler<TextInputElement> = () => { | ||
onHideSuggestions() | ||
} | ||
|
||
// Even though the overlay has an Escape listener, it only works when focus is inside | ||
// the overlay; in this case the textarea is focused | ||
const onKeyDown: React.KeyboardEventHandler<TextInputElement> = event => { | ||
if (suggestionsVisible && event.key === 'Escape') { | ||
onHideSuggestions() | ||
event.stopPropagation() | ||
} | ||
} | ||
|
||
const onChange: React.ChangeEventHandler<TextInputElement> = event => { | ||
const selectionStart = getSelectionStart(event.currentTarget) | ||
if (selectionStart === null) { | ||
onHideSuggestions() | ||
return | ||
} | ||
|
||
showEventRef.current = calculateSuggestionsQuery(triggers, event.currentTarget.value, selectionStart) | ||
|
||
if (showEventRef.current) { | ||
onShowSuggestions(showEventRef.current) | ||
} else { | ||
onHideSuggestions() | ||
} | ||
} | ||
|
||
const onCommit = (suggestion: string) => { | ||
if (!inputRef.current || !showEventRef.current) return | ||
const {query, trigger} = showEventRef.current | ||
|
||
const currentCaretPosition = getSelectionStart(inputRef.current) ?? 0 | ||
const deleteLength = query.length + trigger.triggerChar.length | ||
const startIndex = currentCaretPosition - deleteLength | ||
|
||
const keepTriggerChar = trigger.keepTriggerCharOnCommit ?? true | ||
const maybeTriggerChar = keepTriggerChar ? trigger.triggerChar : '' | ||
const replacement = `${maybeTriggerChar}${suggestion} ` | ||
|
||
emitSyntheticChange(replacement, [startIndex, startIndex + deleteLength]) | ||
onHideSuggestions() | ||
} | ||
|
||
const input = cloneElement(externalInput, { | ||
...forwardProps, | ||
onBlur: augmentHandler(externalInput.props.onBlur, onBlur), | ||
onKeyDown: augmentHandler(externalInput.props.onKeyDown, onKeyDown), | ||
onChange: augmentHandler(externalInput.props.onChange, onChange), | ||
ref: inputRef | ||
}) | ||
|
||
return ( | ||
// Try to get as close as possible to making the container 'invisible' by making it shrink | ||
// tight to child input | ||
<Box sx={{display: 'inline-block', '& > *': {width: '100%'}, ...sx, position: 'relative'}}> | ||
{input} | ||
<AutocompleteSuggestions | ||
suggestions={suggestions} | ||
inputRef={inputRef} | ||
onCommit={onCommit} | ||
onClose={onHideSuggestions} | ||
top={suggestionsOffset.top} | ||
left={suggestionsOffset.left} | ||
visible={suggestionsVisible} | ||
/> | ||
</Box> | ||
) | ||
} | ||
|
||
export default InlineAutocomplete |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.