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
Show file tree
Hide file tree
Changes from 33 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 Jun 14, 2022
9ef18e3
Add `useSyntheticChange` hook
iansan5653 Jun 14, 2022
d14418e
Add `InlineAutocomplete` component
iansan5653 Jun 14, 2022
34ab6f3
Refactor and improve comments
iansan5653 Jun 16, 2022
f63e606
Remove extra type
iansan5653 Jun 16, 2022
2ef8d23
Add story and make it work with `FormControl`
iansan5653 Jun 16, 2022
02d8d1a
Add to main exports
iansan5653 Jun 16, 2022
ed4b12e
Add MDX file
iansan5653 Jun 16, 2022
c9b8ead
Merge branch 'main' of https://github.com/primer/react into add-inlin…
iansan5653 Jun 29, 2022
b110c98
Remove unecessary ID on textarea in story
iansan5653 Jun 29, 2022
c029876
Remove version-lock from new dependencies
iansan5653 Jun 29, 2022
d8dad97
Make type of render function more specific
iansan5653 Jun 29, 2022
375d6c9
Add unit tests
iansan5653 Jun 30, 2022
c53bf3d
Simplify `useCombobox` and use `navigate` to focus first item
iansan5653 Jul 1, 2022
7b55370
Fix tests by wrapping `userEvent.type` in `act`
iansan5653 Jul 12, 2022
1b39b54
Fix preventing blur when tabbing from loading state
iansan5653 Jul 12, 2022
0a30f05
Delete unused imports
iansan5653 Jul 12, 2022
9a97bdf
Change interfaces out for object types
iansan5653 Jul 12, 2022
af4ea26
Add accessible live status message to describe suggestions
iansan5653 Jul 12, 2022
431f1eb
Dynamically assign the combobox role to avoid treating the textarea a…
iansan5653 Jul 12, 2022
20b619e
Shorten & revise status message
iansan5653 Jul 15, 2022
010674c
Merge branch 'main' of https://github.com/primer/react into add-inlin…
iansan5653 Jul 19, 2022
b7ba947
Move to drafts
iansan5653 Jul 19, 2022
b3f0808
Move docs to drafts
iansan5653 Jul 19, 2022
de624b4
Fix import in docs
iansan5653 Jul 19, 2022
f0ccf34
Update combobox-nav dependency
iansan5653 Jul 20, 2022
ebd1c6f
Add option to control whether `Tab` key inserts suggestions
iansan5653 Jul 20, 2022
90bcff5
Style the defaulted-to first option differently from the selected option
iansan5653 Jul 20, 2022
82ba405
Update combobox-nav dependency
iansan5653 Jul 22, 2022
3373f33
Merge branch 'main' of https://github.com/primer/react into add-inlin…
iansan5653 Jul 28, 2022
0ee19db
Update and fix unit tests
iansan5653 Jul 28, 2022
af9bcc6
Remove unused import (fix lint error)
iansan5653 Jul 29, 2022
d37426d
docs: add drafts metastring
siddharthkp Aug 1, 2022
b6d2e28
Remove `selectionVariant` from suggestions list
iansan5653 Aug 1, 2022
841b41c
Merge branch 'add-inline-autocomplete-component' of https://github.co…
iansan5653 Aug 1, 2022
ac68a54
Merge branch 'main' of https://github.com/primer/react into add-inlin…
iansan5653 Aug 1, 2022
17b3747
Add `install:docs` script
iansan5653 Aug 1, 2022
1b7cb9c
Add more examples to docs
iansan5653 Aug 1, 2022
07af717
Add more stories
iansan5653 Aug 1, 2022
0a608ab
Fix _another_ bug with the caret-coordinates utility and single-line …
iansan5653 Aug 1, 2022
f48c8e5
Move component & hooks to drafts folder
iansan5653 Aug 1, 2022
dcfbffc
Move stories & tests into drafts
iansan5653 Aug 1, 2022
ed30cc6
Remove non-null assertions in tests
iansan5653 Aug 1, 2022
468b5a9
Move `textarea-caret` type declaration to `@types`
iansan5653 Aug 1, 2022
592b179
Add props table
iansan5653 Aug 1, 2022
69f3203
Fix TS issue
iansan5653 Aug 1, 2022
1456caf
Create cuddly-bags-sort.md
iansan5653 Aug 2, 2022
0d4c896
Merge branch 'main' into add-inline-autocomplete-component
siddharthkp Aug 2, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions docs/content/drafts/InlineAutocomplete.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
componentId: inline_autocomplete
title: InlineAutocomplete
status: Draft
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/drafts'

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 drafts
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
}}
/>
3 changes: 2 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ module.exports = {
'<rootDir>/src/utils/test-deprecations.tsx',
'<rootDir>/src/utils/test-helpers.tsx'
],
testMatch: ['<rootDir>/(src|codemods)/**/*.test.[jt]s?(x)', '!**/*.types.test.[jt]s?(x)']
testMatch: ['<rootDir>/(src|codemods)/**/*.test.[jt]s?(x)', '!**/*.types.test.[jt]s?(x)'],
transformIgnorePatterns: ['node_modules/(?!@github/combobox-nav|@koddsson/textarea-caret)']
}
22 changes: 22 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@
"npm": ">=7"
},
"dependencies": {
"@github/combobox-nav": "^2.1.5",
"@koddsson/textarea-caret": "^4.0.1",
"@primer/behaviors": "^1.1.1",
"@primer/octicons-react": "^17.3.0",
"@primer/primitives": "7.9.0",
Expand Down
12 changes: 11 additions & 1 deletion src/FormControl/FormControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {get} from '../constants'
import FormControlLeadingVisual from './_FormControlLeadingVisual'
import {SxProp} from '../sx'
import CheckboxOrRadioGroupContext from '../_CheckboxOrRadioGroup/_CheckboxOrRadioGroupContext'
import InlineAutocomplete from '../InlineAutocomplete'

export type FormControlProps = {
children?: React.ReactNode
Expand Down Expand Up @@ -38,7 +39,16 @@ export interface FormControlContext extends Pick<FormControlProps, 'disabled' |

const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>(
({children, disabled: disabledProp, layout, id: idProp, required, sx}, ref) => {
const expectedInputComponents = [Autocomplete, Checkbox, Radio, Select, TextInput, TextInputWithTokens, Textarea]
const expectedInputComponents = [
Autocomplete,
Checkbox,
Radio,
Select,
TextInput,
TextInputWithTokens,
Textarea,
InlineAutocomplete
]
const choiceGroupContext = useContext(CheckboxOrRadioGroupContext)
const disabled = choiceGroupContext?.disabled || disabledProp
const id = useSSRSafeId(idProp)
Expand Down
220 changes: 220 additions & 0 deletions src/InlineAutocomplete/InlineAutocomplete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import React, {cloneElement, useRef} from 'react'
import Box from '../Box'
import {useCombinedRefs} from '../hooks/useCombinedRefs'
import {useSyntheticChange} from '../hooks/useSyntheticChange'
import Portal from '../Portal'
import {BetterSystemStyleObject} from '../sx'

import {ShowSuggestionsEvent, Suggestions, TextInputCompatibleChild, TextInputElement, Trigger} from './types'
import {
augmentHandler,
calculateSuggestionsQuery,
getAbsoluteCharacterCoordinates,
getSuggestionValue,
requireChildrenToBeInput
} from './utils'
import AutocompleteSuggestions from './_AutocompleteSuggestions'

export type InlineAutocompleteProps = {
/** 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
/**
* If `true`, suggestions will be applied with both `Tab` and `Enter`, instead of just
* `Enter`. This may be expected behavior for users used to IDEs, but use caution when
* hijacking browser tabbing capability.
* @default false
*/
tabInsertsSuggestions?: boolean
/**
* 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,
tabInsertsSuggestions = false,
// 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
})

/**
* Even thoughn we apply all the aria attributes, screen readers don't fully support this
* dynamic use case and so they don't have a native way to indicate to the user when
* there are suggestions available. So we use some hidden text with aria-live to politely
* indicate what's available and how to use it.
*
* This text should be consistent and the important info should be first, because users
* will hear it as they type - if they have heard the message before they should be able
* to recognize it and quickly apply the first suggestion without listening to the rest
* of the message.
*
* When screen reader users navigate using arrow keys, the `aria-activedescendant` will
* change and will be read out so we don't need to handle that interaction here.
*/
const suggestionsDescription = !suggestionsVisible
? ''
: suggestions === 'loading'
? 'Loading autocomplete suggestions…'
: // It's important to include both Enter and Tab because we are telling the user that we are hijacking these keys:
`${suggestions.length} autocomplete ${
suggestions.length === 1 ? 'suggestion' : 'suggestions'
} available; "${getSuggestionValue(suggestions[0])}" is highlighted. Press ${
tabInsertsSuggestions ? 'Enter or Tab' : 'Enter'
} to insert.`

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}
tabInsertsSuggestions={tabInsertsSuggestions}
/>

<Portal>
{/* This should NOT be linked to the input with aria-describedby or screen readers may not read the live updates.
The assertive live attribute ensures the suggestions are read instead of the input label, which voiceover will try to re-read when the role changes. */}
<span aria-live="assertive" aria-atomic style={{clipPath: 'circle(0)'}}>
{suggestionsDescription}
</span>
</Portal>
</Box>
)
}

export default InlineAutocomplete
Loading